diff --git a/App.tsx b/App.tsx index bc631fa8..31b0ddc7 100644 --- a/App.tsx +++ b/App.tsx @@ -58,10 +58,11 @@ import { useCalendarStore } from './src/stores/calendar-store'; import { useContactsStore } from './src/stores/contacts-store'; import { useEmailStore, viewerParamsForRow } from './src/stores/email-store'; import { mailboxAccountId } from './src/lib/mailbox-tree'; -import { prefetchMessage } from './src/lib/email-detail-cache'; +import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; import { useLocaleStore } from './src/stores/locale-store'; +import { toast } from './src/stores/toast-store'; import { useNetworkStore } from './src/stores/network-store'; import { useUpdatesStore } from './src/stores/updates-store'; import { UpdateBanner } from './src/components/UpdateBanner'; @@ -69,10 +70,9 @@ import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt'; import { ToastHost } from './src/components/ToastHost'; import { UndoSnackbar } from './src/components/UndoSnackbar'; import { AppIconBadge } from './src/components/AppIconBadge'; -import { getEmails, getFullEmail } from './src/api/email'; +import { getEmails } from './src/api/email'; import { signOutWidgets, startWidgetSync } from './src/widgets/sync'; import { draftContextFromEmail } from './src/lib/draft-context'; -import { replyComposeParams } from './src/lib/reply-compose'; import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking'; import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent'; import { OfflineCacheBanner } from './src/components/OfflineCacheBanner'; @@ -134,7 +134,7 @@ async function openCalendarReminder(target: CalendarReminderTarget): Promise { - await handleDeepLink(link, { + const opened = await handleDeepLink(link, { navigation: navigationRef, resolveThreadId: async (emailId, jmapAccountId) => { try { @@ -144,19 +144,10 @@ async function openDeepLink(link: DeepLink): Promise { return null; } }, - openReply: async (emailId, jmapAccountId) => { - try { - const params = replyComposeParams('reply', await getFullEmail(emailId, jmapAccountId), jmapAccountId); - if (!params || !navigationRef.isReady()) return false; - navigationRef.navigate('Compose', params); - return true; - } catch { - return false; - } - }, openDraft: async (emailId, jmapAccountId) => { try { - const draft = draftContextFromEmail(await getFullEmail(emailId, jmapAccountId), jmapAccountId); + // Cache first, so a draft read before still opens offline. + const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId); if (!navigationRef.isReady()) return false; navigationRef.navigate('Compose', { draft }); return true; @@ -172,6 +163,9 @@ async function openDeepLink(link: DeepLink): Promise { return useAuthStore.getState().activeAccountId === accountId; }, }); + // 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. + if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this")); } // Toasts and the undo bar show on whichever stack screen is up, so a failure diff --git a/locales/rn/en.json b/locales/rn/en.json index cabbed7d..b6c558d8 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -249,6 +249,9 @@ "color_tag": "Tag", "remove_color": "Remove tag" }, + "deep_link": { + "open_failed": "Couldn't open this" + }, "email_composer": { "attach_camera": "Camera", "attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?", diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index a701f962..3468282b 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -3,6 +3,7 @@ import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking'; import { usePendingSettingsTab } from '../pending-settings-tab'; import { usePendingCalendarOpen } from '../pending-calendar-open'; import { usePendingMailSearch } from '../pending-mail-search'; +import { links } from '../../widgets/clicks'; describe('parseDeepLink', () => { it('parses app-scheme mail links', () => { @@ -80,6 +81,14 @@ describe('parseDeepLink', () => { expect(parseDeepLink('bulwarkmobile://mail/message/M1?account=a%40b&jmapAccount=team&action=reply')).toEqual({ kind: 'message', emailId: 'M1', accountId: 'a@b', jmapAccountId: 'team', action: 'reply', }); + // What the widgets build is what the app reads, thread included. + const m = { id: 'M/1', threadId: 'T1', accountId: 'a@b', jmapAccountId: 'team' }; + expect(parseDeepLink(links.reply(m))).toEqual({ + kind: 'message', emailId: 'M/1', accountId: 'a@b', jmapAccountId: 'team', threadId: 'T1', action: 'reply', + }); + expect(parseDeepLink(links.message({ id: 'M2', accountId: 'a@b' }))).toEqual({ + kind: 'message', emailId: 'M2', accountId: 'a@b', + }); expect(parseDeepLink('bulwarkmobile://mail/draft/D1')).toEqual({ kind: 'draft', emailId: 'D1', accountId: undefined }); expect(parseDeepLink('bulwarkmobile://mail/unified?view=starred')).toEqual({ kind: 'unified', view: 'starred' }); expect(parseDeepLink('bulwarkmobile://mail/unified?role=drafts&view=bogus')).toEqual({ kind: 'unified', role: 'drafts' }); @@ -179,7 +188,7 @@ describe('handleDeepLink', () => { })); }); - it('opens a group-mailbox message in its JMAP account and hands replies to the composer opener', async () => { + it('opens a group-mailbox message in its JMAP account', async () => { const navigation = nav(); const resolveThreadId = vi.fn(async () => 'T1'); await handleDeepLink( @@ -188,14 +197,25 @@ describe('handleDeepLink', () => { ); expect(resolveThreadId).toHaveBeenCalledWith('M1', 'team'); expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1', jmapAccountId: 'team' }); + }); - const openReply = vi.fn(async () => true); + it('opens a reply over its message, without a lookup when the link names the thread', async () => { + // Offline, or before the session is back: the lookup would fail. + const resolveThreadId = vi.fn(async () => null); + const navigation = nav(); const ok = await handleDeepLink( - { kind: 'message', emailId: 'M2', action: 'reply' }, - { navigation: nav() as never, resolveThreadId, openReply }, + { kind: 'message', emailId: 'M2', threadId: 'T2', action: 'reply' }, + { navigation: navigation as never, resolveThreadId }, ); expect(ok).toBe(true); - expect(openReply).toHaveBeenCalledWith('M2', undefined); + expect(resolveThreadId).not.toHaveBeenCalled(); + expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M2', threadId: 'T2', action: 'reply' }); + + // Without the thread and without a server, nothing opens (the app says so). + expect(await handleDeepLink( + { kind: 'message', emailId: 'M3', action: 'reply' }, + { navigation: nav() as never, resolveThreadId }, + )).toBe(false); }); it('opens drafts, unified views, scheduled mail and searches', async () => { diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 9149f94d..537b48e0 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -19,8 +19,9 @@ type UnifiedView = typeof UNIFIED_VIEWS[number]; export type DeepLink = // `jmapAccountId`: the JMAP account owning a message in a group/shared - // mailbox (`?jmapAccount=`); `action: 'reply'` opens a reply to it. - | { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; action?: 'reply' } + // mailbox (`?jmapAccount=`); `threadId` (`?thread=`) saves looking it up; + // `action: 'reply'` opens a reply to it. + | { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; threadId?: string; action?: 'reply' } | { kind: 'draft'; emailId: string; accountId?: string; jmapAccountId?: string } | { kind: 'thread'; threadId: string; accountId?: string } | { kind: 'folder'; ref: string; accountId?: string } @@ -96,11 +97,13 @@ export function parseDeepLink(url: string): DeepLink | null { case 'mail': { const jmapAccountId = search.get('jmapAccount') ?? undefined; if (kind === 'message' && value) { + const threadId = search.get('thread'); return { kind: 'message', emailId: decodeSegment(value), accountId, ...(jmapAccountId ? { jmapAccountId } : {}), + ...(threadId ? { threadId } : {}), ...(search.get('action') === 'reply' ? { action: 'reply' as const } : {}), }; } @@ -169,9 +172,8 @@ export interface DeepLinkNavigator { // null when the message cannot be loaded. `jmapAccountId` names the // group/shared account holding it, when it is not the user's own. resolveThreadId: (emailId: string, jmapAccountId?: string) => Promise; - // Open the composer on a reply to / on a server draft of this message. - // Resolve false when the message cannot be loaded. - openReply?: (emailId: string, jmapAccountId?: string) => Promise; + // Open the composer on a server draft. Resolve false when the draft cannot + // 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. @@ -189,15 +191,16 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr switch (link.kind) { case 'message': { - if (link.action === 'reply' && nav.openReply) { - return nav.openReply(link.emailId, link.jmapAccountId); - } - const threadId = await nav.resolveThreadId(link.emailId, link.jmapAccountId); + const threadId = link.threadId ?? await nav.resolveThreadId(link.emailId, link.jmapAccountId); if (!threadId) return false; + // A reply opens over the message, like the reader's own Reply: the + // reader loads it (from its cache when offline) and then opens the + // composer, or shows why it could not. navigation.navigate('EmailThread', { emailId: link.emailId, threadId, ...(link.jmapAccountId ? { jmapAccountId: link.jmapAccountId } : {}), + ...(link.action === 'reply' ? { action: 'reply' as const } : {}), }); return true; } diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 3ed53acb..098f7c8f 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -67,6 +67,8 @@ export type RootStackParamList = { * viewer pages over the store's current folder. */ emailIds?: string[]; + /** Open the composer on a reply once the message has loaded (widget Reply). */ + action?: 'reply'; }; EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string }; Compose: diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 747213a3..aad8f3f9 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -536,6 +536,15 @@ function EmailViewer({ route, navigation }: Props) { if (params) navigation.navigate('Compose', params); }, [email, navigation, ownerAccountId]); + // Opened to reply (a widget's Reply): the composer follows as soon as the + // message is here, once. If it never loads, the reader's error says why. + const replyPending = React.useRef(route.params.action === 'reply'); + React.useEffect(() => { + if (!replyPending.current || email?.id !== route.params.emailId) return; + replyPending.current = false; + navigateCompose('reply', email); + }, [email, route.params.emailId, navigateCompose]); + // Forward the raw message as a message/rfc822 attachment (webmail 1.8.1). const onForwardAsAttachment = () => { setMoreMenuOpen(false); diff --git a/src/widgets/clicks.ts b/src/widgets/clicks.ts index b88f2a6c..838f21c9 100644 --- a/src/widgets/clicks.ts +++ b/src/widgets/clicks.ts @@ -37,12 +37,16 @@ function query(params: Record): string { return parts.length ? `?${parts.join('&')}` : ''; } +// Message links carry the thread so the app opens them without asking the +// server first, which also works offline from its cache. +type MessageRef = Pick & { threadId?: string }; + export const links = { inbox: () => `${base}mail/folder/inbox`, - message: (m: Pick) => - `${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`, - reply: (m: Pick) => - `${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, action: 'reply' })}`, + message: (m: MessageRef) => + `${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId })}`, + reply: (m: MessageRef) => + `${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId, action: 'reply' })}`, draft: (m: Pick) => `${base}mail/draft/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`, unified: (params: { view?: 'all' | 'unread' | 'starred'; role?: string } = {}) => diff --git a/src/widgets/jmap.ts b/src/widgets/jmap.ts index 1891802c..52ddd260 100644 --- a/src/widgets/jmap.ts +++ b/src/widgets/jmap.ts @@ -184,7 +184,7 @@ export async function fetchMailSection( calls.push(['Email/get', { accountId, '#ids': { resultOf: 'qa', name: 'Email/query', path: '/ids' }, - properties: ['id', 'from', 'receivedAt', 'attachments'], + properties: ['id', 'threadId', 'from', 'receivedAt', 'attachments'], bodyProperties: ['name', 'type', 'disposition', 'cid', 'size'], }, 'ga']); } @@ -221,6 +221,7 @@ export async function fetchMailSection( }>) .map((e) => ({ emailId: e.id, + threadId: e.threadId, accountId: registryAccountId, fromName: e.from?.[0]?.name || e.from?.[0]?.email || '', receivedAt: Date.parse(e.receivedAt) || 0, diff --git a/src/widgets/layouts/hubs.tsx b/src/widgets/layouts/hubs.tsx index b205a6ce..71bc13be 100644 --- a/src/widgets/layouts/hubs.tsx +++ b/src/widgets/layouts/hubs.tsx @@ -616,7 +616,7 @@ export const AttachmentsLayout: Layout = ({ s, p, f, now, width, height }) => { return (