diff --git a/src/lib/folder-ref.ts b/src/lib/folder-ref.ts new file mode 100644 index 00000000..eed8b03f --- /dev/null +++ b/src/lib/folder-ref.ts @@ -0,0 +1,20 @@ +import type { Mailbox } from '../api/types'; + +/** Roles a folder link may name instead of an id (webmail lib/deep-links.ts). */ +const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'junk']); + +/** + * The folder a `mail/folder/` link names: an exact id first, then a role + * alias (the own account's folder before a shared one's). Null when nothing in + * the loaded folders matches, a link to a folder that was deleted or lives in + * another account (webmail resolveFolderRef). + */ +export function resolveFolderRef(ref: string, mailboxes: readonly Mailbox[]): string | null { + if (mailboxes.some((m) => m.id === ref)) return ref; + if (ALIASED_ROLES.has(ref)) { + const byRole = mailboxes.find((m) => m.role === ref && !m.isShared) + ?? mailboxes.find((m) => m.role === ref); + return byRole?.id ?? null; + } + return null; +} diff --git a/src/navigation/__tests__/folder-link.test.ts b/src/navigation/__tests__/folder-link.test.ts new file mode 100644 index 00000000..a4d03447 --- /dev/null +++ b/src/navigation/__tests__/folder-link.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { handleDeepLink, parseDeepLink } from '../linking'; +import { usePendingMailFolder } from '../pending-mail-folder'; +import { resolveFolderRef } from '../../lib/folder-ref'; +import type { Mailbox } from '../../api/types'; + +// A `mail/folder/` link opened only the Mail tab; the folder it named +// was dropped (file 10, "/mail/folder/ deep link opens only the tab"). + +const box = (id: string, role: string | null, isShared = false) => ({ id, name: id, role, isShared } as unknown as Mailbox); + +describe('mail/folder deep link', () => { + it('parks the folder for the mail list', async () => { + const link = parseDeepLink('bulwarkmobile://mail/folder/archive'); + const navigation = { isReady: () => true, navigate: vi.fn() }; + expect(await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null })).toBe(true); + expect(usePendingMailFolder.getState().consume()).toBe('archive'); + expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Mail' }); + }); + + it('resolves an id or a role, the own folder first, and nothing for an unknown one', () => { + const boxes = [box('g:in', 'inbox', true), box('in', 'inbox'), box('f1', null)]; + expect(resolveFolderRef('f1', boxes)).toBe('f1'); + expect(resolveFolderRef('inbox', boxes)).toBe('in'); + expect(resolveFolderRef('trash', boxes)).toBeNull(); + expect(resolveFolderRef('gone', boxes)).toBeNull(); + }); + + it('is opened by the mail list', () => { + const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/const id = ref === null \? null : resolveFolderRef\(ref, mailboxes\);/); + expect(list).toMatch(/if \(id !== currentMailboxId\) void selectMailbox\(id\);/); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 8b786423..b08028b5 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -9,6 +9,7 @@ import type { RootStackParamList } from './types'; import { setPendingSettingsTab } from './pending-settings-tab'; import { setPendingCalendarOpen } from './pending-calendar-open'; import { setPendingMailSearch } from './pending-mail-search'; +import { setPendingMailFolder } from './pending-mail-folder'; import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link'; import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth'; import { MAX_ACCOUNTS } from '../lib/account-utils'; @@ -283,6 +284,8 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr navigation.navigate('MainTabs', { screen: 'Mail' } as never); return true; case 'folder': + // The mail list opens the folder once its folders are loaded. + setPendingMailFolder(link.ref); navigation.navigate('MainTabs', { screen: 'Mail' } as never); return true; case 'calendar': diff --git a/src/navigation/pending-mail-folder.ts b/src/navigation/pending-mail-folder.ts new file mode 100644 index 00000000..3dd8413a --- /dev/null +++ b/src/navigation/pending-mail-folder.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand'; + +// A `mail/folder/` deep link arrives before the mail list is mounted or +// its folders are loaded. The reference is parked here and consumed by +// EmailListScreen, which opens the folder once it can resolve it. +interface PendingMailFolderState { + ref: string | null; + set: (ref: string | null) => void; + consume: () => string | null; +} + +export const usePendingMailFolder = create((set, get) => ({ + ref: null, + set: (ref) => set({ ref }), + consume: () => { + const ref = get().ref; + if (ref !== null) set({ ref: null }); + return ref; + }, +})); + +export function setPendingMailFolder(ref: string | null): void { + usePendingMailFolder.getState().set(ref); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index ba535963..32abe070 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -68,6 +68,8 @@ import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; import type { RootStackParamList } from '../navigation/types'; import { usePendingMailSearch } from '../navigation/pending-mail-search'; +import { usePendingMailFolder } from '../navigation/pending-mail-folder'; +import { resolveFolderRef } from '../lib/folder-ref'; import type { Attachment, Email } from '../api/types'; import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; @@ -1202,6 +1204,18 @@ export default function EmailListScreen({ } }, [ensureMailboxes, mailboxes.length]); + // A `mail/folder/` link opens its folder once the folders are here; + // one that names no folder of this account leaves the list as it is. + const pendingFolder = usePendingMailFolder((s) => s.ref); + React.useEffect(() => { + if (pendingFolder === null || mailboxes.length === 0) return; + const ref = usePendingMailFolder.getState().consume(); + const id = ref === null ? null : resolveFolderRef(ref, mailboxes); + if (!id) return; + clearSearchAndFilters(); + if (id !== currentMailboxId) void selectMailbox(id); + }, [pendingFolder, mailboxes, currentMailboxId, selectMailbox, clearSearchAndFilters]); + React.useEffect(() => { if (mailboxes.length > 0 && !currentMailboxId) { const own = ownMailboxes(mailboxes);