diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 469cfe36..07a64684 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking'; import { usePendingSettingsTab } from '../pending-settings-tab'; +import { usePendingCalendarOpen } from '../pending-calendar-open'; describe('parseDeepLink', () => { it('parses app-scheme mail links', () => { @@ -107,6 +108,28 @@ describe('handleDeepLink', () => { expect(navigation.navigate).not.toHaveBeenCalled(); }); + it('opens the event a calendar link names, also on a shared calendar', async () => { + const navigation = nav(); + expect(parseDeepLink('https://mail.example.com/calendar/event/E1?account=acc-2')) + .toEqual({ kind: 'calendar', eventId: 'E1', jmapAccountId: 'acc-2' }); + + await handleDeepLink( + { kind: 'calendar', eventId: 'E1', jmapAccountId: 'acc-2' }, + { navigation: navigation as never, resolveThreadId: async () => null }, + ); + expect(usePendingCalendarOpen.getState().consume()).toEqual({ + kind: 'event', eventId: 'acc-2:E1', serverId: 'E1', accountId: 'acc-2', + }); + expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' }); + + // A date or view link just opens the tab. + await handleDeepLink( + { kind: 'calendar', date: '2026-08-29' }, + { navigation: navigation as never, resolveThreadId: async () => null }, + ); + expect(usePendingCalendarOpen.getState().consume()).toBeNull(); + }); + it('parks the settings tab and opens the Settings tab', async () => { const navigation = nav(); await handleDeepLink( diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 5390a31e..14a8bf48 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -7,6 +7,7 @@ import type { EmailAddress } from '../api/types'; import { parseMailtoUrl } from '../lib/mailto'; import type { RootStackParamList } from './types'; import { setPendingSettingsTab } from './pending-settings-tab'; +import { setPendingCalendarOpen } from './pending-calendar-open'; export const APP_SCHEME = 'bulwarkmobile'; @@ -14,7 +15,9 @@ 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 } + // `jmapAccountId`: the JMAP account owning the event (`?account=`), for + // one on a calendar shared with the user. + | { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string } | { kind: 'contact'; contactId: string } | { kind: 'contacts' } | { kind: 'files' } @@ -88,7 +91,12 @@ export function parseDeepLink(url: string): DeepLink | null { return { kind: 'folder', ref: 'inbox', accountId }; } case 'calendar': { - if (kind === 'event' && value) return { kind: 'calendar', eventId: decodeSegment(value) }; + if (kind === 'event' && value) { + const jmapAccountId = search.get('account') ?? undefined; + return jmapAccountId + ? { kind: 'calendar', eventId: decodeSegment(value), jmapAccountId } + : { kind: 'calendar', eventId: decodeSegment(value) }; + } const date = [kind, value].find((s) => s && /^\d{4}-\d{2}-\d{2}$/.test(s)); return { kind: 'calendar', date }; } @@ -154,6 +162,16 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr navigation.navigate('MainTabs', { screen: 'Mail' } as never); return true; case 'calendar': + // An event link opens the event like a tapped reminder does: the + // Calendar tab looks it up by its server id. + if (link.eventId) { + setPendingCalendarOpen({ + kind: 'event', + eventId: link.jmapAccountId ? `${link.jmapAccountId}:${link.eventId}` : link.eventId, + serverId: link.eventId, + accountId: link.jmapAccountId, + }); + } navigation.navigate('MainTabs', { screen: 'Calendar' } as never); return true; case 'contact':