fix: open a notification or link for another account over that account's inbox

This commit is contained in:
Linus Rath
2026-10-31 13:24:56 +01:00
parent 688e8bef3b
commit f9a36b2892
3 changed files with 92 additions and 27 deletions
+18 -21
View File
@@ -82,10 +82,12 @@ import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
import { draftContextFromEmail } from './src/lib/draft-context'; import { draftContextFromEmail } from './src/lib/draft-context';
import { import {
acceptSignInLink, acceptSignInLink,
enterLinkAccount,
handleDeepLink, handleDeepLink,
parseDeepLink, parseDeepLink,
routeParkedSignInLink, routeParkedSignInLink,
shareToDeepLink, shareToDeepLink,
type AccountSwitchResult,
type DeepLink, type DeepLink,
} from './src/navigation/linking'; } from './src/navigation/linking';
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link'; import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
@@ -107,20 +109,26 @@ const navigationRef = createNavigationContainerRef<RootStackParamList>();
// on every getInitialURL(), and a sign-in link must not run twice. // on every getInitialURL(), and a sign-in link must not run twice.
let initialSignInLinkRead = false; 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<AccountSwitchResult> {
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<void> { async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return; if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user // 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 // has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active // when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail. // account would fetch the email from the wrong server and fail. After a
const auth = useAuthStore.getState(); // switch the stack starts over on that account's inbox, so Back from the
if (payload.accountId && payload.accountId !== auth.activeAccountId) { // message lands there instead of on the old account's screens.
const account = useAccountStore.getState().getAccountById(payload.accountId); if (!(await enterLinkAccount(navigationRef, payload.accountId, switchToAccount))) return;
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
prefetchMessage({ id: payload.emailId, threadId: payload.threadId }); prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
navigationRef.navigate('EmailThread', { 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 // 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. // CalendarScreen, which also picks it up when it mounts after a cold start.
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> { async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
const auth = useAuthStore.getState(); if (!(await enterLinkAccount(navigationRef, target.appAccountId, switchToAccount))) return;
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;
}
setPendingCalendarOpen(target); setPendingCalendarOpen(target);
// On a cold start the navigator mounts right after the auth gate flips. // On a cold start the navigator mounts right after the auth gate flips.
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) { for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
@@ -178,13 +181,7 @@ async function openDeepLink(link: DeepLink): Promise<void> {
return false; return false;
} }
}, },
switchAccount: async (accountId) => { switchAccount: switchToAccount,
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;
},
}); });
// A widget or a link elsewhere brought the app up for this; say it failed // 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. // rather than leave the user wondering why the app just opened.
+36 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest'; 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 { usePendingSignInLinkStore } from '../pending-sign-in-link';
import { usePendingSettingsTab } from '../pending-settings-tab'; import { usePendingSettingsTab } from '../pending-settings-tab';
import { usePendingCalendarOpen } from '../pending-calendar-open'; import { usePendingCalendarOpen } from '../pending-calendar-open';
@@ -168,6 +169,40 @@ describe('handleDeepLink', () => {
})).toBe(false); })).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 () => { it('parks the settings tab and opens the Settings tab', async () => {
const navigation = nav(); const navigation = nav();
await handleDeepLink( await handleDeepLink(
+38 -5
View File
@@ -276,6 +276,40 @@ export function routeParkedSignInLink(accountCount: number): 'add-account' | 'ac
return 'add-account'; 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<RootStackParamList>): 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<RootStackParamList>,
accountId: string | undefined,
switchAccount: ((accountId: string) => Promise<AccountSwitchResult>) | undefined,
): Promise<boolean> {
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 { export interface DeepLinkNavigator {
navigation: NavigationContainerRefWithCurrent<RootStackParamList>; navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
// Resolve a message id to its thread (EmailThread needs both). Returns // Resolve a message id to its thread (EmailThread needs both). Returns
@@ -286,8 +320,9 @@ export interface DeepLinkNavigator {
// be loaded. // be loaded.
openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>; openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
// Switch to the account a permalink names (`?account=`); resolves false // Switch to the account a permalink names (`?account=`); resolves false
// when that account is not signed in on this device. // when that account is not signed in on this device, 'switched' when the
switchAccount?: (accountId: string) => Promise<boolean>; // app moved to it (see AccountSwitchResult).
switchAccount?: (accountId: string) => Promise<AccountSwitchResult>;
} }
/** Navigate for a parsed link. Returns false when nothing could be opened. */ /** 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; const { navigation } = nav;
if (!navigation.isReady()) return false; if (!navigation.isReady()) return false;
if ('accountId' in link && link.accountId && nav.switchAccount) { if ('accountId' in link && !(await enterLinkAccount(navigation, link.accountId, nav.switchAccount))) return false;
if (!(await nav.switchAccount(link.accountId))) return false;
}
switch (link.kind) { switch (link.kind) {
case 'message': { case 'message': {