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.
91 lines
2.8 KiB
TypeScript
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;
|
|
}
|