mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: open deep links, mailto: URLs and share-sheet payloads in the app
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user