fix(mobile): replay cold-start notification taps after auth bootstrap
This commit is contained in:
parent
7a8e1e3e25
commit
9a5333c617
5 changed files with 396 additions and 22 deletions
|
|
@ -0,0 +1,92 @@
|
|||
import type { NotificationNavigationTarget } from './notification-contract'
|
||||
|
||||
/**
|
||||
* What the navigation shell can currently accept. Kept as plain booleans so
|
||||
* the routing policy stays independent of React, auth and navigation code.
|
||||
*/
|
||||
export interface NotificationNavigationReadiness {
|
||||
/** The NavigationContainer is mounted and can dispatch actions. */
|
||||
navigationReady: boolean
|
||||
/**
|
||||
* Auth and preferences bootstrap finished, a user is signed in and the
|
||||
* authenticated app stack (not onboarding / password recovery) is mounted.
|
||||
*/
|
||||
authenticatedAppReady: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation links are the only targets reachable while signed out; every
|
||||
* other notification points at account data and must wait for the
|
||||
* authenticated stack.
|
||||
*/
|
||||
export function canNavigateToNotificationTarget(
|
||||
target: NotificationNavigationTarget,
|
||||
readiness: NotificationNavigationReadiness,
|
||||
): boolean {
|
||||
if (!readiness.navigationReady) return false
|
||||
return target.name === 'InviteAccept' || readiness.authenticatedAppReady
|
||||
}
|
||||
|
||||
/**
|
||||
* A notification opened while signed out (or during a cold start) may wait
|
||||
* for a login, but not forever: a stale tap must not hijack navigation long
|
||||
* after the user moved on.
|
||||
*/
|
||||
export const PENDING_NOTIFICATION_TARGET_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
export interface NotificationNavigationQueue {
|
||||
/** Remember the latest opened notification target (latest wins). */
|
||||
offer(target: NotificationNavigationTarget): void
|
||||
/**
|
||||
* Remove and return the pending target when the shell can navigate to it
|
||||
* now; otherwise keep it for a later attempt and return null.
|
||||
*/
|
||||
take(readiness: NotificationNavigationReadiness): NotificationNavigationTarget | null
|
||||
clear(): void
|
||||
hasPending(): boolean
|
||||
}
|
||||
|
||||
interface QueueOptions {
|
||||
now?: () => number
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export function createNotificationNavigationQueue({
|
||||
now = Date.now,
|
||||
ttlMs = PENDING_NOTIFICATION_TARGET_TTL_MS,
|
||||
}: QueueOptions = {}): NotificationNavigationQueue {
|
||||
let pending: { target: NotificationNavigationTarget; offeredAt: number } | null = null
|
||||
|
||||
const dropExpired = (): void => {
|
||||
if (pending !== null && now() - pending.offeredAt > ttlMs) pending = null
|
||||
}
|
||||
|
||||
return {
|
||||
offer(target) {
|
||||
pending = { target, offeredAt: now() }
|
||||
},
|
||||
take(readiness) {
|
||||
dropExpired()
|
||||
if (pending === null) return null
|
||||
if (!canNavigateToNotificationTarget(pending.target, readiness)) return null
|
||||
const { target } = pending
|
||||
pending = null
|
||||
return target
|
||||
},
|
||||
clear() {
|
||||
pending = null
|
||||
},
|
||||
hasPending() {
|
||||
dropExpired()
|
||||
return pending !== null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-lifetime queue. It deliberately lives outside React: the native
|
||||
* initial-notification payload is one-shot, and the root navigator can
|
||||
* unmount and remount while auth/preferences bootstrap (for example when the
|
||||
* preferences owner switches from the installation to the signed-in user).
|
||||
*/
|
||||
export const notificationNavigationQueue = createNotificationNavigationQueue()
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Suppresses duplicate deliveries of the same opened notification (the
|
||||
* initial-notification read and the opened event can both report one tap).
|
||||
*
|
||||
* A key is only recorded as handled once the caller confirms delivery, so a
|
||||
* failed or abandoned attempt never blocks a later retry of the same tap.
|
||||
*/
|
||||
export interface NotificationOpenDedupe {
|
||||
/** Returns false when the key was already delivered or is being delivered. */
|
||||
begin(key: string): boolean
|
||||
/** Mark the key as delivered. */
|
||||
complete(key: string): void
|
||||
/** Release an in-flight key without recording it (delivery failed). */
|
||||
abort(key: string): void
|
||||
}
|
||||
|
||||
export const NOTIFICATION_OPEN_DEDUPE_LIMIT = 100
|
||||
|
||||
export function createNotificationOpenDedupe(
|
||||
limit = NOTIFICATION_OPEN_DEDUPE_LIMIT,
|
||||
): NotificationOpenDedupe {
|
||||
let handled = new Set<string>()
|
||||
const inFlight = new Set<string>()
|
||||
|
||||
return {
|
||||
begin(key) {
|
||||
if (handled.has(key) || inFlight.has(key)) return false
|
||||
inFlight.add(key)
|
||||
return true
|
||||
},
|
||||
complete(key) {
|
||||
inFlight.delete(key)
|
||||
handled.add(key)
|
||||
if (handled.size > limit) handled = new Set([key])
|
||||
},
|
||||
abort(key) {
|
||||
inFlight.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAuth } from '../../lib/auth-context'
|
||||
import { useDevice } from '../../lib/device-context'
|
||||
import { savePendingInviteToken } from '../teams/pending-invite'
|
||||
|
|
@ -14,9 +14,16 @@ import {
|
|||
onNotificationOpened,
|
||||
onPushRegistrationChanged,
|
||||
} from './notification-native'
|
||||
import { createNotificationOpenDedupe } from './notification-open-dedupe'
|
||||
import { synchronizePushRegistration } from './notification-service'
|
||||
|
||||
interface NotificationRuntimeOptions {
|
||||
/**
|
||||
* Receives every opened notification target. It may be invoked after the
|
||||
* caller re-rendered or unmounted (the native initial payload is one-shot),
|
||||
* so it must accept the target even when it cannot navigate yet — e.g. by
|
||||
* queueing it until the navigation shell is ready.
|
||||
*/
|
||||
navigate: (target: NotificationNavigationTarget) => void
|
||||
onFullSyncRequired?: () => Promise<boolean>
|
||||
onError?: (error: unknown) => void
|
||||
|
|
@ -30,7 +37,6 @@ export function useNotificationRuntime({
|
|||
const { user } = useAuth()
|
||||
const { currentDevice } = useDevice()
|
||||
const authRef = useRef({ user, currentDevice })
|
||||
const handledRef = useRef(new Set<string>())
|
||||
authRef.current = { user, currentDevice }
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -63,23 +69,34 @@ export function useNotificationRuntime({
|
|||
}
|
||||
}, [currentDevice, onError, onFullSyncRequired, user])
|
||||
|
||||
// The native initial notification is one-shot (the module clears it on
|
||||
// read), so the open listener must not be torn down and re-registered each
|
||||
// time the caller's navigate callback changes identity: a payload resolved
|
||||
// by a cancelled effect would otherwise be dropped. Always hand targets to
|
||||
// the latest callback instead.
|
||||
const navigateRef = useRef(navigate)
|
||||
const onErrorRef = useRef(onError)
|
||||
const [dedupe] = useState(createNotificationOpenDedupe)
|
||||
navigateRef.current = navigate
|
||||
onErrorRef.current = onError
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const handle = async (payload: Record<string, string>): Promise<void> => {
|
||||
let dedupeKey: string | null = null
|
||||
try {
|
||||
const notification = parseNotificationPayload(payload)
|
||||
const dedupeKey = `${notification.eventType}:${notification.resourceId}`
|
||||
if (handledRef.current.has(dedupeKey)) return
|
||||
handledRef.current.add(dedupeKey)
|
||||
if (handledRef.current.size > 100) {
|
||||
handledRef.current = new Set([dedupeKey])
|
||||
}
|
||||
const key = `${notification.eventType}:${notification.resourceId}`
|
||||
if (!dedupe.begin(key)) return
|
||||
dedupeKey = key
|
||||
if (notification.eventType === 'team.invite.created') {
|
||||
await savePendingInviteToken(notification.inviteToken)
|
||||
}
|
||||
if (active) navigate(notificationNavigationTarget(notification))
|
||||
navigateRef.current(notificationNavigationTarget(notification))
|
||||
dedupe.complete(key)
|
||||
} catch (error) {
|
||||
if (active) onError?.(error)
|
||||
if (dedupeKey !== null) dedupe.abort(dedupeKey)
|
||||
if (active) onErrorRef.current?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,12 +105,12 @@ export function useNotificationRuntime({
|
|||
.then((payload) => {
|
||||
if (payload !== null) return handle(payload)
|
||||
})
|
||||
.catch((error) => { if (active) onError?.(error) })
|
||||
.catch((error) => { if (active) onErrorRef.current?.(error) })
|
||||
return () => {
|
||||
active = false
|
||||
opened.remove()
|
||||
}
|
||||
}, [navigate, onError])
|
||||
}, [dedupe])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue