mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: open a calendar link on the view and day it names
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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/<view>/<date>` 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/<view>[/<date>]`, or a bare `/calendar/<date>` 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/<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.
|
||||
const viewOptions = React.useMemo<DrawerViewOption[]>(
|
||||
() => [
|
||||
|
||||
Reference in New Issue
Block a user