mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: open a notification or link for another account over that account's inbox
This commit is contained in:
@@ -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<RootStackParamList>();
|
||||
// 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<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> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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 {
|
||||
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
|
||||
// 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<boolean>;
|
||||
// Switch to the account a permalink names (`?account=`); resolves false
|
||||
// when that account is not signed in on this device.
|
||||
switchAccount?: (accountId: string) => Promise<boolean>;
|
||||
// when that account is not signed in on this device, 'switched' when the
|
||||
// app moved to it (see AccountSwitchResult).
|
||||
switchAccount?: (accountId: string) => Promise<AccountSwitchResult>;
|
||||
}
|
||||
|
||||
/** 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': {
|
||||
|
||||
Reference in New Issue
Block a user