From 8da335d7788ed3390607b58bb248cd144201deb5 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 14:26:13 -0400 Subject: [PATCH] fix(mobile): reconcile dismissed pushes on first connection --- .../src/notifications/mobile-notifications.ts | 8 +++-- ...notification-reopen-push-duplicate.test.ts | 33 +++++++++++++++++++ .../push-dismissal-reconciliation.ts | 8 +++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 508952afb7f..61fa439e4fc 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -246,11 +246,13 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin // left over from a previous desktop lifetime, so the catch-up request carries // a watermark that means something against the counter now answering it. adoptNotificationEpoch(session, hostId, readyEpoch) - // A reconnect always catches up. A cold open catches up only when this device - // has delivered for this host before — a first-ever pairing must not be handed - // the desktop's whole retained buffer. + // First pairing recovers tray dismissals without replaying historical alerts. if (isReconnect || session.hadStoredWatermark) { await fetchMissed() + } else { + await requestNotificationCatchup(client, hostId, undefined, () => disposed).catch( + () => {} + ) } })() return diff --git a/mobile/src/notifications/notification-reopen-push-duplicate.test.ts b/mobile/src/notifications/notification-reopen-push-duplicate.test.ts index e6a0bd9287f..7d3a1afdfa0 100644 --- a/mobile/src/notifications/notification-reopen-push-duplicate.test.ts +++ b/mobile/src/notifications/notification-reopen-push-duplicate.test.ts @@ -148,6 +148,39 @@ beforeEach(() => { }) describe('reopen after a push the OS showed while Orca was closed', () => { + it('recovers a dismissed push without a stored watermark or replaying historical alerts', async () => { + const identity = { notificationId: 'a:6', notificationSeq: 6, notificationEpoch: 'epoch-1' } + presentTray([{ hostFingerprint, ...identity }]) + const { client, ready } = catchUpClient() + vi.mocked(client.sendRequest).mockResolvedValue({ + ok: true, + result: { + dismissedPushes: [identity], + notifications: [{ type: 'notification', title: 'Historical alert', notificationSeq: 5 }] + } + } as never) + const dispose = subscribeToDesktopNotifications(client, 'host-1') + ready() + await flushAsync() + expect(client.sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', { + lastSeenSeq: Number.MAX_SAFE_INTEGER, + deliveredPushes: [identity] + }) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('tray-0') + expect(shownTitles()).toEqual([]) + expect(persistedSeq()).toBe(0) + dispose() + }) + + it('does not request history on a first pairing with an empty tray', async () => { + const { client, ready } = catchUpClient() + const dispose = subscribeToDesktopNotifications(client, 'host-1') + ready() + await flushAsync() + expect(client.sendRequest).not.toHaveBeenCalled() + dispose() + }) + it('replays only the events still missing from the tray', async () => { presentTray([ { hostFingerprint, notificationId: 'a:6', notificationSeq: 6, notificationEpoch: 'epoch-1' } diff --git a/mobile/src/notifications/push-dismissal-reconciliation.ts b/mobile/src/notifications/push-dismissal-reconciliation.ts index 619b1af9c46..8262a9068d4 100644 --- a/mobile/src/notifications/push-dismissal-reconciliation.ts +++ b/mobile/src/notifications/push-dismissal-reconciliation.ts @@ -61,12 +61,16 @@ async function readDelivered(hostId: string): Promise, hostId: string, - params: { lastSeenSeq: number; epoch?: string; includeDesktopSuppressed?: boolean }, + params: { lastSeenSeq: number; epoch?: string; includeDesktopSuppressed?: boolean } | undefined, isDisposed: () => boolean ) { const delivered = await readDelivered(hostId) + if (!params && (delivered.size === 0 || isDisposed())) { + return { ok: true, result: { notifications: [] } } + } const response = await client.sendRequest('notifications.getMissedSince', { - ...params, + // First pairing reconciles the tray without requesting historical alerts. + ...(params ?? { lastSeenSeq: Number.MAX_SAFE_INTEGER }), ...(delivered.size ? { deliveredPushes: [...delivered.values()].map((payload) => identity(payload)!) } : {})