Compare commits

...
2 Commits
14 changed files with 79 additions and 41 deletions
+9 -15
View File
@@ -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<voi
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
// Android share-sheet payloads all end up here once the navigator is ready.
async function openDeepLink(link: DeepLink): Promise<void> {
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<void> {
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<void> {
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
+1 -1
View File
@@ -1 +1 @@
0.1.64
0.1.65
+3
View File
@@ -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?",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.64",
"version": "0.1.65",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.64",
"version": "0.1.65",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.64",
"version": "0.1.65",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+25 -5
View File
@@ -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 () => {
+12 -9
View File
@@ -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<string | null>;
// 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<boolean>;
// Open the composer on a server draft. Resolve false when the draft cannot
// 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.
@@ -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;
}
+2
View File
@@ -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:
+9
View File
@@ -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);
+8 -4
View File
@@ -37,12 +37,16 @@ function query(params: Record<string, string | undefined>): 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<MailItem, 'id' | 'accountId' | 'jmapAccountId'> & { threadId?: string };
export const links = {
inbox: () => `${base}mail/folder/inbox`,
message: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`,
reply: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${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<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${base}mail/draft/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`,
unified: (params: { view?: 'all' | 'unread' | 'starred'; role?: string } = {}) =>
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -616,7 +616,7 @@ export const AttachmentsLayout: Layout = ({ s, p, f, now, width, height }) => {
return (
<FlexWidget
key={g.emailId}
{...open(links.message({ id: g.emailId, accountId: g.accountId }))}
{...open(links.message({ id: g.emailId, threadId: g.threadId, accountId: g.accountId }))}
style={{
width: 'match_parent',
paddingHorizontal: 14,
+2 -2
View File
@@ -157,9 +157,9 @@ export function sampleSnapshot(now: number): WidgetSnapshot {
recentSearches: ['from:sarah', 'invoice', 'has:attachment', 'Q4 timeline', 'is:unread'],
tags: [{ keyword: '$label:receipts', name: 'Receipts', color: '#f97316', unread: 2, total: 14, latestFrom: 'Sarah Kim', latestAt: ago(46) }],
attachments: [
{ emailId: 'm2', accountId: 'demo@example.com@demo', fromName: 'Sarah Kim', receivedAt: ago(46),
{ emailId: 'm2', threadId: 't-m2', accountId: 'demo@example.com@demo', fromName: 'Sarah Kim', receivedAt: ago(46),
files: [{ name: 'Invoice-2024-089.pdf', type: 'application/pdf' }, { name: 'prototype-v3.png', type: 'image/png' }] },
{ emailId: 'm7', accountId: 'demo@example.com@demo', fromName: 'Anna Kowalski', receivedAt: ago(60 * 48),
{ emailId: 'm7', threadId: 't-m7', accountId: 'demo@example.com@demo', fromName: 'Anna Kowalski', receivedAt: ago(60 * 48),
files: [{ name: 'wedding-001.jpg', type: 'image/jpeg' }, { name: 'wedding-014.jpg', type: 'image/jpeg' }, { name: 'wedding-038.jpg', type: 'image/jpeg' }] },
],
},
+2
View File
@@ -75,6 +75,8 @@ export interface TagCount {
export interface AttachmentGroup {
emailId: string;
/** Absent in snapshots stored before it was kept. */
threadId?: string;
accountId: string;
fromName: string;
receivedAt: number;