feat: open a calendar link on the view and day it names

This commit is contained in:
Linus Rath
2026-10-31 05:13:57 +01:00
parent 7a15c0c6ac
commit fe45537037
5 changed files with 174 additions and 6 deletions
@@ -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/<view>/<yyyy-mm-dd>` 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/);
});
});
+2 -2
View File
@@ -22,7 +22,7 @@ describe('parseDeepLink', () => {
it('parses calendar, contacts, files and settings links', () => { it('parses calendar, contacts, files and settings links', () => {
expect(parseDeepLink('bulwarkmobile://calendar/event/E1')).toEqual({ kind: 'calendar', eventId: 'E1' }); 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://contacts')).toEqual({ kind: 'contacts' });
expect(parseDeepLink('bulwarkmobile://files')).toEqual({ kind: 'files' }); expect(parseDeepLink('bulwarkmobile://files')).toEqual({ kind: 'files' });
expect(parseDeepLink('bulwarkmobile://settings/notifications')).toEqual({ kind: 'settings', tab: 'notifications' }); expect(parseDeepLink('bulwarkmobile://settings/notifications')).toEqual({ kind: 'settings', tab: 'notifications' });
@@ -145,7 +145,7 @@ describe('handleDeepLink', () => {
}); });
expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' }); 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( await handleDeepLink(
{ kind: 'calendar', date: '2026-08-29' }, { kind: 'calendar', date: '2026-08-29' },
{ navigation: navigation as never, resolveThreadId: async () => null }, { navigation: navigation as never, resolveThreadId: async () => null },
+25 -4
View File
@@ -8,6 +8,12 @@ import { parseMailtoUrl } from '../lib/mailto';
import type { RootStackParamList } from './types'; import type { RootStackParamList } from './types';
import { setPendingSettingsTab } from './pending-settings-tab'; import { setPendingSettingsTab } from './pending-settings-tab';
import { setPendingCalendarOpen } from './pending-calendar-open'; 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 { setPendingMailSearch } from './pending-mail-search';
import { setPendingMailFolder } from './pending-mail-folder'; import { setPendingMailFolder } from './pending-mail-folder';
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link'; import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
@@ -38,8 +44,9 @@ export type DeepLink =
| { kind: 'search'; query: string } | { kind: 'search'; query: string }
// `jmapAccountId`: the JMAP account owning the event (`?account=`), for // `jmapAccountId`: the JMAP account owning the event (`?account=`), for
// one on a calendar shared with the user; `accountId`: the signed-in // one on a calendar shared with the user; `accountId`: the signed-in
// account it belongs to (`?appAccount=`, from widgets). // account it belongs to (`?appAccount=`, from widgets). `view` and `date`
| { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string; accountId?: string } // (`YYYY-MM-DD`): the view and day a `/calendar/<view>/<date>` link shows.
| { kind: 'calendar'; eventId?: string; view?: CalendarLinkView; date?: string; jmapAccountId?: string; accountId?: string }
| { kind: 'contact'; contactId: string } | { kind: 'contact'; contactId: string }
| { kind: 'contacts' } | { kind: 'contacts' }
| { kind: 'files' } | { kind: 'files' }
@@ -149,8 +156,16 @@ export function parseDeepLink(url: string): DeepLink | null {
...(appAccountId ? { accountId: appAccountId } : {}), ...(appAccountId ? { accountId: appAccountId } : {}),
}; };
} }
const date = [kind, value].find((s) => s && /^\d{4}-\d{2}-\d{2}$/.test(s)); // `/calendar/<view>[/<date>]`, or a bare `/calendar/<date>` that keeps
return { kind: 'calendar', date }; // 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': { case 'contacts': {
if (kind && kind !== 'new') return { kind: 'contact', contactId: decodeSegment(kind) }; 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, serverId: link.eventId,
accountId: link.jmapAccountId, 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); navigation.navigate('MainTabs', { screen: 'Calendar' } as never);
return true; return true;
+50
View File
@@ -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/<view>/<yyyy-mm-dd>` 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<PendingCalendarDateState>((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);
}
+41
View File
@@ -107,6 +107,11 @@ import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store'; import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
import { startCalendarNotificationSync } from '../lib/calendar-notifications'; import { startCalendarNotificationSync } from '../lib/calendar-notifications';
import { useCalendarReminderOpen } from '../lib/calendar-reminder-open'; 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 { shareEventICS } from '../lib/calendar-ics-export';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import type { Calendar, CalendarEvent } from '../api/types'; import type { Calendar, CalendarEvent } from '../api/types';
@@ -516,6 +521,42 @@ export default function CalendarScreen() {
[viewMode, visibleDate, focus.date], [viewMode, visibleDate, focus.date],
); );
// A `/calendar/<view>/<date>` 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. // The views, listed at the top of the drawer.
const viewOptions = React.useMemo<DrawerViewOption[]>( const viewOptions = React.useMemo<DrawerViewOption[]>(
() => [ () => [