From f9a36b28925774e64a845c2c319b0b47611b2854 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:24:56 +0100 Subject: [PATCH] fix: open a notification or link for another account over that account's inbox --- App.tsx | 39 ++++++++++----------- src/navigation/__tests__/linking.test.ts | 37 +++++++++++++++++++- src/navigation/linking.ts | 43 +++++++++++++++++++++--- 3 files changed, 92 insertions(+), 27 deletions(-) diff --git a/App.tsx b/App.tsx index e0729c85..27414f71 100644 --- a/App.tsx +++ b/App.tsx @@ -82,10 +82,12 @@ import { signOutWidgets, startWidgetSync } from './src/widgets/sync'; import { draftContextFromEmail } from './src/lib/draft-context'; import { acceptSignInLink, + enterLinkAccount, handleDeepLink, parseDeepLink, routeParkedSignInLink, shareToDeepLink, + type AccountSwitchResult, type DeepLink, } from './src/navigation/linking'; import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link'; @@ -107,20 +109,26 @@ const navigationRef = createNavigationContainerRef(); // on every getInitialURL(), and a sign-in link must not run twice. let initialSignInLinkRead = false; +// Move to the account a notification, reminder or link names. An account +// that was signed out meanwhile has nothing safe to open. +async function switchToAccount(accountId: string): Promise { + const auth = useAuthStore.getState(); + if (auth.activeAccountId === accountId) return true; + if (!useAccountStore.getState().getAccountById(accountId)) return false; + await auth.switchAccount(accountId); + return useAuthStore.getState().activeAccountId === accountId ? 'switched' : false; +} + async function navigateToNotificationTap(payload: NotificationTapPayload): Promise { if (!navigationRef.isReady()) return; // The notification carries the account it was generated for. If the user // has since switched to a different account (or had a different one active // when the notification arrived), opening EmailThread under the active - // account would fetch the email from the wrong server and fail. - const auth = useAuthStore.getState(); - if (payload.accountId && payload.accountId !== auth.activeAccountId) { - const account = useAccountStore.getState().getAccountById(payload.accountId); - if (!account) return; // account was logged out — nothing safe to open. - await auth.switchAccount(payload.accountId); - if (useAuthStore.getState().activeAccountId !== payload.accountId) return; - } + // account would fetch the email from the wrong server and fail. After a + // switch the stack starts over on that account's inbox, so Back from the + // message lands there instead of on the old account's screens. + if (!(await enterLinkAccount(navigationRef, payload.accountId, switchToAccount))) return; prefetchMessage({ id: payload.emailId, threadId: payload.threadId }); navigationRef.navigate('EmailThread', { @@ -138,12 +146,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi // under the account it was scheduled for. The target is parked for // CalendarScreen, which also picks it up when it mounts after a cold start. async function openCalendarReminder(target: CalendarReminderTarget): Promise { - const auth = useAuthStore.getState(); - if (target.appAccountId && target.appAccountId !== auth.activeAccountId) { - if (!useAccountStore.getState().getAccountById(target.appAccountId)) return; - await auth.switchAccount(target.appAccountId); - if (useAuthStore.getState().activeAccountId !== target.appAccountId) return; - } + if (!(await enterLinkAccount(navigationRef, target.appAccountId, switchToAccount))) return; setPendingCalendarOpen(target); // On a cold start the navigator mounts right after the auth gate flips. for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) { @@ -178,13 +181,7 @@ async function openDeepLink(link: DeepLink): Promise { return false; } }, - switchAccount: async (accountId) => { - const auth = useAuthStore.getState(); - if (auth.activeAccountId === accountId) return true; - if (!useAccountStore.getState().getAccountById(accountId)) return false; - await auth.switchAccount(accountId); - return useAuthStore.getState().activeAccountId === accountId; - }, + switchAccount: switchToAccount, }); // A widget or a link elsewhere brought the app up for this; say it failed // rather than leave the user wondering why the app just opened. diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index be7c5cf3..f0f18907 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking'; +import { acceptSignInLink, enterLinkAccount, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking'; +import { usePendingMailFolder } from '../pending-mail-folder'; import { usePendingSignInLinkStore } from '../pending-sign-in-link'; import { usePendingSettingsTab } from '../pending-settings-tab'; import { usePendingCalendarOpen } from '../pending-calendar-open'; @@ -168,6 +169,40 @@ describe('handleDeepLink', () => { })).toBe(false); }); + it('starts the stack over on the inbox of the account a link switched to', async () => { + const calls: unknown[][] = []; + const navigation = { + isReady: () => true, + navigate: vi.fn((...args: unknown[]) => { calls.push(['navigate', ...args]); }), + reset: vi.fn((state: unknown) => { calls.push(['reset', state]); }), + }; + const ok = await handleDeepLink( + { kind: 'message', emailId: 'M1', threadId: 'T1', accountId: 'b@example.org@mail' }, + { navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => 'switched' }, + ); + expect(ok).toBe(true); + // The old account's screens go, the message opens over the new inbox. + expect(calls).toEqual([ + ['reset', { index: 0, routes: [{ name: 'MainTabs', params: { screen: 'Mail' } }] }], + ['navigate', 'EmailThread', { emailId: 'M1', threadId: 'T1' }], + ]); + expect(usePendingMailFolder.getState().consume()).toBe('inbox'); + }); + + it('leaves the stack alone when the link names the active account', async () => { + const navigation = { isReady: () => true, navigate: vi.fn(), reset: vi.fn() }; + await handleDeepLink( + { kind: 'message', emailId: 'M1', threadId: 'T1', accountId: 'a@example.org@mail' }, + { navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => true }, + ); + expect(navigation.reset).not.toHaveBeenCalled(); + expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1' }); + // A notification tap goes the same way (App navigateToNotificationTap). + expect(await enterLinkAccount(navigation as never, 'gone@example.org@mail', async () => false)).toBe(false); + expect(await enterLinkAccount(navigation as never, undefined, async () => 'switched')).toBe(true); + expect(navigation.reset).not.toHaveBeenCalled(); + }); + it('parks the settings tab and opens the Settings tab', async () => { const navigation = nav(); await handleDeepLink( diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index ddadeac4..a46efb38 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -276,6 +276,40 @@ export function routeParkedSignInLink(accountCount: number): 'add-account' | 'ac return 'add-account'; } +/** + * What switching to the account a link or notification names came to: false + * when that account is not signed in here (or the switch failed), true when + * it was the active one already, 'switched' when the app moved to it. + */ +export type AccountSwitchResult = boolean | 'switched'; + +/** + * After a link or notification switched accounts, the screens on the stack + * are the old account's. Start the stack over on the new account's inbox, so + * whatever the link opens goes on top of it and Back lands there. + */ +export function resetToAccountInbox(navigation: NavigationContainerRefWithCurrent): void { + setPendingMailFolder('inbox'); + navigation.reset({ index: 0, routes: [{ name: 'MainTabs', params: { screen: 'Mail' } }] } as never); +} + +/** + * Move to the account `accountId` names, if it is not the active one, and + * start the stack over when the app moved (resetToAccountInbox). False when + * the account is not signed in here: nothing may be opened then. + */ +export async function enterLinkAccount( + navigation: NavigationContainerRefWithCurrent, + accountId: string | undefined, + switchAccount: ((accountId: string) => Promise) | undefined, +): Promise { + if (!accountId || !switchAccount) return true; + const result = await switchAccount(accountId); + if (!result) return false; + if (result === 'switched' && navigation.isReady()) resetToAccountInbox(navigation); + return true; +} + export interface DeepLinkNavigator { navigation: NavigationContainerRefWithCurrent; // Resolve a message id to its thread (EmailThread needs both). Returns @@ -286,8 +320,9 @@ export interface DeepLinkNavigator { // be loaded. openDraft?: (emailId: string, jmapAccountId?: string) => Promise; // Switch to the account a permalink names (`?account=`); resolves false - // when that account is not signed in on this device. - switchAccount?: (accountId: string) => Promise; + // when that account is not signed in on this device, 'switched' when the + // app moved to it (see AccountSwitchResult). + switchAccount?: (accountId: string) => Promise; } /** Navigate for a parsed link. Returns false when nothing could be opened. */ @@ -295,9 +330,7 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr const { navigation } = nav; if (!navigation.isReady()) return false; - if ('accountId' in link && link.accountId && nav.switchAccount) { - if (!(await nav.switchAccount(link.accountId))) return false; - } + if ('accountId' in link && !(await enterLinkAccount(navigation, link.accountId, nav.switchAccount))) return false; switch (link.kind) { case 'message': {