mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78901f2375 | ||
|
|
a4985a63a8 |
@@ -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
|
||||
|
||||
@@ -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?",
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.64",
|
||||
"version": "0.1.65",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user