feat: manage calendars from the drawer and scope, edit and auto-refresh iCal subscriptions

This commit is contained in:
Linus Rath
2026-08-29 18:14:48 +02:00
parent 42ab246cb9
commit dae5700688
11 changed files with 1188 additions and 74 deletions
+5 -5
View File
@@ -152,17 +152,17 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: plain `Calendar/set` (`src/api/calendar.ts:374-396`). Only matters once RN gains a create-calendar UI; RN has no WebDAV proxy, but it can talk to Stalwart's CalDAV endpoint directly (the same host serves `/dav/`). - What RN does: plain `Calendar/set` (`src/api/calendar.ts:374-396`). Only matters once RN gains a create-calendar UI; RN has no WebDAV proxy, but it can talk to Stalwart's CalDAV endpoint directly (the same host serves `/dav/`).
- Fix hint: when adding calendar creation, issue `MKCALENDAR` against the account's calendar-home with `supported-calendar-component-set`, then `Calendar/get` to find the new id. - Fix hint: when adding calendar creation, issue `MKCALENDAR` against the account's calendar-home with `supported-calendar-component-set`, then `Calendar/get` to find the new id.
- [ ] **No first-touch gate: duplicate default calendars on clustered Stalwart (#907)** — `P2` — `bugfix-parity` - [x] **No first-touch gate: duplicate default calendars on clustered Stalwart (#907)** — `P2` — `bugfix-parity` — fixed in 58e3ef6 (in-flight dedupe of fetchCalendars/fetchTasks; ensureRange awaits it)
- What WEB does: `FirstTouchGate` serialises the first `Calendar/*`/`CalendarEvent/*` request per account so concurrent first-touch requests cannot each create a default calendar (ref `lib/jmap/first-touch-gate.ts:56-101`, `lib/jmap/client.ts:678, 1129`, changelog 1.9.0 #907). - What WEB does: `FirstTouchGate` serialises the first `Calendar/*`/`CalendarEvent/*` request per account so concurrent first-touch requests cannot each create a default calendar (ref `lib/jmap/first-touch-gate.ts:56-101`, `lib/jmap/client.ts:678, 1129`, changelog 1.9.0 #907).
- What RN does: `CalendarScreen` fires `fetchCalendars()` on mount and, in a second effect, `ensureRange()` which sees `calendars.length === 0` and calls `fetchCalendars()` again then `CalendarEvent/query` (`src/screens/CalendarScreen.tsx:261-269`, `src/stores/calendar-store.ts:235-251`); `auth-store` also kicks `fetchCalendars`/`refresh` after login (`src/stores/auth-store.ts:92-98`). Two to three concurrent first-touch calls — exactly the #907 race. RN affected. - What RN does: `CalendarScreen` fires `fetchCalendars()` on mount and, in a second effect, `ensureRange()` which sees `calendars.length === 0` and calls `fetchCalendars()` again then `CalendarEvent/query` (`src/screens/CalendarScreen.tsx:261-269`, `src/stores/calendar-store.ts:235-251`); `auth-store` also kicks `fetchCalendars`/`refresh` after login (`src/stores/auth-store.ts:92-98`). Two to three concurrent first-touch calls — exactly the #907 race. RN affected.
- Fix hint: port `FirstTouchGate` into `src/api/jmap-client.ts` `request()`, or at minimum dedupe in-flight `fetchCalendars` and make `ensureRange` await the mount fetch. - Fix hint: port `FirstTouchGate` into `src/api/jmap-client.ts` `request()`, or at minimum dedupe in-flight `fetchCalendars` and make `ensureRange` await the mount fetch.
- [ ] **Tasks-only calendar detection misses CalDAV tasks without `@type`** — `P2` — `bugfix-parity` - [x] **Tasks-only calendar detection misses CalDAV tasks without `@type`** — `P2` — `bugfix-parity` — fixed in 58e3ef6
- What WEB does: `isTaskLikeObject` treats `@type: Task` OR presence of `due`/`progress`/`percentComplete` (and `@type !== 'Event'`) as a task, both for the tasks list and for hiding tasks-only calendars (ref `lib/calendar-component-detection.ts:30-58`, `lib/jmap/client.ts:6300-6316`, changelog 1.5.x #84 "Detect tasks created by external CalDAV clients such as Thunderbird"). - What WEB does: `isTaskLikeObject` treats `@type: Task` OR presence of `due`/`progress`/`percentComplete` (and `@type !== 'Event'`) as a task, both for the tasks list and for hiding tasks-only calendars (ref `lib/calendar-component-detection.ts:30-58`, `lib/jmap/client.ts:6300-6316`, changelog 1.5.x #84 "Detect tasks created by external CalDAV clients such as Thunderbird").
- What RN does: `fetchEvents` classifies solely on `'@type' === 'Task'` (`src/stores/calendar-store.ts:208-227`); Thunderbird/Todoist VTODOs that Stalwart returns without `@type` are neither shown as tasks nor counted for `taskOnlyCalendarIds` (they fall through as start-less "events" and are dropped by `!!e.start`). - What RN does: `fetchEvents` classifies solely on `'@type' === 'Task'` (`src/stores/calendar-store.ts:208-227`); Thunderbird/Todoist VTODOs that Stalwart returns without `@type` are neither shown as tasks nor counted for `taskOnlyCalendarIds` (they fall through as start-less "events" and are dropped by `!!e.start`).
- Fix hint: port `isTaskLikeObject` and use it in both filters. - Fix hint: port `isTaskLikeObject` and use it in both filters.
- [ ] **Shared accounts without calendar access are re-probed on every fetch** — `P3` — `bugfix-parity` - [x] **Shared accounts without calendar access are re-probed on every fetch** — `P3` — `bugfix-parity` — fixed in 58e3ef6
- What WEB does: remembers `forbidden`/`accountNotFound` per account in `calendarAccessDenied` and skips it (ref `lib/jmap/client.ts:5806-5813`, changelog 1.8.1 "Stop re-probing shared accounts that have no calendar access"). - What WEB does: remembers `forbidden`/`accountNotFound` per account in `calendarAccessDenied` and skips it (ref `lib/jmap/client.ts:5806-5813`, changelog 1.8.1 "Stop re-probing shared accounts that have no calendar access").
- What RN does: `getCalendars` and `fetchEvents` try every calendar-capable session account each time and swallow errors (`src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:194-204`). - What RN does: `getCalendars` and `fetchEvents` try every calendar-capable session account each time and swallow errors (`src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:194-204`).
- Fix hint: keep a module-level `Set` of denied account ids, reset on reconnect. - Fix hint: keep a module-level `Set` of denied account ids, reset on reconnect.
@@ -226,7 +226,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
### Data loading / sync ### Data loading / sync
- [ ] **Range queries fetch every event in the account (no `after`/`before`), capped at 1000** — `P2` — `bug` - [x] **Range queries fetch every event in the account (no `after`/`before`), capped at 1000** — `P2` — `bug` — fixed in 58e3ef6
- What WEB does: `CalendarEvent/query` with `{ after, before }` + `timeZone` (changelog 1.7.0 "timezone-aware calendar queries", 1.7.1 "Stalwart-compatible calendar filters") (ref `lib/jmap/client.ts:5722-5745`). - What WEB does: `CalendarEvent/query` with `{ after, before }` + `timeZone` (changelog 1.7.0 "timezone-aware calendar queries", 1.7.1 "Stalwart-compatible calendar filters") (ref `lib/jmap/client.ts:5722-5745`).
- What RN does: `queryEvents` ignores its `_after`/`_before` arguments citing "Stalwart rejects after/before filters" (outdated) and uses `limit: 1000` (`src/api/calendar.ts:188-208`); `fetchEvents` then re-fetches everything whenever `ensureRange` widens the union (`src/stores/calendar-store.ts:235-251`). Accounts with more than 1000 objects silently lose events; every navigation past the loaded range re-downloads all events. - What RN does: `queryEvents` ignores its `_after`/`_before` arguments citing "Stalwart rejects after/before filters" (outdated) and uses `limit: 1000` (`src/api/calendar.ts:188-208`); `fetchEvents` then re-fetches everything whenever `ensureRange` widens the union (`src/stores/calendar-store.ts:235-251`). Accounts with more than 1000 objects silently lose events; every navigation past the loaded range re-downloads all events.
- Fix hint: send `filter: { after, before, inCalendar... }` as WEB does (`{ operator: 'AND', conditions: [...] }` when combined), keep `timeZone`; then `loadedRange` can be the requested window rather than a growing union. - Fix hint: send `filter: { after, before, inCalendar... }` as WEB does (`{ operator: 'AND', conditions: [...] }` when combined), keep `timeZone`; then `loadedRange` can be the requested window rather than a growing union.
@@ -246,7 +246,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: `fetch(url)` from the phone (`src/stores/calendar-subscriptions-store.ts:42-53`). No server is exposed, so the SSRF class does not apply; the device can be pointed at LAN hosts, which is the user's own network. Redirects are followed by `fetch` without validation (fine). N/A for the advisory; worth a 10 MB size cap and a `text/calendar`-ish content check (already checks `BEGIN:VCALENDAR`). - What RN does: `fetch(url)` from the phone (`src/stores/calendar-subscriptions-store.ts:42-53`). No server is exposed, so the SSRF class does not apply; the device can be pointed at LAN hosts, which is the user's own network. Redirects are followed by `fetch` without validation (fine). N/A for the advisory; worth a 10 MB size cap and a `text/calendar`-ish content check (already checks `BEGIN:VCALENDAR`).
- Fix hint: optional size cap. - Fix hint: optional size cap.
- [ ] **Created recurring event appears as a single instance until refresh; created event lacks `utcStart`/`utcEnd`** — `P3` — `partial` - [x] **Created recurring event appears as a single instance until refresh; created event lacks `utcStart`/`utcEnd`** — `P3` — `partial` — fixed in 58e3ef6
- What WEB does: `createCalendarEvent` refetches the created event with full properties (`lib/jmap/client.ts:5942-5960`) and the store refetches when occurrences are in view. - What WEB does: `createCalendarEvent` refetches the created event with full properties (`lib/jmap/client.ts:5942-5960`) and the store refetches when occurrences are in view.
- What RN does: `createEvent` merges the `/set` echo over the payload (`src/api/calendar.ts:272-274`) and appends without expansion (`src/stores/calendar-store.ts:286-304`). - What RN does: `createEvent` merges the `/set` echo over the payload (`src/api/calendar.ts:272-274`) and appends without expansion (`src/stores/calendar-store.ts:286-304`).
- Fix hint: after create, `CalendarEvent/get` the id (RN `getEvents([id])`) and, if `recurrenceRules` is set, run `expandRecurringEvents` for `loadedRange` or call `refresh()`. - Fix hint: after create, `CalendarEvent/get` the id (RN `getEvents([id])`) and, if `recurrenceRules` is set, run `expandRecurringEvents` for `loadedRange` or call `refresh()`.
+116 -6
View File
@@ -1,6 +1,6 @@
import { jmapClient } from './jmap-client'; import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types'; import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types';
import { getEffectiveTimeZone } from '../lib/calendar-timezone'; import { getEffectiveTimeZone } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
@@ -489,12 +489,15 @@ export async function parseCalendarBlob(blobId: string): Promise<Partial<Calenda
export async function createCalendar( export async function createCalendar(
name: string, name: string,
color?: string, color?: string,
description?: string,
): Promise<Calendar> { ): Promise<Calendar> {
const accountId = jmapClient.accountId; const accountId = jmapClient.accountId;
const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true };
if (description?.trim()) props.description = description.trim();
const res = await jmapClient.request( const res = await jmapClient.request(
[['Calendar/set', { [['Calendar/set', {
accountId, accountId,
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } }, create: { 'new-cal': props },
}, '0']], }, '0']],
USING, USING,
); );
@@ -527,12 +530,119 @@ export async function setDefaultCalendar(
methodResult(res); methodResult(res);
} }
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too — // Destroy a calendar. `onDestroyRemoveEvents` removes its events too —
// Stalwart otherwise refuses to delete a non-empty calendar. // Stalwart otherwise refuses to delete a non-empty calendar.
export async function deleteCalendar(id: string): Promise<void> { export async function deleteCalendar(id: string, targetAccountId?: string): Promise<void> {
const accountId = jmapClient.accountId; const accountId = targetAccountId || jmapClient.accountId;
await jmapClient.request( const res = await jmapClient.request(
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']], [['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
USING, USING,
); );
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notDestroyed?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to delete calendar');
}
export interface CalendarUpdates {
name?: string;
color?: string | null;
description?: string | null;
sortOrder?: number;
isSubscribed?: boolean;
}
// Rename / recolour / describe a calendar (Calendar/set update).
export async function updateCalendar(
id: string,
updates: CalendarUpdates,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', { accountId, update: { [id]: updates } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update calendar');
}
// Add, update, or remove a principal's rights on an owned calendar via a
// `shareWith/{principalId}` patch (null removes the share).
export async function setCalendarShare(
calendarId: string,
principalId: string,
rights: CalendarRights | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', {
accountId,
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
}, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[calendarId];
if (err) throw new Error(err.description || err.type || 'Failed to update sharing');
}
/**
* Remove every event from a calendar. Events that live ONLY in this calendar
* are destroyed; events also linked to another calendar are unlinked instead
* (their `calendarIds` loses this calendar) so the user's copy elsewhere is
* not cascade-deleted. Returns the number of events removed.
*/
export async function clearCalendarEvents(
calendarId: string,
targetAccountId?: string,
): Promise<number> {
const accountId = targetAccountId || jmapClient.accountId;
let removed = 0;
// Loop for accounts with more than one query page of objects.
for (let pass = 0; pass < 20; pass++) {
const ids = await queryEvents([calendarId], '', '', accountId);
if (ids.length === 0) break;
const batchSize = jmapClient.getMaxObjectsInGet();
const objects: Array<{ id: string; calendarIds?: Record<string, boolean> }> = [];
for (let i = 0; i < ids.length; i += batchSize) {
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId,
ids: ids.slice(i, i + batchSize),
properties: ['id', 'calendarIds'],
}, '0']],
USING,
);
objects.push(...(methodResult<{ list: typeof objects }>(res).list ?? []));
}
const toDestroy: string[] = [];
const toUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
for (const obj of objects) {
const others = { ...(obj.calendarIds || {}) };
delete others[calendarId];
if (Object.keys(others).length === 0) toDestroy.push(obj.id);
else toUnlink.push({ id: obj.id, calendarIds: others });
}
if (toDestroy.length > 0) {
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, destroy: toDestroy }, '0']],
USING,
);
removed += (methodResult<{ destroyed?: string[] }>(res).destroyed ?? []).length;
}
if (toUnlink.length > 0) {
const update: Record<string, unknown> = {};
for (const { id, calendarIds } of toUnlink) update[id] = { calendarIds };
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, update }, '0']],
USING,
);
removed += Object.keys(methodResult<{ updated?: Record<string, unknown> }>(res).updated ?? {}).length;
}
if (toDestroy.length === 0 && toUnlink.length === 0) break;
if (ids.length < 1000) break;
}
return removed;
} }
+2
View File
@@ -544,6 +544,8 @@ export interface Calendar {
isDefault?: boolean; isDefault?: boolean;
sortOrder?: number; sortOrder?: number;
myRights?: CalendarRights; myRights?: CalendarRights;
// Principals this (owned) calendar is shared with and their rights.
shareWith?: Record<string, CalendarRights> | null;
// Client-only: JMAP account the calendar was fetched from. Absent for the // Client-only: JMAP account the calendar was fetched from. Absent for the
// primary account; set for calendars shared with the user. // primary account; set for calendars shared with the user.
accountId?: string; accountId?: string;
@@ -0,0 +1,182 @@
import React from 'react';
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Check } from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
import Button from '../Button';
export interface CalendarEditValues {
name: string;
color: string;
description: string;
}
interface CalendarEditSheetProps {
visible: boolean;
// Existing calendar to edit; null creates a new one.
calendar: Calendar | null;
onSave: (values: CalendarEditValues) => Promise<void>;
onClose: () => void;
}
// Create / rename / recolour / describe a calendar. The same fields the
// webmail's calendar-management settings offer, as a bottom sheet.
export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [name, setName] = React.useState('');
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
const [description, setDescription] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!visible) return;
setName(calendar?.name ?? '');
setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]);
setDescription(calendar?.description ?? '');
setError(null);
setSaving(false);
}, [visible, calendar]);
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed || saving) return;
setSaving(true);
setError(null);
try {
await onSave({ name: trimmed, color, description: description.trim() });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : t('calendar.notifications.event_error', 'Something went wrong'));
} finally {
setSaving(false);
}
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.title}>
{calendar
? t('calendar.management.edit_calendar', 'Edit calendar')
: t('calendar.management.new_calendar', 'New calendar')}
</Text>
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={styles.body}>
<Text style={styles.label}>{t('calendar.management.name', 'Name')}</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder={t('calendar.management.name_placeholder', 'Calendar name')}
placeholderTextColor={c.textMuted}
style={styles.input}
autoFocus
/>
<Text style={styles.label}>{t('calendar.management.color', 'Color')}</Text>
<View style={styles.paletteRow}>
{CALENDAR_COLOR_PALETTE.map((col) => {
const active = color.toLowerCase() === col.toLowerCase();
return (
<Pressable
key={col}
onPress={() => setColor(col)}
style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]}
>
{active && <Check size={12} color={c.textInverse} />}
</Pressable>
);
})}
</View>
<Text style={styles.label}>{t('calendar.management.description', 'Description')}</Text>
<TextInput
value={description}
onChangeText={setDescription}
placeholder={t('calendar.management.description_placeholder', 'Optional')}
placeholderTextColor={c.textMuted}
style={[styles.input, styles.multiline]}
multiline
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
<View style={styles.footer}>
<Button variant="outline" size="sm" onPress={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
size="sm"
onPress={() => { void handleSave(); }}
disabled={!name.trim() || saving}
icon={saving ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
>
{calendar ? t('common.save', 'Save') : t('common.create', 'Create')}
</Button>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
padding: spacing.lg,
paddingBottom: spacing.xxl,
maxHeight: '85%',
gap: spacing.md,
},
title: { ...typography.h3, color: c.text },
body: { gap: spacing.sm },
label: { ...typography.captionMedium, color: c.textSecondary, marginTop: spacing.xs },
input: {
minHeight: 40,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: c.text,
backgroundColor: c.surface,
...typography.body,
},
multiline: { minHeight: 64, textAlignVertical: 'top' },
paletteRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
swatch: {
width: 28,
height: 28,
borderRadius: radius.full,
alignItems: 'center',
justifyContent: 'center',
},
swatchActive: { borderWidth: 2, borderColor: c.text },
error: { ...typography.caption, color: c.error },
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md },
});
}
@@ -0,0 +1,323 @@
import React from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Trash2, UserPlus, Users } from 'lucide-react-native';
import { getPrincipals, ownPrincipalId } from '../../api/files';
import type { Calendar, CalendarRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager';
// Same presets the webmail's share-collection dialog offers for calendars.
const CALENDAR_PRESETS: Record<RolePreset, CalendarRights> = {
freeBusy: {
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
read: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
readWrite: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
},
manager: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
},
};
const PRESET_LABEL_KEYS: Record<RolePreset, [string, string]> = {
freeBusy: ['calendar.share.role_free_busy', 'Free/busy'],
read: ['calendar.share.role_viewer', 'Viewer'],
readWrite: ['calendar.share.role_editor', 'Editor'],
manager: ['calendar.share.role_manager', 'Manager'],
};
const PRESET_ORDER: RolePreset[] = ['freeBusy', 'read', 'readWrite', 'manager'];
function detectPreset(rights: CalendarRights): RolePreset | 'custom' {
for (const preset of PRESET_ORDER) {
const expected = CALENDAR_PRESETS[preset];
const keys = Object.keys(expected) as (keyof CalendarRights)[];
if (keys.every((k) => !!expected[k] === !!rights[k])) return preset;
}
return 'custom';
}
interface CalendarShareSheetProps {
calendar: Calendar | null;
onShare: (calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
onClose: () => void;
}
// JMAP sharing for an owned calendar: pick a principal, choose a role. The
// principal list comes from the same Principal/query the Files share sheet
// uses.
export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShareSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [principals, setPrincipals] = React.useState<Principal[]>([]);
const [loading, setLoading] = React.useState(false);
const [shares, setShares] = React.useState<Record<string, CalendarRights>>({});
const [search, setSearch] = React.useState('');
const [savingId, setSavingId] = React.useState<string | null>(null);
React.useEffect(() => {
if (!calendar) return;
setShares(calendar.shareWith ?? {});
setSearch('');
setLoading(true);
getPrincipals()
.then(setPrincipals)
.catch(() => setPrincipals([]))
.finally(() => setLoading(false));
}, [calendar]);
const principalsById = React.useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
return map;
}, [principals]);
const sharedEntries = React.useMemo(
() => Object.entries(shares).filter(([, rights]) => rights != null),
[shares],
);
const candidates = React.useMemo(() => {
const self = ownPrincipalId();
const q = search.trim().toLowerCase();
return principals
.filter((p) => p.id !== self && shares[p.id] == null)
.filter((p) =>
!q ||
p.name?.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q))
.slice(0, 25);
}, [principals, shares, search]);
const applyShare = async (principalId: string, rights: CalendarRights | null) => {
if (!calendar || savingId) return;
setSavingId(principalId);
try {
await onShare(calendar.id, principalId, rights);
setShares((prev) => {
const next = { ...prev };
if (rights == null) delete next[principalId];
else next[principalId] = rights;
return next;
});
} catch (e) {
Alert.alert(
t('calendar.share.error', 'Failed to update sharing'),
e instanceof Error ? e.message : String(e),
);
} finally {
setSavingId(null);
}
};
if (!calendar) return null;
const renderPrincipalLabel = (principalId: string) => {
const p = principalsById.get(principalId);
return (
<View style={styles.principalInfo}>
<Text style={styles.principalName} numberOfLines={1}>
{p?.description || p?.name || principalId}
</Text>
{p?.email ? (
<Text style={styles.principalEmail} numberOfLines={1}>{p.email}</Text>
) : null}
</View>
);
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Users size={18} color={c.textMuted} />
<Text style={styles.title} numberOfLines={1}>
{t('calendar.share.title', 'Share')} “{calendar.name}”
</Text>
</View>
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="handled">
{sharedEntries.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionLabel}>{t('calendar.share.shared_with', 'Shared with')}</Text>
{sharedEntries.map(([principalId, rights]) => {
const preset = detectPreset(rights);
const busy = savingId === principalId;
return (
<View key={principalId} style={styles.shareRow}>
{renderPrincipalLabel(principalId)}
<View style={styles.roleChips}>
{PRESET_ORDER.map((p) => (
<Pressable
key={p}
onPress={() => void applyShare(principalId, CALENDAR_PRESETS[p])}
disabled={busy}
style={[styles.chip, preset === p && styles.chipActive]}
>
<Text style={[styles.chipText, preset === p && styles.chipTextActive]}>
{t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])}
</Text>
</Pressable>
))}
{preset === 'custom' ? (
<Text style={styles.customLabel}>{t('calendar.share.role_custom', 'Custom')}</Text>
) : null}
<Pressable
onPress={() => void applyShare(principalId, null)}
disabled={busy}
hitSlop={8}
style={styles.removeBtn}
>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Trash2 size={16} color={c.error} />
)}
</Pressable>
</View>
</View>
);
})}
</View>
) : null}
<View style={styles.section}>
<Text style={styles.sectionLabel}>{t('calendar.share.add_people', 'Add people')}</Text>
<TextInput
value={search}
onChangeText={setSearch}
placeholder={t('calendar.share.search_placeholder', 'Search by name or email')}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
/>
{loading ? (
<ActivityIndicator size="small" color={c.textMuted} style={{ marginTop: spacing.md }} />
) : candidates.length === 0 ? (
<Text style={styles.empty}>
{principals.length === 0
? t('calendar.share.no_principals', 'Sharing is not available on this server.')
: t('calendar.share.no_matches', 'No matches')}
</Text>
) : (
candidates.map((p) => (
<Pressable
key={p.id}
onPress={() => void applyShare(p.id, CALENDAR_PRESETS.read)}
disabled={!!savingId}
style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]}
>
{renderPrincipalLabel(p.id)}
{savingId === p.id ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<UserPlus size={18} color={c.primary} />
)}
</Pressable>
))
)}
</View>
</ScrollView>
<Pressable onPress={onClose} style={styles.doneBtn}>
<Text style={styles.doneText}>{t('common.done', 'Done')}</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
sheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
padding: spacing.lg,
paddingBottom: spacing.xxl,
maxHeight: '85%',
},
titleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.md },
title: { ...typography.h3, color: c.text, flex: 1 },
scroll: { flexGrow: 0 },
section: { marginBottom: spacing.lg, gap: spacing.sm },
sectionLabel: { ...typography.captionMedium, color: c.textSecondary },
shareRow: {
gap: spacing.xs,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
principalInfo: { flex: 1, minWidth: 0 },
principalName: { ...typography.body, color: c.text },
principalEmail: { ...typography.caption, color: c.textMuted },
roleChips: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: spacing.xs },
chip: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
chipActive: { backgroundColor: c.primary, borderColor: c.primary },
chipText: { ...typography.caption, color: c.text },
chipTextActive: { color: c.primaryForeground },
customLabel: { ...typography.caption, color: c.textMuted },
removeBtn: { marginLeft: 'auto', padding: 4 },
input: {
minHeight: 40,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
color: c.text,
backgroundColor: c.surface,
...typography.body,
},
empty: { ...typography.caption, color: c.textMuted, paddingVertical: spacing.sm },
candidateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
rowPressed: { backgroundColor: c.surfaceHover },
doneBtn: { alignSelf: 'flex-end', paddingVertical: spacing.sm, paddingHorizontal: spacing.md },
doneText: { ...typography.bodyMedium, color: c.primary },
});
}
@@ -11,7 +11,9 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Check, X, Upload, Rss, Shuffle, Star } from 'lucide-react-native'; import {
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2,
} from 'lucide-react-native';
import type { Calendar } from '../../api/types'; import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
@@ -28,11 +30,19 @@ interface CalendarSidebarDrawerProps {
onClose: () => void; onClose: () => void;
onImport?: () => void; onImport?: () => void;
onManageSubscriptions?: () => void; onManageSubscriptions?: () => void;
// Long-press actions. Set-default applies to the user's own calendars; onCreate?: () => void;
// color change/reset applies to shared calendars (per-viewer recolor). // Long-press actions. Set-default / rename / share / clear / delete apply
// to the user's own calendars; color change applies to own calendars
// (server-side) and shared calendars (per-viewer recolor).
onSetDefault?: (calendar: Calendar) => void; onSetDefault?: (calendar: Calendar) => void;
onSetColor?: (calendar: Calendar, color: string) => void; onSetColor?: (calendar: Calendar, color: string) => void;
onResetColor?: (calendar: Calendar) => void; onResetColor?: (calendar: Calendar) => void;
onRename?: (calendar: Calendar) => void;
onShare?: (calendar: Calendar) => void;
onClear?: (calendar: Calendar) => void;
onDelete?: (calendar: Calendar) => void;
// Client-side iCal subscriptions are managed from the subscriptions sheet.
isSubscriptionCalendar?: (calendarId: string) => boolean;
} }
export function CalendarSidebarDrawer({ export function CalendarSidebarDrawer({
@@ -43,9 +53,15 @@ export function CalendarSidebarDrawer({
onClose, onClose,
onImport, onImport,
onManageSubscriptions, onManageSubscriptions,
onCreate,
onSetDefault, onSetDefault,
onSetColor, onSetColor,
onResetColor, onResetColor,
onRename,
onShare,
onClear,
onDelete,
isSubscriptionCalendar,
}: CalendarSidebarDrawerProps) { }: CalendarSidebarDrawerProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -102,6 +118,11 @@ export function CalendarSidebarDrawer({
onSetDefault, onSetDefault,
onSetColor, onSetColor,
onResetColor, onResetColor,
onRename,
onShare,
onClear,
onDelete,
isSubscriptionCalendar,
}; };
return ( return (
@@ -144,8 +165,17 @@ export function CalendarSidebarDrawer({
<Section title="Subscribed" calendars={subscribed} {...sectionProps} /> <Section title="Subscribed" calendars={subscribed} {...sectionProps} />
)} )}
{(onImport || onManageSubscriptions) && ( {(onImport || onManageSubscriptions || onCreate) && (
<View style={styles.actionsSection}> <View style={styles.actionsSection}>
{onCreate && (
<Pressable
onPress={onCreate}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Plus size={18} color={c.textSecondary} />
<Text style={styles.actionText}>New calendar</Text>
</Pressable>
)}
{onManageSubscriptions && ( {onManageSubscriptions && (
<Pressable <Pressable
onPress={onManageSubscriptions} onPress={onManageSubscriptions}
@@ -183,6 +213,11 @@ function Section({
onSetDefault, onSetDefault,
onSetColor, onSetColor,
onResetColor, onResetColor,
onRename,
onShare,
onClear,
onDelete,
isSubscriptionCalendar,
}: { }: {
title: string; title: string;
calendars: Calendar[]; calendars: Calendar[];
@@ -193,6 +228,11 @@ function Section({
onSetDefault?: (calendar: Calendar) => void; onSetDefault?: (calendar: Calendar) => void;
onSetColor?: (calendar: Calendar, color: string) => void; onSetColor?: (calendar: Calendar, color: string) => void;
onResetColor?: (calendar: Calendar) => void; onResetColor?: (calendar: Calendar) => void;
onRename?: (calendar: Calendar) => void;
onShare?: (calendar: Calendar) => void;
onClear?: (calendar: Calendar) => void;
onDelete?: (calendar: Calendar) => void;
isSubscriptionCalendar?: (calendarId: string) => boolean;
}) { }) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -202,9 +242,17 @@ function Section({
{calendars.map((cal) => { {calendars.map((cal) => {
const visible = !hiddenSet.has(cal.id); const visible = !hiddenSet.has(cal.id);
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID; const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault; const isSubscription = !!isSubscriptionCalendar?.(cal.id);
const canRecolor = !!onSetColor && !!cal.isShared; const isOwn = !cal.isShared && !isBirthday && !isSubscription;
const hasActions = canSetDefault || canRecolor; const canSetDefault = !!onSetDefault && isOwn && !cal.isDefault;
// Own calendars recolour server-side; shared ones locally (#345).
const canRecolor = !!onSetColor && (!!cal.isShared || isOwn);
const canRename = !!onRename && isOwn;
const canShare = !!onShare && isOwn && (!cal.myRights || cal.myRights.mayShare !== false);
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
// Like webmail: the default calendar and shared calendars can't be deleted.
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
const expanded = expandedId === cal.id && hasActions; const expanded = expandedId === cal.id && hasActions;
return ( return (
<View key={cal.id}> <View key={cal.id}>
@@ -246,6 +294,15 @@ function Section({
<Text style={styles.panelRowText}>Set as default calendar</Text> <Text style={styles.panelRowText}>Set as default calendar</Text>
</Pressable> </Pressable>
)} )}
{canRename && (
<Pressable
onPress={() => { onExpand(null); onRename!(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Pencil size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Edit name and description</Text>
</Pressable>
)}
{canRecolor && ( {canRecolor && (
<> <>
<View style={styles.paletteRow}> <View style={styles.paletteRow}>
@@ -266,7 +323,7 @@ function Section({
); );
})} })}
</View> </View>
{!!onResetColor && cal.colorIsLocalOverride && ( {!!onResetColor && cal.isShared && cal.colorIsLocalOverride && (
<Pressable <Pressable
onPress={() => { onExpand(null); onResetColor(cal); }} onPress={() => { onExpand(null); onResetColor(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
@@ -277,6 +334,33 @@ function Section({
)} )}
</> </>
)} )}
{canShare && (
<Pressable
onPress={() => { onExpand(null); onShare!(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Share2 size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Share…</Text>
</Pressable>
)}
{canClear && (
<Pressable
onPress={() => { onExpand(null); onClear!(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Eraser size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Remove all events</Text>
</Pressable>
)}
{canDelete && (
<Pressable
onPress={() => { onExpand(null); onDelete!(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Trash2 size={16} color={c.error} />
<Text style={[styles.panelRowText, { color: c.error }]}>Delete calendar</Text>
</Pressable>
)}
</View> </View>
)} )}
</View> </View>
@@ -9,7 +9,20 @@ import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils'; import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils';
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store'; import {
DEFAULT_REFRESH_INTERVAL_MINUTES,
selectAccountSubscriptions,
useCalendarSubscriptionsStore,
type CalendarSubscription,
} from '../../stores/calendar-subscriptions-store';
import { jmapClient } from '../../api/jmap-client';
const INTERVAL_OPTIONS: { minutes: number; label: string }[] = [
{ minutes: 15, label: '15 min' },
{ minutes: 60, label: '1 h' },
{ minutes: 360, label: '6 h' },
{ minutes: 1440, label: '1 day' },
];
interface Props { interface Props {
visible: boolean; visible: boolean;
@@ -20,26 +33,54 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions); const allSubscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
// Only the signed-in account's feeds: a sub created under another account
// mirrors into a calendar id that doesn't exist (or collides) here.
const subscriptions = React.useMemo(
() => selectAccountSubscriptions(allSubscriptions, jmapClient.isConnected ? jmapClient.accountId : null),
[allSubscriptions],
);
const syncing = useCalendarSubscriptionsStore((s) => s.syncing); const syncing = useCalendarSubscriptionsStore((s) => s.syncing);
const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription); const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription);
const updateSubscription = useCalendarSubscriptionsStore((s) => s.updateSubscription);
const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription); const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription);
const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription); const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription);
const [name, setName] = React.useState(''); const [name, setName] = React.useState('');
const [url, setUrl] = React.useState(''); const [url, setUrl] = React.useState('');
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]); const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
const [interval, setInterval] = React.useState<number>(DEFAULT_REFRESH_INTERVAL_MINUTES);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [adding, setAdding] = React.useState(false); const [adding, setAdding] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null); const [formError, setFormError] = React.useState<string | null>(null);
const startEdit = (sub: CalendarSubscription) => {
setEditingId(sub.id);
setName(sub.name);
setUrl(sub.url);
setColor(sub.color || CALENDAR_COLOR_PALETTE[0]);
setInterval(sub.refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES);
setFormError(null);
};
const resetForm = () => {
setEditingId(null);
setName('');
setUrl('');
setColor(CALENDAR_COLOR_PALETTE[0]);
setInterval(DEFAULT_REFRESH_INTERVAL_MINUTES);
setFormError(null);
};
const slideY = React.useRef(new Animated.Value(900)).current; const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => { React.useEffect(() => {
if (visible) { if (visible) {
setEditingId(null);
setName(''); setName('');
setUrl(''); setUrl('');
setColor(CALENDAR_COLOR_PALETTE[0]); setColor(CALENDAR_COLOR_PALETTE[0]);
setInterval(DEFAULT_REFRESH_INTERVAL_MINUTES);
setFormError(null); setFormError(null);
Animated.parallel([ Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
@@ -53,27 +94,26 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
} }
}, [visible, slideY, overlayOpacity]); }, [visible, slideY, overlayOpacity]);
const handleAdd = async () => { const handleSubmit = async () => {
const n = name.trim(); const n = name.trim();
const u = url.trim(); const u = url.trim();
if (!n || !u) { if (!n || !u) {
setFormError('Enter a name and a feed URL.'); setFormError('Enter a name and a feed URL.');
return; return;
} }
if (!/^(https?|webcal):\/\//i.test(u)) { if (!/^(https?|webcals?):\/\//i.test(u)) {
setFormError('URL must start with https://, http:// or webcal://'); setFormError('URL must start with https://, http://, webcal:// or webcals://');
return; return;
} }
setFormError(null); setFormError(null);
setAdding(true); setAdding(true);
try { try {
const sub = await addSubscription({ name: n, url: u, color }); if (editingId) {
if (sub.lastError) { await updateSubscription(editingId, { name: n, url: u, color, refreshIntervalMinutes: interval });
setFormError(sub.lastError);
} else { } else {
setName(''); await addSubscription({ name: n, url: u, color, refreshIntervalMinutes: interval });
setUrl('');
} }
resetForm();
} catch (e) { } catch (e) {
setFormError(e instanceof Error ? e.message : 'Could not add subscription.'); setFormError(e instanceof Error ? e.message : 'Could not add subscription.');
} finally { } finally {
@@ -97,7 +137,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
</View> </View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}> <ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
<Text style={styles.sectionLabel}>Add a feed</Text> <Text style={styles.sectionLabel}>{editingId ? 'Edit subscription' : 'Add a feed'}</Text>
<TextInput <TextInput
value={name} value={name}
onChangeText={setName} onChangeText={setName}
@@ -128,19 +168,43 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
/> />
))} ))}
</View> </View>
<Text style={styles.fieldLabel}>Refresh every</Text>
<View style={styles.intervalRow}>
{INTERVAL_OPTIONS.map((opt) => {
const active = interval === opt.minutes;
return (
<Pressable
key={opt.minutes}
onPress={() => setInterval(opt.minutes)}
style={[styles.intervalChip, active && styles.intervalChipActive]}
>
<Text style={[styles.intervalChipText, active && styles.intervalChipTextActive]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
{!!formError && ( {!!formError && (
<View style={styles.errorBox}> <View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} /> <AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{formError}</Text> <Text style={styles.errorText}>{formError}</Text>
</View> </View>
)} )}
<Button <View style={styles.formActions}>
onPress={() => { void handleAdd(); }} {editingId && (
disabled={adding} <Button variant="outline" onPress={resetForm} disabled={adding}>
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />} Cancel
> </Button>
{adding ? 'Subscribing…' : 'Subscribe'} )}
</Button> <Button
onPress={() => { void handleSubmit(); }}
disabled={adding}
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
>
{adding ? (editingId ? 'Saving…' : 'Subscribing…') : editingId ? 'Save changes' : 'Subscribe'}
</Button>
</View>
{subscriptions.length > 0 && ( {subscriptions.length > 0 && (
<> <>
@@ -150,7 +214,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
return ( return (
<View key={sub.id} style={styles.subRow}> <View key={sub.id} style={styles.subRow}>
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} /> <View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
<View style={{ flex: 1, minWidth: 0 }}> <Pressable style={{ flex: 1, minWidth: 0 }} onPress={() => startEdit(sub)}>
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text> <Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
<Text style={styles.subMeta} numberOfLines={1}> <Text style={styles.subMeta} numberOfLines={1}>
{sub.lastError {sub.lastError
@@ -159,7 +223,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}` ? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
: 'Not synced yet'} : 'Not synced yet'}
</Text> </Text>
</View> </Pressable>
<Pressable <Pressable
onPress={() => { void syncSubscription(sub.id); }} onPress={() => { void syncSubscription(sub.id); }}
hitSlop={6} hitSlop={6}
@@ -232,6 +296,20 @@ function makeStyles(c: ThemePalette) {
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md }, colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
fieldLabel: { ...typography.caption, color: c.textSecondary, marginBottom: spacing.xs },
intervalRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
intervalChip: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
intervalChipActive: { backgroundColor: c.primary, borderColor: c.primary },
intervalChipText: { ...typography.caption, color: c.text },
intervalChipTextActive: { color: c.primaryForeground },
formActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' }, colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' },
colorDotActive: { borderColor: c.text }, colorDotActive: { borderColor: c.text },
errorBox: { errorBox: {
+125 -2
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { import {
Alert, Alert,
AppState,
View, View,
Text, Text,
StyleSheet, StyleSheet,
@@ -52,6 +53,8 @@ import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDra
import { TasksSheet } from '../components/calendar/TasksSheet'; import { TasksSheet } from '../components/calendar/TasksSheet';
import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet';
import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet';
import { import {
applySharedCalendarColors, applySharedCalendarColors,
buildEventDayIndex, buildEventDayIndex,
@@ -177,6 +180,17 @@ export default function CalendarScreen() {
const deleteTask = useCalendarStore((s) => s.deleteTask); const deleteTask = useCalendarStore((s) => s.deleteTask);
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility); const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const createCalendar = useCalendarStore((s) => s.createCalendar);
const updateCalendar = useCalendarStore((s) => s.updateCalendar);
const removeCalendar = useCalendarStore((s) => s.removeCalendar);
const clearCalendarEvents = useCalendarStore((s) => s.clearCalendarEvents);
const shareCalendar = useCalendarStore((s) => s.shareCalendar);
// Calendar management sheets (create / edit / share) opened from the drawer.
const [calendarEditTarget, setCalendarEditTarget] = React.useState<
{ mode: 'create' } | { mode: 'edit'; calendar: Calendar } | null
>(null);
const [shareTarget, setShareTarget] = React.useState<Calendar | null>(null);
const syncDueSubscriptions = useCalendarSubscriptionsStore((s) => s.syncDue);
const storeCalendars = useCalendarStore((s) => s.calendars); const storeCalendars = useCalendarStore((s) => s.calendars);
const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds); const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds); const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
@@ -278,6 +292,16 @@ export default function CalendarScreen() {
void fetchCalendarsAction(); void fetchCalendarsAction();
}, [hydrate, fetchCalendarsAction]); }, [hydrate, fetchCalendarsAction]);
// Refresh iCal subscriptions whose interval elapsed: on mount and whenever
// the app returns to the foreground (like the webmail's periodic refresh).
React.useEffect(() => {
void syncDueSubscriptions();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') void syncDueSubscriptions();
});
return () => sub.remove();
}, [syncDueSubscriptions]);
React.useEffect(() => { React.useEffect(() => {
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek); const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
void ensureRange(after.toISOString(), before.toISOString()); void ensureRange(after.toISOString(), before.toISOString());
@@ -509,6 +533,86 @@ export default function CalendarScreen() {
} }
}, [refresh]); }, [refresh]);
const reportError = React.useCallback(
(err: unknown) => {
Alert.alert(
t('calendar.notifications.event_error', 'Something went wrong'),
err instanceof Error ? err.message : undefined,
);
},
[t],
);
const handleCalendarEditSave = React.useCallback(
async (values: CalendarEditValues) => {
if (!calendarEditTarget) return;
if (calendarEditTarget.mode === 'create') {
await createCalendar(values.name, values.color, values.description);
} else {
await updateCalendar(calendarEditTarget.calendar.id, {
name: values.name,
color: values.color,
description: values.description || null,
});
}
},
[calendarEditTarget, createCalendar, updateCalendar],
);
const handleSetCalendarColor = React.useCallback(
(cal: Calendar, color: string) => {
if (cal.isShared) {
// Per-viewer recolor (#345): the owner's colour is left alone.
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
return;
}
updateCalendar(cal.id, { color }).catch(reportError);
},
[setSharedCalendarColor, updateCalendar, reportError],
);
const handleClearCalendar = React.useCallback(
(cal: Calendar) => {
Alert.alert(
t('calendar.management.clear_title', 'Remove all events?'),
t(
'calendar.management.clear_description',
'Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.',
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('calendar.management.clear_confirm', 'Remove all'),
style: 'destructive',
onPress: () => { clearCalendarEvents(cal.id).catch(reportError); },
},
],
);
},
[t, clearCalendarEvents, reportError],
);
const handleDeleteCalendar = React.useCallback(
(cal: Calendar) => {
Alert.alert(
t('calendar.management.delete_title', 'Delete calendar?'),
t(
'calendar.management.delete_description',
'The calendar and all of its events will be deleted. This cannot be undone.',
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('common.delete', 'Delete'),
style: 'destructive',
onPress: () => { removeCalendar(cal.id).catch(reportError); },
},
],
);
},
[t, removeCalendar, reportError],
);
const isSelectedToday = isToday(selectedDate); const isSelectedToday = isToday(selectedDate);
return ( return (
@@ -689,13 +793,19 @@ export default function CalendarScreen() {
onClose={() => setSidebarVisible(false)} onClose={() => setSidebarVisible(false)}
onImport={() => { setSidebarVisible(false); setImportVisible(true); }} onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }} onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
onSetDefault={(cal) => { void setDefaultCalendar(cal.id); }} onCreate={() => { setSidebarVisible(false); setCalendarEditTarget({ mode: 'create' }); }}
onSetColor={(cal, color) => setSharedCalendarColor(sharedCalendarColorKey(cal), color)} onSetDefault={(cal) => { setDefaultCalendar(cal.id).catch(reportError); }}
onSetColor={handleSetCalendarColor}
onResetColor={(cal) => { onResetColor={(cal) => {
// Drop the local override; the auto-assign effect picks a fresh // Drop the local override; the auto-assign effect picks a fresh
// unused color (so it never reverts to a collision). // unused color (so it never reverts to a collision).
removeSharedCalendarColor(sharedCalendarColorKey(cal)); removeSharedCalendarColor(sharedCalendarColorKey(cal));
}} }}
onRename={(cal) => { setSidebarVisible(false); setCalendarEditTarget({ mode: 'edit', calendar: cal }); }}
onShare={(cal) => { setSidebarVisible(false); setShareTarget(cal); }}
onClear={handleClearCalendar}
onDelete={handleDeleteCalendar}
isSubscriptionCalendar={isSubscriptionCalendar}
/> />
<TasksSheet <TasksSheet
@@ -724,6 +834,19 @@ export default function CalendarScreen() {
visible={subscriptionsVisible} visible={subscriptionsVisible}
onClose={() => setSubscriptionsVisible(false)} onClose={() => setSubscriptionsVisible(false)}
/> />
<CalendarEditSheet
visible={!!calendarEditTarget}
calendar={calendarEditTarget?.mode === 'edit' ? calendarEditTarget.calendar : null}
onSave={handleCalendarEditSave}
onClose={() => setCalendarEditTarget(null)}
/>
<CalendarShareSheet
calendar={shareTarget}
onShare={shareCalendar}
onClose={() => setShareTarget(null)}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../api/calendar', () => ({
createCalendar: vi.fn(),
deleteCalendar: vi.fn(),
parseCalendarBlob: vi.fn(),
queryEvents: vi.fn(),
getEvents: vi.fn(),
deleteEvents: vi.fn(),
updateEvent: vi.fn(),
updateCalendar: vi.fn(),
}));
vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn() }));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', isConnected: true },
}));
vi.mock('../calendar-store', () => ({
useCalendarStore: { getState: () => ({}) },
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
}));
import {
normalizeFeedUrl,
selectAccountSubscriptions,
type CalendarSubscription,
} from '../calendar-subscriptions-store';
describe('normalizeFeedUrl', () => {
it('maps webcal:// and webcals:// to https://', () => {
expect(normalizeFeedUrl('webcal://example.com/feed.ics').url).toBe('https://example.com/feed.ics');
expect(normalizeFeedUrl('WEBCALS://example.com/feed.ics').url).toBe('https://example.com/feed.ics');
expect(normalizeFeedUrl('https://example.com/feed.ics').headers).toEqual({});
});
it('moves URL credentials into a basic-auth header (#275)', () => {
const { url, headers } = normalizeFeedUrl('https://user:p%40ss@example.com/private.ics');
expect(url).toBe('https://example.com/private.ics');
expect(headers.Authorization).toBe(`Basic ${Buffer.from('user:p@ss', 'utf8').toString('base64')}`);
});
});
describe('selectAccountSubscriptions', () => {
const subs: CalendarSubscription[] = [
{ id: 'a', name: 'A', url: 'x', calendarId: 'c1', accountId: 'acc-1', lastSyncAt: null, lastError: null },
{ id: 'b', name: 'B', url: 'y', calendarId: 'c2', accountId: 'acc-2', lastSyncAt: null, lastError: null },
{ id: 'legacy', name: 'L', url: 'z', calendarId: 'c3', lastSyncAt: null, lastError: null },
];
it('hides subscriptions that belong to another account, keeping legacy ones', () => {
expect(selectAccountSubscriptions(subs, 'acc-1').map((s) => s.id)).toEqual(['a', 'legacy']);
expect(selectAccountSubscriptions(subs, 'acc-2').map((s) => s.id)).toEqual(['b', 'legacy']);
});
});
+53 -4
View File
@@ -1,8 +1,13 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Calendar, CalendarEvent, StateChange } from '../api/types'; import type { Calendar, CalendarEvent, CalendarRights, StateChange } from '../api/types';
import { import {
type CalendarUpdates,
updateCalendar as apiUpdateCalendar,
deleteCalendar as apiDeleteCalendar,
clearCalendarEvents as apiClearCalendarEvents,
setCalendarShare as apiSetCalendarShare,
getCalendars as fetchCalendars, getCalendars as fetchCalendars,
queryEvents, queryEvents,
getEvents as fetchEvents, getEvents as fetchEvents,
@@ -108,7 +113,11 @@ export interface CalendarState {
replyTo?: Record<string, string> | null, replyTo?: Record<string, string> | null,
) => Promise<void>; ) => Promise<void>;
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>; importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
createCalendar: (name: string, color?: string) => Promise<Calendar>; createCalendar: (name: string, color?: string, description?: string) => Promise<Calendar>;
updateCalendar: (id: string, updates: CalendarUpdates) => Promise<void>;
removeCalendar: (id: string) => Promise<void>;
clearCalendarEvents: (id: string) => Promise<number>;
shareCalendar: (id: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
setDefaultCalendar: (id: string) => Promise<void>; setDefaultCalendar: (id: string) => Promise<void>;
// Tasks // Tasks
createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>; createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>;
@@ -508,12 +517,52 @@ export const useCalendarStore = create<CalendarState>()(
return count; return count;
}, },
createCalendar: async (name, color) => { createCalendar: async (name, color, description) => {
const created = await apiCreateCalendar(name, color); const created = await apiCreateCalendar(name, color, description);
set({ calendars: [...get().calendars, created] }); set({ calendars: [...get().calendars, created] });
return created; return created;
}, },
updateCalendar: async (id, updates) => {
const cal = get().calendars.find((c) => c.id === id);
await apiUpdateCalendar(cal?.originalId || id, updates, cal?.accountId);
set({
calendars: get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c)),
});
},
removeCalendar: async (id) => {
const cal = get().calendars.find((c) => c.id === id);
await apiDeleteCalendar(cal?.originalId || id, cal?.accountId);
set({
calendars: get().calendars.filter((c) => c.id !== id),
events: get().events.filter((e) => !e.calendarIds?.[id]),
tasks: get().tasks.filter((t) => !t.calendarIds?.[id]),
hiddenCalendarIds: get().hiddenCalendarIds.filter((x) => x !== id),
});
},
clearCalendarEvents: async (id) => {
const cal = get().calendars.find((c) => c.id === id);
const removed = await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId);
await get().refresh();
return removed;
},
shareCalendar: async (id, principalId, rights) => {
const cal = get().calendars.find((c) => c.id === id);
await apiSetCalendarShare(cal?.originalId || id, principalId, rights, cal?.accountId);
set({
calendars: get().calendars.map((c) => {
if (c.id !== id) return c;
const next = { ...(c.shareWith ?? {}) };
if (rights === null) delete next[principalId];
else next[principalId] = rights;
return { ...c, shareWith: next };
}),
});
},
setDefaultCalendar: async (id) => { setDefaultCalendar: async (id) => {
const cal = get().calendars.find((c) => c.id === id); const cal = get().calendars.find((c) => c.id === id);
await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId); await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId);
+133 -29
View File
@@ -9,7 +9,10 @@ import {
queryEvents, queryEvents,
getEvents, getEvents,
deleteEvents, deleteEvents,
updateEvent,
updateCalendar,
} from '../api/calendar'; } from '../api/calendar';
import { jmapClient } from '../api/jmap-client';
import { uploadBytes } from '../api/blob'; import { uploadBytes } from '../api/blob';
import { useCalendarStore } from './calendar-store'; import { useCalendarStore } from './calendar-store';
@@ -20,29 +23,88 @@ export interface CalendarSubscription {
color?: string; color?: string;
/** The local Stalwart calendar that mirrors this remote feed. */ /** The local Stalwart calendar that mirrors this remote feed. */
calendarId: string; calendarId: string;
/** JMAP account the mirror calendar lives in; subs of other accounts are hidden. */
accountId?: string;
/** Minutes between automatic refreshes (default 60). */
refreshIntervalMinutes?: number;
lastSyncAt: number | null; lastSyncAt: number | null;
lastError: string | null; lastError: string | null;
} }
export const DEFAULT_REFRESH_INTERVAL_MINUTES = 60;
// 10 MB is far beyond any real feed; a bigger response is not a calendar.
const MAX_FEED_BYTES = 10 * 1024 * 1024;
interface SubscriptionsState { interface SubscriptionsState {
subscriptions: CalendarSubscription[]; subscriptions: CalendarSubscription[];
syncing: Record<string, boolean>; syncing: Record<string, boolean>;
addSubscription: (input: { name: string; url: string; color?: string }) => Promise<CalendarSubscription>; addSubscription: (input: {
name: string;
url: string;
color?: string;
refreshIntervalMinutes?: number;
}) => Promise<CalendarSubscription>;
updateSubscription: (
id: string,
updates: { name?: string; url?: string; color?: string; refreshIntervalMinutes?: number },
) => Promise<void>;
removeSubscription: (id: string) => Promise<void>; removeSubscription: (id: string) => Promise<void>;
syncSubscription: (id: string) => Promise<void>; syncSubscription: (id: string) => Promise<void>;
syncAll: () => Promise<void>; syncAll: () => Promise<void>;
/** Refresh every subscription of the active account whose interval elapsed. */
syncDue: () => Promise<void>;
} }
// webcal:// is just iCalendar over HTTP(S) — browsers/clients map it to https. /** Subscriptions belonging to the signed-in JMAP account (legacy ones without an accountId count as its own). */
function normalizeFeedUrl(url: string): string { export function selectAccountSubscriptions(
return url.trim().replace(/^webcal:\/\//i, 'https://'); subscriptions: CalendarSubscription[],
accountId: string | null,
): CalendarSubscription[] {
return subscriptions.filter((s) => !s.accountId || !accountId || s.accountId === accountId);
}
function currentAccountId(): string | null {
return jmapClient.isConnected ? jmapClient.accountId : null;
}
// webcal:// and webcals:// are just iCalendar over HTTP(S) — clients map
// them to https. Credentials embedded in the URL (#275) are moved into an
// Authorization header because RN's WHATWG fetch rejects userinfo in URLs.
export function normalizeFeedUrl(url: string): { url: string; headers: Record<string, string> } {
let normalized = url.trim().replace(/^webcals?:\/\//i, 'https://');
const headers: Record<string, string> = {};
const m = /^(https?:\/\/)([^/@]+)@(.+)$/i.exec(normalized);
if (m) {
const creds = decodeURIComponent(m[2]);
normalized = `${m[1]}${m[3]}`;
headers.Authorization = `Basic ${base64(creds)}`;
}
return { url: normalized, headers };
}
function base64(input: string): string {
const g = globalThis as { btoa?: (s: string) => string; Buffer?: { from: (s: string, e: string) => { toString: (e: string) => string } } };
if (g.btoa) {
// btoa expects latin1; encode UTF-8 bytes first.
const bytes = new TextEncoder().encode(input);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return g.btoa(bin);
}
if (g.Buffer) return g.Buffer.from(input, 'utf8').toString('base64');
return input;
} }
async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> { async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> {
const res = await fetch(normalizeFeedUrl(url)); const { url: feedUrl, headers } = normalizeFeedUrl(url);
const res = await fetch(feedUrl, { headers });
if (!res.ok) throw new Error(`Could not fetch feed (HTTP ${res.status})`); if (!res.ok) throw new Error(`Could not fetch feed (HTTP ${res.status})`);
const length = Number(res.headers.get('content-length') || 0);
if (length > MAX_FEED_BYTES) throw new Error('Feed is too large');
const text = await res.text(); const text = await res.text();
if (text.length > MAX_FEED_BYTES) throw new Error('Feed is too large');
if (!/BEGIN:VCALENDAR/i.test(text)) { if (!/BEGIN:VCALENDAR/i.test(text)) {
throw new Error('That URL did not return an iCalendar feed'); throw new Error('That URL did not return an iCalendar feed');
} }
@@ -52,20 +114,26 @@ async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]>
return events.filter((e) => !!e.start); return events.filter((e) => !!e.start);
} }
// Reconcile the local calendar with the remote feed: delete events whose UID // Reconcile the local calendar with the remote feed: drop events whose UID
// disappeared upstream, then import the current set (importEvents dedupes by // disappeared upstream (unlinking rather than deleting when the event also
// UID, so unchanged events are left in place). // lives in another calendar), then import the current set (importEvents
// dedupes by UID, so unchanged events are left in place).
async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<void> { async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<void> {
const parsed = await fetchAndParseFeed(url); const parsed = await fetchAndParseFeed(url);
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]); const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
try { try {
const allIds = await queryEvents([], '', ''); const ids = await queryEvents([calendarId], '', '');
const all = allIds.length > 0 ? await getEvents(allIds) : []; const existing = ids.length > 0 ? await getEvents(ids) : [];
const stale = all const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
.filter((e) => e.calendarIds?.[calendarId] && e.uid && !parsedUids.has(e.uid)) const toDelete: string[] = [];
.map((e) => e.id); for (const e of stale) {
if (stale.length > 0) await deleteEvents(stale); const others = { ...(e.calendarIds || {}) };
delete others[calendarId];
if (Object.keys(others).length === 0) toDelete.push(e.id);
else await updateEvent(e.id, { calendarIds: others });
}
if (toDelete.length > 0) await deleteEvents(toDelete);
} catch { } catch {
// Non-fatal: if we can't enumerate existing events, still import new ones. // Non-fatal: if we can't enumerate existing events, still import new ones.
} }
@@ -79,7 +147,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
subscriptions: [], subscriptions: [],
syncing: {}, syncing: {},
addSubscription: async ({ name, url, color }) => { addSubscription: async ({ name, url, color, refreshIntervalMinutes }) => {
// Create a dedicated local calendar to hold the feed's events. // Create a dedicated local calendar to hold the feed's events.
const calendar = await createCalendar(name, color); const calendar = await createCalendar(name, color);
const sub: CalendarSubscription = { const sub: CalendarSubscription = {
@@ -88,31 +156,55 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
url: url.trim(), url: url.trim(),
color, color,
calendarId: calendar.id, calendarId: calendar.id,
accountId: currentAccountId() ?? undefined,
refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES,
lastSyncAt: null, lastSyncAt: null,
lastError: null, lastError: null,
}; };
set({ subscriptions: [...get().subscriptions, sub] });
// Make the new calendar visible immediately. // Make the new calendar visible immediately.
await useCalendarStore.getState().fetchCalendars(); await useCalendarStore.getState().fetchCalendars();
try { try {
set({ syncing: { ...get().syncing, [sub.id]: true } }); set({ syncing: { ...get().syncing, [sub.id]: true } });
await syncFeedIntoCalendar(sub.calendarId, sub.url); await syncFeedIntoCalendar(sub.calendarId, sub.url);
set({ set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] });
subscriptions: get().subscriptions.map((s) => await useCalendarStore.getState().refresh();
s.id === sub.id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s, return { ...sub, lastSyncAt: Date.now() };
),
});
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Sync failed'; // Roll back: a bad URL / 404 must not leave a phantom calendar behind.
set({ try {
subscriptions: get().subscriptions.map((s) => await deleteCalendar(calendar.id);
s.id === sub.id ? { ...s, lastError: message } : s, } catch {
), // best-effort
}); }
await useCalendarStore.getState().fetchCalendars();
throw err instanceof Error ? err : new Error('Sync failed');
} finally { } finally {
set({ syncing: { ...get().syncing, [sub.id]: false } }); set({ syncing: { ...get().syncing, [sub.id]: false } });
} }
return sub; },
updateSubscription: async (id, updates) => {
const sub = get().subscriptions.find((s) => s.id === id);
if (!sub) return;
const next: CalendarSubscription = {
...sub,
...(updates.name !== undefined ? { name: updates.name.trim() } : {}),
...(updates.url !== undefined ? { url: updates.url.trim() } : {}),
...(updates.color !== undefined ? { color: updates.color } : {}),
...(updates.refreshIntervalMinutes !== undefined
? { refreshIntervalMinutes: updates.refreshIntervalMinutes }
: {}),
};
if (next.name !== sub.name || next.color !== sub.color) {
try {
await updateCalendar(sub.calendarId, { name: next.name, color: next.color });
await useCalendarStore.getState().fetchCalendars();
} catch {
// The mirror calendar keeps its old name/colour; the sub still updates.
}
}
set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? next : s)) });
if (next.url !== sub.url) await get().syncSubscription(id);
}, },
removeSubscription: async (id) => { removeSubscription: async (id) => {
@@ -132,6 +224,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
syncSubscription: async (id) => { syncSubscription: async (id) => {
const sub = get().subscriptions.find((s) => s.id === id); const sub = get().subscriptions.find((s) => s.id === id);
if (!sub) return; if (!sub) return;
if (get().syncing[id]) return;
set({ syncing: { ...get().syncing, [id]: true } }); set({ syncing: { ...get().syncing, [id]: true } });
try { try {
await syncFeedIntoCalendar(sub.calendarId, sub.url); await syncFeedIntoCalendar(sub.calendarId, sub.url);
@@ -153,11 +246,22 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
}, },
syncAll: async () => { syncAll: async () => {
const subs = get().subscriptions; const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId());
for (const sub of subs) { for (const sub of subs) {
await get().syncSubscription(sub.id); await get().syncSubscription(sub.id);
} }
}, },
syncDue: async () => {
if (!jmapClient.isConnected) return;
const now = Date.now();
const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId());
for (const sub of subs) {
const interval = (sub.refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES) * 60_000;
if (sub.lastSyncAt && now - sub.lastSyncAt < interval) continue;
await get().syncSubscription(sub.id);
}
},
}), }),
{ {
name: 'calendar-subscriptions', name: 'calendar-subscriptions',