diff --git a/src/navigation/__tests__/calendar-date-link.test.ts b/src/navigation/__tests__/calendar-date-link.test.ts new file mode 100644 index 00000000..67d13b72 --- /dev/null +++ b/src/navigation/__tests__/calendar-date-link.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { handleDeepLink, parseDeepLink } from '../linking'; +import { parseCalendarLinkDate, usePendingCalendarDate } from '../pending-calendar-date'; +import { usePendingCalendarOpen } from '../pending-calendar-open'; + +// A `/calendar//` link opened only the Calendar tab, on +// whatever day it showed (file 10 "date deep links", 05 "Deep links to +// calendar/event"). Webmail lib/deep-links.ts parseCalendarPath. + +const nav = () => ({ isReady: () => true, navigate: vi.fn() }); + +describe('calendar date deep links', () => { + it('parses the view and the day, and only real days', () => { + expect(parseDeepLink('bulwarkmobile://calendar/week/2026-08-29')) + .toEqual({ kind: 'calendar', view: 'week', date: '2026-08-29' }); + expect(parseDeepLink('https://mail.example.com/de/calendar/day/2026-02-03')) + .toEqual({ kind: 'calendar', view: 'day', date: '2026-02-03' }); + expect(parseDeepLink('bulwarkmobile://calendar/agenda')).toEqual({ kind: 'calendar', view: 'agenda' }); + expect(parseDeepLink('bulwarkmobile://calendar/tasks')).toEqual({ kind: 'calendar', view: 'tasks' }); + // A bare date keeps the view on screen. + expect(parseDeepLink('bulwarkmobile://calendar/2026-08-06')).toEqual({ kind: 'calendar', date: '2026-08-06' }); + // 2026-02-31 does not roll over into March. + expect(parseDeepLink('bulwarkmobile://calendar/month/2026-02-31')).toEqual({ kind: 'calendar', view: 'month' }); + expect(parseDeepLink('bulwarkmobile://calendar/year/2026-08-06')).toEqual({ kind: 'calendar' }); + }); + + it('reads the day as a local calendar day', () => { + const d = parseCalendarLinkDate('2026-08-06')!; + expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()]).toEqual([2026, 7, 6, 0]); + expect(parseCalendarLinkDate('2026-13-01')).toBeNull(); + expect(parseCalendarLinkDate('06.08.2026')).toBeNull(); + }); + + it('parks the view and day for the Calendar tab', async () => { + const navigation = nav(); + const link = parseDeepLink('bulwarkmobile://calendar/day/2026-08-06')!; + expect(await handleDeepLink(link, { navigation: navigation as never, resolveThreadId: async () => null })).toBe(true); + expect(usePendingCalendarDate.getState().consume()).toEqual({ view: 'day', date: '2026-08-06' }); + expect(usePendingCalendarOpen.getState().consume()).toBeNull(); + expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' }); + + // A plain /calendar link parks nothing. + await handleDeepLink(parseDeepLink('bulwarkmobile://calendar')!, { navigation: navigation as never, resolveThreadId: async () => null }); + expect(usePendingCalendarDate.getState().consume()).toBeNull(); + }); + + // No RN renderer in the test env: check the screen's wiring instead. + it('is applied by the Calendar screen', () => { + const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/CalendarScreen.tsx'), 'utf8'); + expect(screen).toMatch(/const link = usePendingCalendarDate\.getState\(\)\.consume\(\);\s*if \(link\) applyDateLink\(link\);/); + expect(screen).toMatch(/const mode: ViewMode = link\.view && link\.view !== 'tasks' \? link\.view : viewMode;/); + expect(screen).toMatch(/setFocus\(\(prev\) => \(\{ date, nonce: prev\.nonce \+ 1 \}\)\);\s*setWindowState\(\(prev\) =>\s*mode === viewMode/); + }); +}); diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 88d2e0d4..be7c5cf3 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -22,7 +22,7 @@ describe('parseDeepLink', () => { 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://calendar/week/2026-08-29')).toEqual({ kind: 'calendar', view: 'week', 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' }); @@ -145,7 +145,7 @@ describe('handleDeepLink', () => { }); expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' }); - // A date or view link just opens the tab. + // A date or view link opens no event (calendar-date-link.test.ts). await handleDeepLink( { kind: 'calendar', date: '2026-08-29' }, { navigation: navigation as never, resolveThreadId: async () => null }, diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index b08028b5..b8ec594d 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -8,6 +8,12 @@ import { parseMailtoUrl } from '../lib/mailto'; import type { RootStackParamList } from './types'; import { setPendingSettingsTab } from './pending-settings-tab'; import { setPendingCalendarOpen } from './pending-calendar-open'; +import { + CALENDAR_LINK_VIEWS, + parseCalendarLinkDate, + setPendingCalendarDate, + type CalendarLinkView, +} from './pending-calendar-date'; import { setPendingMailSearch } from './pending-mail-search'; import { setPendingMailFolder } from './pending-mail-folder'; import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link'; @@ -38,8 +44,9 @@ export type DeepLink = | { kind: 'search'; query: string } // `jmapAccountId`: the JMAP account owning the event (`?account=`), for // one on a calendar shared with the user; `accountId`: the signed-in - // account it belongs to (`?appAccount=`, from widgets). - | { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string; accountId?: string } + // account it belongs to (`?appAccount=`, from widgets). `view` and `date` + // (`YYYY-MM-DD`): the view and day a `/calendar//` link shows. + | { kind: 'calendar'; eventId?: string; view?: CalendarLinkView; date?: string; jmapAccountId?: string; accountId?: string } | { kind: 'contact'; contactId: string } | { kind: 'contacts' } | { kind: 'files' } @@ -149,8 +156,16 @@ export function parseDeepLink(url: string): DeepLink | null { ...(appAccountId ? { accountId: appAccountId } : {}), }; } - const date = [kind, value].find((s) => s && /^\d{4}-\d{2}-\d{2}$/.test(s)); - return { kind: 'calendar', date }; + // `/calendar/[/]`, or a bare `/calendar/` that keeps + // the view on screen (webmail parseCalendarPath). + if (kind && (CALENDAR_LINK_VIEWS as readonly string[]).includes(kind)) { + return { + kind: 'calendar', + view: kind as CalendarLinkView, + ...(parseCalendarLinkDate(value) ? { date: value } : {}), + }; + } + return { kind: 'calendar', ...(parseCalendarLinkDate(kind) ? { date: kind } : {}) }; } case 'contacts': { if (kind && kind !== 'new') return { kind: 'contact', contactId: decodeSegment(kind) }; @@ -298,6 +313,12 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr serverId: link.eventId, accountId: link.jmapAccountId, }); + } else if (link.view || link.date) { + // The Calendar tab moves to the view and day. + setPendingCalendarDate({ + ...(link.view ? { view: link.view } : {}), + ...(link.date ? { date: link.date } : {}), + }); } navigation.navigate('MainTabs', { screen: 'Calendar' } as never); return true; diff --git a/src/navigation/pending-calendar-date.ts b/src/navigation/pending-calendar-date.ts new file mode 100644 index 00000000..4fb1e1c2 --- /dev/null +++ b/src/navigation/pending-calendar-date.ts @@ -0,0 +1,50 @@ +import { create } from 'zustand'; + +/** The views a calendar link names (webmail lib/deep-links.ts CALENDAR_VIEWS). */ +export const CALENDAR_LINK_VIEWS = ['month', 'week', 'day', 'agenda', 'tasks'] as const; +export type CalendarLinkView = typeof CALENDAR_LINK_VIEWS[number]; + +/** A `/calendar//` link: the view and the day to show. */ +export interface CalendarDateLink { + view?: CalendarLinkView; + /** `YYYY-MM-DD`, a local calendar day. */ + date?: string; +} + +/** + * `YYYY-MM-DD` as a local-midnight Date; null when malformed or impossible + * (2026-02-31 would otherwise roll over into March). The webmail's + * parseLinkDate. + */ +export function parseCalendarLinkDate(value: string | null | undefined): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? ''); + if (!match) return null; + const [, y, m, d] = match; + const date = new Date(Number(y), Number(m) - 1, Number(d)); + if (Number.isNaN(date.getTime())) return null; + if (date.getMonth() !== Number(m) - 1 || date.getDate() !== Number(d)) return null; + return date; +} + +// A date link arrives before CalendarScreen is mounted (cold start) or while +// it shows another day. It is parked here and consumed by the screen, like +// pending-calendar-open. +interface PendingCalendarDateState { + link: CalendarDateLink | null; + set: (link: CalendarDateLink | null) => void; + consume: () => CalendarDateLink | null; +} + +export const usePendingCalendarDate = create((set, get) => ({ + link: null, + set: (link) => set({ link }), + consume: () => { + const link = get().link; + if (link) set({ link: null }); + return link; + }, +})); + +export function setPendingCalendarDate(link: CalendarDateLink | null): void { + usePendingCalendarDate.getState().set(link); +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index d769e6ec..25c679e6 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -107,6 +107,11 @@ import { useUserCalendarAddresses } from '../lib/calendar-user-addresses'; import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store'; import { startCalendarNotificationSync } from '../lib/calendar-notifications'; import { useCalendarReminderOpen } from '../lib/calendar-reminder-open'; +import { + parseCalendarLinkDate, + usePendingCalendarDate, + type CalendarDateLink, +} from '../navigation/pending-calendar-date'; import { shareEventICS } from '../lib/calendar-ics-export'; import * as Clipboard from 'expo-clipboard'; import type { Calendar, CalendarEvent } from '../api/types'; @@ -516,6 +521,42 @@ export default function CalendarScreen() { [viewMode, visibleDate, focus.date], ); + // A `/calendar//` link (webmail applyCalendarDeepLink): the + // view it names, on the day it names. Without a view the view on screen + // stays; `tasks`, a view the phone has as a sheet, opens the task list. + const applyDateLink = React.useCallback( + (link: CalendarDateLink) => { + if (link.view === 'tasks') { + if (enableCalendarTasks) { + setTasksInitialId(null); + setTasksVisible(true); + } + } + const mode: ViewMode = link.view && link.view !== 'tasks' ? link.view : viewMode; + const date = parseCalendarLinkDate(link.date); + if (!date) { + changeViewMode(mode); + return; + } + setSelectedDate(date); + setVisibleDate(null); + setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); + setWindowState((prev) => + mode === viewMode + ? windowStateForJump(prev, windowMode(mode), date, windowOptions) + : freshScrollWindowState(windowMode(mode), date), + ); + setViewMode(mode); + }, + [enableCalendarTasks, viewMode, changeViewMode, windowOptions], + ); + const pendingDateLink = usePendingCalendarDate((s) => s.link); + React.useEffect(() => { + if (!pendingDateLink) return; + const link = usePendingCalendarDate.getState().consume(); + if (link) applyDateLink(link); + }, [pendingDateLink, applyDateLink]); + // The views, listed at the top of the drawer. const viewOptions = React.useMemo( () => [