feat: open deep links, mailto: URLs and share-sheet payloads in the app

This commit is contained in:
Linus Rath
2026-08-29 18:20:01 +02:00
parent 06742ef7d5
commit 5802caeea9
13 changed files with 709 additions and 4 deletions
+75 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { ActivityIndicator, AppState, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
@@ -50,6 +50,10 @@ import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
import { ToastHost } from './src/components/ToastHost';
import { getEmails } from './src/api/email';
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';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
@@ -83,6 +87,29 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
});
}
// 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, {
navigation: navigationRef,
resolveThreadId: async (emailId) => {
try {
const [email] = await getEmails([emailId]);
return email?.threadId ?? null;
} catch {
return null;
}
},
switchAccount: async (accountId) => {
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;
},
});
}
function LoadingScreen({ message }: { message: string }) {
const c = useColors();
return (
@@ -108,6 +135,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<UpdateBanner />
<OfflineCacheBanner />
<PushOnboardingPrompt />
<ToastHost />
<Tab.Navigator
screenOptions={{
headerShown: false,
@@ -344,6 +372,52 @@ export default function App() {
};
}, [isAuthenticated]);
// Deep links and share-sheet payloads. The cold-start URL / share is read
// once auth is restored so the target screen has credentials.
React.useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const open = (link: DeepLink | null) => {
if (!link || cancelled) return;
// Give the navigator a tick to mount after the auth gate flips.
setTimeout(() => { void openDeepLink(link); }, 50);
};
void Linking.getInitialURL().then((url) => open(url ? parseDeepLink(url) : null));
void getInitialShare().then((share) => {
if (!share) return;
const link = shareToDeepLink(share);
if (link.kind === 'compose' && share.uris?.length) {
navigationRef.isReady() && navigationRef.navigate('Compose', {
prefillTo: link.to,
prefillSubject: link.subject,
prefillBody: link.body,
prefillAttachments: shareAttachments(share),
});
return;
}
open(link);
});
const urlSub = Linking.addEventListener('url', ({ url }) => open(parseDeepLink(url)));
const shareSub = addShareListener((share) => {
const link = shareToDeepLink(share);
if (link.kind === 'compose' && share.uris?.length && navigationRef.isReady()) {
navigationRef.navigate('Compose', {
prefillTo: link.to,
prefillSubject: link.subject,
prefillBody: link.body,
prefillAttachments: shareAttachments(share),
});
return;
}
open(link);
});
return () => {
cancelled = true;
urlSub.remove();
shareSub();
};
}, [isAuthenticated]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down THIS account's subscription so
+24
View File
@@ -28,6 +28,30 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SENDTO"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="mailto"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/*"/>
<data android:mimeType="image/*"/>
<data android:mimeType="application/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="video/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
<data android:mimeType="application/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="video/*"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
@@ -91,6 +91,12 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
promise.resolve(payload?.toMap())
}
@ReactMethod
fun getInitialShare(promise: Promise) {
val payload = ShareIntentStore.consume()
promise.resolve(payload?.toMap())
}
private fun postNotification(
notificationId: String,
title: String,
@@ -19,11 +19,15 @@ class MainActivity : ReactActivity() {
setTheme(R.style.AppTheme);
super.onCreate(null)
NotificationTapStore.captureFromIntent(intent)
ShareIntentStore.captureFromIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
ShareIntentStore.captureFromIntent(intent)?.let { share ->
BulwarkFcmModule.emit("app:share", share.toMap())
}
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
}
@@ -0,0 +1,85 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
// Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE
// intent (the system share sheet). MainActivity fills this on onCreate /
// onNewIntent; JS drains it via BulwarkFcm.getInitialShare or reacts to the
// live "app:share" event and opens the composer.
object ShareIntentStore {
@Volatile
private var pending: SharePayload? = null
fun consume(): SharePayload? {
val value = pending
pending = null
return value
}
fun captureFromIntent(intent: Intent?): SharePayload? {
val action = intent?.action ?: return null
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.take(MAX_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.take(512)
val uris = mutableListOf<Uri>()
if (action == Intent.ACTION_SEND) {
streamExtra(intent)?.let { uris.add(it) }
} else {
streamExtras(intent)?.let { uris.addAll(it) }
}
val type = intent.type
if (text.isNullOrBlank() && uris.isEmpty()) return null
val payload = SharePayload(
text = text,
subject = subject,
uris = uris.map { it.toString() },
mimeTypes = uris.map { type ?: "" },
)
pending = payload
// Clear so a later lifecycle event doesn't replay this share.
intent.removeExtra(Intent.EXTRA_TEXT)
intent.removeExtra(Intent.EXTRA_SUBJECT)
intent.removeExtra(Intent.EXTRA_STREAM)
intent.action = Intent.ACTION_MAIN
return payload
}
@Suppress("DEPRECATION")
private fun streamExtra(intent: Intent): Uri? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
@Suppress("DEPRECATION")
private fun streamExtras(intent: Intent): List<Uri>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
data class SharePayload(
val text: String?,
val subject: String?,
val uris: List<String>,
val mimeTypes: List<String>,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
if (text != null) putString("text", text)
if (subject != null) putString("subject", subject)
putArray("uris", Arguments.fromList(uris))
putArray("mimeTypes", Arguments.fromList(mimeTypes))
}
}
// 1 MiB of shared text is plenty; anything bigger is an attachment.
private const val MAX_TEXT = 1 * 1024 * 1024
}
+8 -3
View File
@@ -26,16 +26,21 @@ module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
scheme: 'bulwarkmobile',
// mailto: lets Android/iOS offer the app for mail links in other apps.
scheme: ['bulwarkmobile', 'mailto'],
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'dark',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
splash: {
image: './assets/splash-icon.png',
resizeMode: 'contain',
backgroundColor: '#09090b',
backgroundColor: '#ffffff',
dark: {
image: './assets/splash-icon.png',
backgroundColor: '#09090b',
},
},
ios: {
supportsTablet: true,
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { parseMailtoUrl } from '../mailto';
describe('parseMailtoUrl', () => {
it('parses recipients, subject and body', () => {
expect(parseMailtoUrl('mailto:a@b.co?subject=Hi%20there&body=Line%201%0ALine%202')).toEqual({
to: ['a@b.co'],
cc: [],
bcc: [],
subject: 'Hi there',
body: 'Line 1\nLine 2',
});
});
it('accepts multiple recipients, to= in the query and cc/bcc', () => {
const parsed = parseMailtoUrl('mailto:a@b.co,c@d.co?to=e@f.co&cc=g@h.co&bcc=i@j.co');
expect(parsed?.to).toEqual(['a@b.co', 'c@d.co', 'e@f.co']);
expect(parsed?.cc).toEqual(['g@h.co']);
expect(parsed?.bcc).toEqual(['i@j.co']);
});
it('drops invalid addresses and returns null when none remain', () => {
expect(parseMailtoUrl('mailto:not-an-address')).toBeNull();
expect(parseMailtoUrl('mailto:?subject=x')).toBeNull();
expect(parseMailtoUrl('https://example.com')).toBeNull();
expect(parseMailtoUrl('')).toBeNull();
});
it('is case-insensitive on the scheme and decodes + as space', () => {
expect(parseMailtoUrl('MAILTO:a@b.co?subject=a+b')?.subject).toBe('a b');
});
});
+66
View File
@@ -0,0 +1,66 @@
// Port of the webmail's parseMailtoUrl (lib/validation.ts) with cc/bcc
// support. Used for `mailto:` links opened from other apps and for share
// intents that carry an address.
export interface ParsedMailto {
to: string[];
cc: string[];
bcc: string[];
subject?: string;
body?: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(value: string): boolean {
return EMAIL_RE.test(value.trim());
}
function decode(value: string): string {
try {
return decodeURIComponent(value.replace(/\+/g, '%20'));
} catch {
return value;
}
}
function splitAddresses(raw: string): string[] {
return raw
.split(',')
.map((a) => decode(a).trim())
.filter((a) => isValidEmail(a));
}
/**
* Parse a `mailto:` URL (RFC 6068). Returns null unless at least one valid
* recipient (in the path or a `to=` query) is present.
*/
export function parseMailtoUrl(url: string): ParsedMailto | null {
if (!url || !/^mailto:/i.test(url)) return null;
const rest = url.slice(7);
const queryIndex = rest.indexOf('?');
const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex);
const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1);
const to = splitAddresses(addressPart);
const cc: string[] = [];
const bcc: string[] = [];
let subject: string | undefined;
let body: string | undefined;
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
const key = pair.slice(0, eq).toLowerCase();
const value = decode(pair.slice(eq + 1));
if (key === 'subject') subject = value;
else if (key === 'body') body = value;
else if (key === 'to') to.push(...splitAddresses(value));
else if (key === 'cc') cc.push(...splitAddresses(value));
else if (key === 'bcc') bcc.push(...splitAddresses(value));
}
if (to.length === 0 && cc.length === 0 && bcc.length === 0) return null;
return { to, cc, bcc, subject, body };
}
+50
View File
@@ -0,0 +1,50 @@
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
import type { SharePayload } from '../navigation/linking';
// Android share sheet (ACTION_SEND / ACTION_SEND_MULTIPLE) → composer.
// MainActivity stashes the intent in ShareIntentStore; JS drains it here on
// launch and listens for later shares while running.
type ShareNative = {
getInitialShare?: () => Promise<SharePayload | null>;
};
function native(): ShareNative | null {
if (Platform.OS !== 'android') return null;
return ((NativeModules as Record<string, unknown>).BulwarkFcm as ShareNative | undefined) ?? null;
}
export async function getInitialShare(): Promise<SharePayload | null> {
const mod = native();
if (!mod?.getInitialShare) return null;
try {
return (await mod.getInitialShare()) ?? null;
} catch {
return null;
}
}
export function addShareListener(listener: (payload: SharePayload) => void): () => void {
if (Platform.OS !== 'android' || !NativeModules.BulwarkFcm) return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('app:share', listener);
return () => sub.remove();
}
/** Attachment descriptors for the composer's `prefillAttachments`. */
export function shareAttachments(
share: SharePayload,
): Array<{ uri: string; name: string; type: string }> {
const uris = share.uris ?? [];
return uris.map((uri, i) => {
const last = uri.split('/').filter(Boolean).pop() ?? `shared-${i + 1}`;
let name: string;
try {
name = decodeURIComponent(last);
} catch {
name = last;
}
const type = share.mimeTypes?.[i] || 'application/octet-stream';
return { uri, name, type };
});
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';
import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking';
import { usePendingSettingsTab } from '../pending-settings-tab';
describe('parseDeepLink', () => {
it('parses app-scheme mail links', () => {
expect(parseDeepLink('bulwarkmobile://mail/message/M1')).toEqual({ kind: 'message', emailId: 'M1', accountId: undefined });
expect(parseDeepLink('bulwarkmobile://mail/thread/T1?account=acc')).toEqual({ kind: 'thread', threadId: 'T1', accountId: 'acc' });
expect(parseDeepLink('bulwarkmobile://mail/folder/inbox')).toEqual({ kind: 'folder', ref: 'inbox', accountId: undefined });
expect(parseDeepLink('bulwarkmobile://mail')).toEqual({ kind: 'folder', ref: 'inbox', accountId: undefined });
});
it('parses webmail https permalinks, ignoring host and locale prefix', () => {
expect(parseDeepLink('https://mail.example.com/mail/message/abc%2Fdef')).toEqual({ kind: 'message', emailId: 'abc/def', accountId: undefined });
expect(parseDeepLink('https://mail.example.com/de/contacts/C9')).toEqual({ kind: 'contact', contactId: 'C9' });
expect(parseDeepLink('https://mail.example.com/mail?email=legacy')).toEqual({ kind: 'message', emailId: 'legacy', accountId: undefined });
});
it('parses calendar, contacts, files and settings links', () => {
expect(parseDeepLink('bulwarkmobile://calendar/event/E1')).toEqual({ kind: 'calendar', eventId: 'E1' });
expect(parseDeepLink('bulwarkmobile://calendar/week/2026-08-29')).toEqual({ kind: 'calendar', date: '2026-08-29' });
expect(parseDeepLink('bulwarkmobile://contacts')).toEqual({ kind: 'contacts' });
expect(parseDeepLink('bulwarkmobile://files')).toEqual({ kind: 'files' });
expect(parseDeepLink('bulwarkmobile://settings/notifications')).toEqual({ kind: 'settings', tab: 'notifications' });
expect(parseDeepLink('bulwarkmobile://settings')).toEqual({ kind: 'settings', tab: undefined });
});
it('turns mailto: into a compose link', () => {
expect(parseDeepLink('mailto:a@b.co?subject=Hi&cc=c@d.co')).toEqual({
kind: 'compose',
to: [{ email: 'a@b.co' }],
cc: [{ email: 'c@d.co' }],
subject: 'Hi',
body: undefined,
});
expect(parseDeepLink('mailto:nope')).toBeNull();
});
it('rejects unknown links', () => {
expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull();
expect(parseDeepLink('garbage')).toBeNull();
expect(parseDeepLink('')).toBeNull();
});
});
describe('handleDeepLink', () => {
const nav = () => ({
isReady: () => true,
navigate: vi.fn(),
});
it('resolves a message to its thread before opening the reader', async () => {
const navigation = nav();
const ok = await handleDeepLink(
{ kind: 'message', emailId: 'M1' },
{ navigation: navigation as never, resolveThreadId: async () => 'T1' },
);
expect(ok).toBe(true);
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1' });
});
it('gives up when the message cannot be loaded', async () => {
const navigation = nav();
const ok = await handleDeepLink(
{ kind: 'message', emailId: 'M1' },
{ navigation: navigation as never, resolveThreadId: async () => null },
);
expect(ok).toBe(false);
expect(navigation.navigate).not.toHaveBeenCalled();
});
it('parks the settings tab and opens the Settings tab', async () => {
const navigation = nav();
await handleDeepLink(
{ kind: 'settings', tab: 'updates' },
{ navigation: navigation as never, resolveThreadId: async () => null },
);
expect(usePendingSettingsTab.getState().consume()).toBe('updates');
expect(usePendingSettingsTab.getState().consume()).toBeNull();
expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Settings' });
});
it('opens the composer with prefilled recipients', async () => {
const navigation = nav();
await handleDeepLink(
{ kind: 'compose', to: [{ email: 'a@b.co' }], cc: [], subject: 'S', body: 'B' },
{ navigation: navigation as never, resolveThreadId: async () => null },
);
expect(navigation.navigate).toHaveBeenCalledWith('Compose', {
prefillTo: [{ email: 'a@b.co' }],
prefillCc: undefined,
prefillSubject: 'S',
prefillBody: 'B',
});
});
it('refuses when the linked account is not signed in', async () => {
const navigation = nav();
const ok = await handleDeepLink(
{ kind: 'message', emailId: 'M1', accountId: 'other' },
{ navigation: navigation as never, resolveThreadId: async () => 'T', switchAccount: async () => false },
);
expect(ok).toBe(false);
});
});
describe('shareToDeepLink', () => {
it('routes shared text into the body and shared addresses into recipients', () => {
expect(shareToDeepLink({ text: 'hello world', subject: 'S' })).toEqual({
kind: 'compose', to: [], cc: [], subject: 'S', body: 'hello world',
});
expect(shareToDeepLink({ text: 'a@b.co' })).toEqual({
kind: 'compose', to: [{ email: 'a@b.co' }], cc: [], subject: undefined,
});
expect(shareToDeepLink({ text: 'mailto:a@b.co?subject=x' })).toMatchObject({ kind: 'compose', subject: 'x' });
});
});
+210
View File
@@ -0,0 +1,210 @@
// Deep links: `bulwarkmobile://…` app links, webmail permalinks
// (https://<webmail>/mail/message/<id> etc. - same path grammar as the
// webmail's lib/deep-links.ts) and `mailto:` URLs. Parsing is pure so it can
// be unit-tested; `handleDeepLink` performs the navigation.
import type { NavigationContainerRefWithCurrent } from '@react-navigation/native';
import type { EmailAddress } from '../api/types';
import { parseMailtoUrl } from '../lib/mailto';
import type { RootStackParamList } from './types';
import { setPendingSettingsTab } from './pending-settings-tab';
export const APP_SCHEME = 'bulwarkmobile';
export type DeepLink =
| { kind: 'message'; emailId: string; accountId?: string }
| { kind: 'thread'; threadId: string; accountId?: string }
| { kind: 'folder'; ref: string; accountId?: string }
| { kind: 'calendar'; eventId?: string; date?: string }
| { kind: 'contact'; contactId: string }
| { kind: 'contacts' }
| { kind: 'files' }
| { kind: 'settings'; tab?: string }
| { kind: 'compose'; to: EmailAddress[]; cc: EmailAddress[]; subject?: string; body?: string };
function decodeSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function toAddresses(list: string[]): EmailAddress[] {
return list.map((email) => ({ email }));
}
/**
* Split a URL into path segments and query, tolerating both `scheme://host/
* path` (https permalinks, `bulwarkmobile://mail/...` where "mail" lands in
* the host slot) and `scheme:path` forms.
*/
function splitUrl(url: string): { segments: string[]; search: URLSearchParams } | null {
const m = /^([a-z][a-z0-9+.-]*):(?:\/\/)?([^?#]*)(?:\?([^#]*))?/i.exec(url.trim());
if (!m) return null;
const scheme = m[1].toLowerCase();
let path = m[2];
// For https permalinks the first segment is the webmail host; drop it and
// an optional locale prefix (`/de/mail/...`).
if (scheme === 'http' || scheme === 'https') {
path = path.replace(/^[^/]*\/?/, '');
}
const segments = path.split('/').filter(Boolean);
if (/^[a-z]{2}(-[A-Za-z]{2,4})?$/.test(segments[0] ?? '') && segments.length > 1
&& ['mail', 'calendar', 'contacts', 'files', 'settings', 'compose'].includes(segments[1])) {
segments.shift();
}
return { segments, search: new URLSearchParams(m[3] ?? '') };
}
export function parseDeepLink(url: string): DeepLink | null {
if (!url) return null;
if (/^mailto:/i.test(url)) {
const parsed = parseMailtoUrl(url);
if (!parsed) return null;
return {
kind: 'compose',
to: toAddresses(parsed.to),
cc: toAddresses(parsed.cc),
subject: parsed.subject,
body: parsed.body,
};
}
const parts = splitUrl(url);
if (!parts) return null;
const { segments, search } = parts;
const [area, kind, value] = segments;
const accountId = search.get('account') ?? undefined;
switch (area) {
case 'mail': {
if (kind === 'message' && value) return { kind: 'message', emailId: decodeSegment(value), accountId };
if (kind === 'thread' && value) return { kind: 'thread', threadId: decodeSegment(value), accountId };
if (kind === 'folder' && value) return { kind: 'folder', ref: decodeSegment(value), accountId };
// Legacy `?email=<id>` from the webmail's older service worker.
const legacyEmail = search.get('email');
if (legacyEmail) return { kind: 'message', emailId: legacyEmail, accountId };
return { kind: 'folder', ref: 'inbox', accountId };
}
case 'calendar': {
if (kind === 'event' && value) return { kind: 'calendar', eventId: decodeSegment(value) };
const date = [kind, value].find((s) => s && /^\d{4}-\d{2}-\d{2}$/.test(s));
return { kind: 'calendar', date };
}
case 'contacts': {
if (kind && kind !== 'new') return { kind: 'contact', contactId: decodeSegment(kind) };
return { kind: 'contacts' };
}
case 'files':
return { kind: 'files' };
case 'settings':
return { kind: 'settings', tab: kind ? decodeSegment(kind) : undefined };
case 'compose': {
const to = search.get('to');
const parsed = to ? parseMailtoUrl(`mailto:${to}?${search.toString()}`) : null;
return {
kind: 'compose',
to: toAddresses(parsed?.to ?? []),
cc: toAddresses(parsed?.cc ?? []),
subject: parsed?.subject ?? search.get('subject') ?? undefined,
body: parsed?.body ?? search.get('body') ?? undefined,
};
}
default:
return null;
}
}
export interface DeepLinkNavigator {
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
// Resolve a message id to its thread (EmailThread needs both). Returns
// null when the message cannot be loaded.
resolveThreadId: (emailId: string) => Promise<string | null>;
// Switch to the account a permalink names (`?account=`); resolves false
// when that account is not signed in on this device.
switchAccount?: (accountId: string) => Promise<boolean>;
}
/** Navigate for a parsed link. Returns false when nothing could be opened. */
export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Promise<boolean> {
const { navigation } = nav;
if (!navigation.isReady()) return false;
if ('accountId' in link && link.accountId && nav.switchAccount) {
if (!(await nav.switchAccount(link.accountId))) return false;
}
switch (link.kind) {
case 'message': {
const threadId = await nav.resolveThreadId(link.emailId);
if (!threadId) return false;
navigation.navigate('EmailThread', { emailId: link.emailId, threadId });
return true;
}
case 'thread':
// The reader keys on the message; without one, open the list.
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
return true;
case 'folder':
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
return true;
case 'calendar':
navigation.navigate('MainTabs', { screen: 'Calendar' } as never);
return true;
case 'contact':
navigation.navigate('ContactDetail', { contactId: link.contactId });
return true;
case 'contacts':
navigation.navigate('MainTabs', { screen: 'Contacts' } as never);
return true;
case 'files':
navigation.navigate('MainTabs', { screen: 'Files' } as never);
return true;
case 'settings':
setPendingSettingsTab(link.tab ?? null);
navigation.navigate('MainTabs', { screen: 'Settings' } as never);
return true;
case 'compose':
navigation.navigate('Compose', {
prefillTo: link.to,
prefillCc: link.cc.length > 0 ? link.cc : undefined,
prefillSubject: link.subject,
prefillBody: link.body,
});
return true;
default:
return false;
}
}
/** Share-sheet payload captured natively (ACTION_SEND / SEND_MULTIPLE). */
export interface SharePayload {
text?: string;
subject?: string;
// content:// URIs of shared files, with their MIME types when known.
uris?: string[];
mimeTypes?: string[];
}
/**
* Turn a share payload into a compose link: a shared `mailto:` or address
* becomes the recipient, everything else lands in the body.
*/
export function shareToDeepLink(share: SharePayload): DeepLink {
const text = share.text?.trim() ?? '';
if (/^mailto:/i.test(text)) {
const link = parseDeepLink(text);
if (link) return link;
}
const asAddress = parseMailtoUrl(`mailto:${text}`);
if (asAddress && !text.includes(' ') && !text.includes('\n')) {
return { kind: 'compose', to: toAddresses(asAddress.to), cc: [], subject: share.subject };
}
return {
kind: 'compose',
to: [],
cc: [],
subject: share.subject,
body: text || undefined,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand';
// A `settings/<tab>` deep link arrives before SettingsScreen is mounted (or
// while it shows the list). The tab id is parked here and consumed by
// SettingsScreen on its next render.
interface PendingSettingsTabState {
tab: string | null;
set: (tab: string | null) => void;
consume: () => string | null;
}
export const usePendingSettingsTab = create<PendingSettingsTabState>((set, get) => ({
tab: null,
set: (tab) => set({ tab }),
consume: () => {
const tab = get().tab;
if (tab) set({ tab: null });
return tab;
},
}));
export function setPendingSettingsTab(tab: string | null): void {
usePendingSettingsTab.getState().set(tab);
}
+8
View File
@@ -39,6 +39,7 @@ import { DownloadsSettings } from '../components/settings/DownloadsSettings';
import { useLocaleStore } from '../stores/locale-store';
import { useHasCalendar, useHasContacts, useHasFiles, useHasSieve, useHasVacation } from '../lib/capabilities';
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
import { usePendingSettingsTab } from '../navigation/pending-settings-tab';
type Tab =
| 'account' | 'language' | 'notifications'
@@ -184,6 +185,13 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
if (!hasVacation) set.add('vacation');
return set;
}, [hasCalendar, hasContacts, hasFiles, hasSieve, hasVacation]);
// A settings/<tab> deep link parks its target here; open it once.
const pendingTab = usePendingSettingsTab((s) => s.tab);
useEffect(() => {
if (!pendingTab) return;
const tab = usePendingSettingsTab.getState().consume();
if (tab && TABS.some((t) => t.id === tab && t.implemented)) setSelectedTab(tab as Tab);
}, [pendingTab]);
const groupedTabs = React.useMemo(() => {
void locale; // dependency: re-translate on locale change
return groupTabs().map((g) => ({