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 { 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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
Reference in New Issue
Block a user