Files
crowd-funder-for-time-pwa/src/services/notifications/alertSearchNotificationTap.ts
T
Jose Olarte III 1fb322e013 Open New Activity when the AlertSearch notification is tapped, including after a cold start.
The tap uses the existing deep link, and Android and iOS now forward the launch tap that Capacitor drops. Receiving the push still does not navigate.
2026-10-19 12:12:28 -04:00

91 lines
2.8 KiB
TypeScript

/**
* Routes a tap on the daily New Activity FCM notification to the in-app form of
* `/deep-link/new-activity`.
*
* Arrival (`pushNotificationReceived`) must not call this. Foreground, background
* resume, and cold start all arrive as `pushNotificationActionPerformed`: Capacitor
* retains that event until the listener exists, and the native shells forward the
* launch intent into the same event. A launch check and the listener can both
* see one tap, so the same notification is opened once.
*/
const ALERT_SEARCH_FCM_TYPE = "alert_search";
/** Long enough to cover the startup listener registering after a launch forward. */
const DEDUPE_WINDOW_MS = 5000;
const recentTapAt = new Map<string, number>();
export interface PushNotificationTap {
actionId?: string;
notification?: {
id?: string;
data?: unknown;
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNotificationBodyTap(actionId: string | undefined): boolean {
return actionId === undefined || actionId === "" || actionId === "tap";
}
/**
* `timesafari://new-activity` is the in-app deep link for `/deep-link/new-activity`.
* Optional `c` is the notification's start cursors.
*/
export function alertSearchDeepLinkForTap(
tap: PushNotificationTap,
): string | null {
if (!isNotificationBodyTap(tap.actionId)) return null;
const data = tap.notification?.data;
if (!isRecord(data) || data.type !== ALERT_SEARCH_FCM_TYPE) return null;
const c =
typeof data.c === "string" && data.c.length > 0 ? data.c : undefined;
return c
? `timesafari://new-activity?c=${encodeURIComponent(c)}`
: "timesafari://new-activity";
}
function dedupeKey(tap: PushNotificationTap, url: string): string {
const id = tap.notification?.id?.trim();
return id ? `id:${id}` : `url:${url}`;
}
function claimTap(key: string, now: number): boolean {
const previous = recentTapAt.get(key);
if (previous !== undefined && now - previous < DEDUPE_WINDOW_MS) {
return false;
}
recentTapAt.set(key, now);
return true;
}
/** Clears the tap dedupe window. Tests use this between cases. */
export function resetAlertSearchNotificationTapState(): void {
recentTapAt.clear();
}
/**
* Open `/deep-link/new-activity` for an AlertSearch notification tap.
* Returns false when the action is not that tap, or when this tap was already opened.
*/
export async function openAlertSearchFromNotificationTap(
tap: PushNotificationTap,
openDeepLink: (url: string) => Promise<void>,
now: number = Date.now(),
): Promise<boolean> {
const url = alertSearchDeepLinkForTap(tap);
if (!url) return false;
const key = dedupeKey(tap, url);
if (!claimTap(key, now)) return false;
try {
await openDeepLink(url);
} catch (error) {
recentTapAt.delete(key);
throw error;
}
return true;
}