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', () => {
|
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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 { 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[]>(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
Reference in New Issue
Block a user