From 5802caeea91424014e5d828660d979ad6ed50ffd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:20:01 +0200 Subject: [PATCH] feat: open deep links, mailto: URLs and share-sheet payloads in the app --- App.tsx | 76 ++++++- android/app/src/main/AndroidManifest.xml | 24 ++ .../bulwarkmobile/BulwarkFcmModule.kt | 6 + .../anonymous/bulwarkmobile/MainActivity.kt | 4 + .../bulwarkmobile/ShareIntentStore.kt | 85 +++++++ app.config.js | 11 +- src/lib/__tests__/mailto.test.ts | 32 +++ src/lib/mailto.ts | 66 ++++++ src/lib/share-intent.ts | 50 +++++ src/navigation/__tests__/linking.test.ts | 117 ++++++++++ src/navigation/linking.ts | 210 ++++++++++++++++++ src/navigation/pending-settings-tab.ts | 24 ++ src/screens/SettingsScreen.tsx | 8 + 13 files changed, 709 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt create mode 100644 src/lib/__tests__/mailto.test.ts create mode 100644 src/lib/mailto.ts create mode 100644 src/lib/share-intent.ts create mode 100644 src/navigation/__tests__/linking.test.ts create mode 100644 src/navigation/linking.ts create mode 100644 src/navigation/pending-settings-tab.ts diff --git a/App.tsx b/App.tsx index c014aac2..c0ddc746 100644 --- a/App.tsx +++ b/App.tsx @@ -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 { + 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 + { + 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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c6647b77..1a6db5be 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index 06b1f72a..e806a980 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -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, diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/MainActivity.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/MainActivity.kt index 96f77647..45922fd7 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/MainActivity.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/MainActivity.kt @@ -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()) } diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt new file mode 100644 index 00000000..ecdfa886 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt @@ -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() + 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? = + 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, + val mimeTypes: List, + ) { + 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 +} diff --git a/app.config.js b/app.config.js index ceb0fb41..5e8015db 100644 --- a/app.config.js +++ b/app.config.js @@ -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, diff --git a/src/lib/__tests__/mailto.test.ts b/src/lib/__tests__/mailto.test.ts new file mode 100644 index 00000000..2e6667b8 --- /dev/null +++ b/src/lib/__tests__/mailto.test.ts @@ -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'); + }); +}); diff --git a/src/lib/mailto.ts b/src/lib/mailto.ts new file mode 100644 index 00000000..f7378a7e --- /dev/null +++ b/src/lib/mailto.ts @@ -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 }; +} diff --git a/src/lib/share-intent.ts b/src/lib/share-intent.ts new file mode 100644 index 00000000..82c105a8 --- /dev/null +++ b/src/lib/share-intent.ts @@ -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; +}; + +function native(): ShareNative | null { + if (Platform.OS !== 'android') return null; + return ((NativeModules as Record).BulwarkFcm as ShareNative | undefined) ?? null; +} + +export async function getInitialShare(): Promise { + 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 }; + }); +} diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts new file mode 100644 index 00000000..2b71fd7c --- /dev/null +++ b/src/navigation/__tests__/linking.test.ts @@ -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' }); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts new file mode 100644 index 00000000..9d97161b --- /dev/null +++ b/src/navigation/linking.ts @@ -0,0 +1,210 @@ +// Deep links: `bulwarkmobile://…` app links, webmail permalinks +// (https:///mail/message/ 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=` 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; + // Resolve a message id to its thread (EmailThread needs both). Returns + // null when the message cannot be loaded. + resolveThreadId: (emailId: string) => Promise; + // Switch to the account a permalink names (`?account=`); resolves false + // when that account is not signed in on this device. + switchAccount?: (accountId: string) => Promise; +} + +/** Navigate for a parsed link. Returns false when nothing could be opened. */ +export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Promise { + 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, + }; +} diff --git a/src/navigation/pending-settings-tab.ts b/src/navigation/pending-settings-tab.ts new file mode 100644 index 00000000..dad37ff9 --- /dev/null +++ b/src/navigation/pending-settings-tab.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand'; + +// A `settings/` 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((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); +} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 429ca2c2..cad46d7d 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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/ 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) => ({