diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index dac38885..a7f5d0a7 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -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/`). - 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 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. -- [ ] **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 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. -- [ ] **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 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. @@ -226,7 +226,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi ### 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 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. @@ -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`). - 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 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()`. diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 02b19417..f279305b 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1,6 +1,6 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; -import type { Calendar, CalendarEvent } from './types'; +import type { Calendar, CalendarEvent, CalendarRights } from './types'; import { getEffectiveTimeZone } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; @@ -489,12 +489,15 @@ export async function parseCalendarBlob(blobId: string): Promise { const accountId = jmapClient.accountId; + const props: Record = { name, color, isVisible: true, isSubscribed: true }; + if (description?.trim()) props.description = description.trim(); const res = await jmapClient.request( [['Calendar/set', { accountId, - create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } }, + create: { 'new-cal': props }, }, '0']], USING, ); @@ -527,12 +530,119 @@ export async function setDefaultCalendar( 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. -export async function deleteCalendar(id: string): Promise { - const accountId = jmapClient.accountId; - await jmapClient.request( +export async function deleteCalendar(id: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || jmapClient.accountId; + const res = await jmapClient.request( [['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']], USING, ); + const result = methodResult<{ notDestroyed?: Record }>(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 { + const accountId = targetAccountId || jmapClient.accountId; + const res = await jmapClient.request( + [['Calendar/set', { accountId, update: { [id]: updates } }, '0']], + USING, + ); + const result = methodResult<{ notUpdated?: Record }>(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 { + 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 }>(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 { + 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 }> = []; + 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 }> = []; + 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 = {}; + 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 }>(res).updated ?? {}).length; + } + if (toDestroy.length === 0 && toUnlink.length === 0) break; + if (ids.length < 1000) break; + } + return removed; } diff --git a/src/api/types.ts b/src/api/types.ts index f03cb741..6cd2b96c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -544,6 +544,8 @@ export interface Calendar { isDefault?: boolean; sortOrder?: number; myRights?: CalendarRights; + // Principals this (owned) calendar is shared with and their rights. + shareWith?: Record | null; // Client-only: JMAP account the calendar was fetched from. Absent for the // primary account; set for calendars shared with the user. accountId?: string; diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx new file mode 100644 index 00000000..571f5c23 --- /dev/null +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -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; + 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(CALENDAR_COLOR_PALETTE[0]); + const [description, setDescription] = React.useState(''); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(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 ( + + + + + + + {calendar + ? t('calendar.management.edit_calendar', 'Edit calendar') + : t('calendar.management.new_calendar', 'New calendar')} + + + {t('calendar.management.name', 'Name')} + + {t('calendar.management.color', 'Color')} + + {CALENDAR_COLOR_PALETTE.map((col) => { + const active = color.toLowerCase() === col.toLowerCase(); + return ( + setColor(col)} + style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]} + > + {active && } + + ); + })} + + {t('calendar.management.description', 'Description')} + + {error ? {error} : null} + + + + + + + + + + + ); +} + +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 }, + }); +} diff --git a/src/components/calendar/CalendarShareSheet.tsx b/src/components/calendar/CalendarShareSheet.tsx new file mode 100644 index 00000000..8262d471 --- /dev/null +++ b/src/components/calendar/CalendarShareSheet.tsx @@ -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 = { + 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 = { + 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; + 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([]); + const [loading, setLoading] = React.useState(false); + const [shares, setShares] = React.useState>({}); + const [search, setSearch] = React.useState(''); + const [savingId, setSavingId] = React.useState(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(); + 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 ( + + + {p?.description || p?.name || principalId} + + {p?.email ? ( + {p.email} + ) : null} + + ); + }; + + return ( + + + + + + + + + {t('calendar.share.title', 'Share')} “{calendar.name}” + + + + + {sharedEntries.length > 0 ? ( + + {t('calendar.share.shared_with', 'Shared with')} + {sharedEntries.map(([principalId, rights]) => { + const preset = detectPreset(rights); + const busy = savingId === principalId; + return ( + + {renderPrincipalLabel(principalId)} + + {PRESET_ORDER.map((p) => ( + void applyShare(principalId, CALENDAR_PRESETS[p])} + disabled={busy} + style={[styles.chip, preset === p && styles.chipActive]} + > + + {t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])} + + + ))} + {preset === 'custom' ? ( + {t('calendar.share.role_custom', 'Custom')} + ) : null} + void applyShare(principalId, null)} + disabled={busy} + hitSlop={8} + style={styles.removeBtn} + > + {busy ? ( + + ) : ( + + )} + + + + ); + })} + + ) : null} + + + {t('calendar.share.add_people', 'Add people')} + + {loading ? ( + + ) : candidates.length === 0 ? ( + + {principals.length === 0 + ? t('calendar.share.no_principals', 'Sharing is not available on this server.') + : t('calendar.share.no_matches', 'No matches')} + + ) : ( + candidates.map((p) => ( + void applyShare(p.id, CALENDAR_PRESETS.read)} + disabled={!!savingId} + style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]} + > + {renderPrincipalLabel(p.id)} + {savingId === p.id ? ( + + ) : ( + + )} + + )) + )} + + + + + {t('common.done', 'Done')} + + + + + + + ); +} + +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 }, + }); +} diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index ed0175b8..0077b00e 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -11,7 +11,9 @@ import { View, } from 'react-native'; 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 { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -28,11 +30,19 @@ interface CalendarSidebarDrawerProps { onClose: () => void; onImport?: () => void; onManageSubscriptions?: () => void; - // Long-press actions. Set-default applies to the user's own calendars; - // color change/reset applies to shared calendars (per-viewer recolor). + onCreate?: () => void; + // 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; onSetColor?: (calendar: Calendar, color: string) => 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({ @@ -43,9 +53,15 @@ export function CalendarSidebarDrawer({ onClose, onImport, onManageSubscriptions, + onCreate, onSetDefault, onSetColor, onResetColor, + onRename, + onShare, + onClear, + onDelete, + isSubscriptionCalendar, }: CalendarSidebarDrawerProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -102,6 +118,11 @@ export function CalendarSidebarDrawer({ onSetDefault, onSetColor, onResetColor, + onRename, + onShare, + onClear, + onDelete, + isSubscriptionCalendar, }; return ( @@ -144,8 +165,17 @@ export function CalendarSidebarDrawer({
)} - {(onImport || onManageSubscriptions) && ( + {(onImport || onManageSubscriptions || onCreate) && ( + {onCreate && ( + [styles.actionRow, pressed && styles.rowPressed]} + > + + New calendar + + )} {onManageSubscriptions && ( void; onSetColor?: (calendar: Calendar, color: string) => 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 styles = React.useMemo(() => makeStyles(c), [c]); @@ -202,9 +242,17 @@ function Section({ {calendars.map((cal) => { const visible = !hiddenSet.has(cal.id); const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID; - const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault; - const canRecolor = !!onSetColor && !!cal.isShared; - const hasActions = canSetDefault || canRecolor; + const isSubscription = !!isSubscriptionCalendar?.(cal.id); + const isOwn = !cal.isShared && !isBirthday && !isSubscription; + 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; return ( @@ -246,6 +294,15 @@ function Section({ Set as default calendar )} + {canRename && ( + { onExpand(null); onRename!(cal); }} + style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} + > + + Edit name and description + + )} {canRecolor && ( <> @@ -266,7 +323,7 @@ function Section({ ); })} - {!!onResetColor && cal.colorIsLocalOverride && ( + {!!onResetColor && cal.isShared && cal.colorIsLocalOverride && ( { onExpand(null); onResetColor(cal); }} style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} @@ -277,6 +334,33 @@ function Section({ )} )} + {canShare && ( + { onExpand(null); onShare!(cal); }} + style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} + > + + Share… + + )} + {canClear && ( + { onExpand(null); onClear!(cal); }} + style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} + > + + Remove all events + + )} + {canDelete && ( + { onExpand(null); onDelete!(cal); }} + style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} + > + + Delete calendar + + )} )} diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx index 495eeee5..e0efcc7e 100644 --- a/src/components/calendar/ICalSubscriptionSheet.tsx +++ b/src/components/calendar/ICalSubscriptionSheet.tsx @@ -9,7 +9,20 @@ import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; 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 { visible: boolean; @@ -20,26 +33,54 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); 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 addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription); + const updateSubscription = useCalendarSubscriptionsStore((s) => s.updateSubscription); const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription); const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription); const [name, setName] = React.useState(''); const [url, setUrl] = React.useState(''); const [color, setColor] = React.useState(CALENDAR_COLOR_PALETTE[0]); + const [interval, setInterval] = React.useState(DEFAULT_REFRESH_INTERVAL_MINUTES); + const [editingId, setEditingId] = React.useState(null); const [adding, setAdding] = React.useState(false); const [formError, setFormError] = React.useState(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 overlayOpacity = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { if (visible) { + setEditingId(null); setName(''); setUrl(''); setColor(CALENDAR_COLOR_PALETTE[0]); + setInterval(DEFAULT_REFRESH_INTERVAL_MINUTES); setFormError(null); Animated.parallel([ 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]); - const handleAdd = async () => { + const handleSubmit = async () => { const n = name.trim(); const u = url.trim(); if (!n || !u) { setFormError('Enter a name and a feed URL.'); return; } - if (!/^(https?|webcal):\/\//i.test(u)) { - setFormError('URL must start with https://, http:// or webcal://'); + if (!/^(https?|webcals?):\/\//i.test(u)) { + setFormError('URL must start with https://, http://, webcal:// or webcals://'); return; } setFormError(null); setAdding(true); try { - const sub = await addSubscription({ name: n, url: u, color }); - if (sub.lastError) { - setFormError(sub.lastError); + if (editingId) { + await updateSubscription(editingId, { name: n, url: u, color, refreshIntervalMinutes: interval }); } else { - setName(''); - setUrl(''); + await addSubscription({ name: n, url: u, color, refreshIntervalMinutes: interval }); } + resetForm(); } catch (e) { setFormError(e instanceof Error ? e.message : 'Could not add subscription.'); } finally { @@ -97,7 +137,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { - Add a feed + {editingId ? 'Edit subscription' : 'Add a feed'} ))} + Refresh every + + {INTERVAL_OPTIONS.map((opt) => { + const active = interval === opt.minutes; + return ( + setInterval(opt.minutes)} + style={[styles.intervalChip, active && styles.intervalChipActive]} + > + + {opt.label} + + + ); + })} + {!!formError && ( {formError} )} - + + {editingId && ( + + )} + + {subscriptions.length > 0 && ( <> @@ -150,7 +214,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { return ( - + startEdit(sub)}> {sub.name} {sub.lastError @@ -159,7 +223,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { ? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}` : 'Not synced yet'} - + { void syncSubscription(sub.id); }} hitSlop={6} @@ -232,6 +296,20 @@ function makeStyles(c: ThemePalette) { marginBottom: spacing.sm, }, 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' }, colorDotActive: { borderColor: c.text }, errorBox: { diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 62e02412..b149ca6e 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Alert, + AppState, View, Text, StyleSheet, @@ -52,6 +53,8 @@ import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDra import { TasksSheet } from '../components/calendar/TasksSheet'; import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; +import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet'; +import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet'; import { applySharedCalendarColors, buildEventDayIndex, @@ -177,6 +180,17 @@ export default function CalendarScreen() { const deleteTask = useCalendarStore((s) => s.deleteTask); const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility); 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(null); + const syncDueSubscriptions = useCalendarSubscriptionsStore((s) => s.syncDue); const storeCalendars = useCalendarStore((s) => s.calendars); const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds); const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds); @@ -278,6 +292,16 @@ export default function CalendarScreen() { void 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(() => { const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek); void ensureRange(after.toISOString(), before.toISOString()); @@ -509,6 +533,86 @@ export default function CalendarScreen() { } }, [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); return ( @@ -689,13 +793,19 @@ export default function CalendarScreen() { onClose={() => setSidebarVisible(false)} onImport={() => { setSidebarVisible(false); setImportVisible(true); }} onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }} - onSetDefault={(cal) => { void setDefaultCalendar(cal.id); }} - onSetColor={(cal, color) => setSharedCalendarColor(sharedCalendarColorKey(cal), color)} + onCreate={() => { setSidebarVisible(false); setCalendarEditTarget({ mode: 'create' }); }} + onSetDefault={(cal) => { setDefaultCalendar(cal.id).catch(reportError); }} + onSetColor={handleSetCalendarColor} onResetColor={(cal) => { // Drop the local override; the auto-assign effect picks a fresh // unused color (so it never reverts to a collision). removeSharedCalendarColor(sharedCalendarColorKey(cal)); }} + onRename={(cal) => { setSidebarVisible(false); setCalendarEditTarget({ mode: 'edit', calendar: cal }); }} + onShare={(cal) => { setSidebarVisible(false); setShareTarget(cal); }} + onClear={handleClearCalendar} + onDelete={handleDeleteCalendar} + isSubscriptionCalendar={isSubscriptionCalendar} /> setSubscriptionsVisible(false)} /> + + setCalendarEditTarget(null)} + /> + + setShareTarget(null)} + /> ); } diff --git a/src/stores/__tests__/calendar-subscriptions-store.test.ts b/src/stores/__tests__/calendar-subscriptions-store.test.ts new file mode 100644 index 00000000..d3afab79 --- /dev/null +++ b/src/stores/__tests__/calendar-subscriptions-store.test.ts @@ -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']); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 43bb63c8..f80eb897 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -1,8 +1,13 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; 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 { + type CalendarUpdates, + updateCalendar as apiUpdateCalendar, + deleteCalendar as apiDeleteCalendar, + clearCalendarEvents as apiClearCalendarEvents, + setCalendarShare as apiSetCalendarShare, getCalendars as fetchCalendars, queryEvents, getEvents as fetchEvents, @@ -108,7 +113,11 @@ export interface CalendarState { replyTo?: Record | null, ) => Promise; importEvents: (events: Partial[], calendarId: string) => Promise; - createCalendar: (name: string, color?: string) => Promise; + createCalendar: (name: string, color?: string, description?: string) => Promise; + updateCalendar: (id: string, updates: CalendarUpdates) => Promise; + removeCalendar: (id: string) => Promise; + clearCalendarEvents: (id: string) => Promise; + shareCalendar: (id: string, principalId: string, rights: CalendarRights | null) => Promise; setDefaultCalendar: (id: string) => Promise; // Tasks createTask: (task: Partial, calendarId: string) => Promise; @@ -508,12 +517,52 @@ export const useCalendarStore = create()( return count; }, - createCalendar: async (name, color) => { - const created = await apiCreateCalendar(name, color); + createCalendar: async (name, color, description) => { + const created = await apiCreateCalendar(name, color, description); set({ calendars: [...get().calendars, 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) => { const cal = get().calendars.find((c) => c.id === id); await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId); diff --git a/src/stores/calendar-subscriptions-store.ts b/src/stores/calendar-subscriptions-store.ts index 2481b1dc..51b04fbe 100644 --- a/src/stores/calendar-subscriptions-store.ts +++ b/src/stores/calendar-subscriptions-store.ts @@ -9,7 +9,10 @@ import { queryEvents, getEvents, deleteEvents, + updateEvent, + updateCalendar, } from '../api/calendar'; +import { jmapClient } from '../api/jmap-client'; import { uploadBytes } from '../api/blob'; import { useCalendarStore } from './calendar-store'; @@ -20,29 +23,88 @@ export interface CalendarSubscription { color?: string; /** The local Stalwart calendar that mirrors this remote feed. */ 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; 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 { subscriptions: CalendarSubscription[]; syncing: Record; - addSubscription: (input: { name: string; url: string; color?: string }) => Promise; + addSubscription: (input: { + name: string; + url: string; + color?: string; + refreshIntervalMinutes?: number; + }) => Promise; + updateSubscription: ( + id: string, + updates: { name?: string; url?: string; color?: string; refreshIntervalMinutes?: number }, + ) => Promise; removeSubscription: (id: string) => Promise; syncSubscription: (id: string) => Promise; syncAll: () => Promise; + /** Refresh every subscription of the active account whose interval elapsed. */ + syncDue: () => Promise; } -// webcal:// is just iCalendar over HTTP(S) — browsers/clients map it to https. -function normalizeFeedUrl(url: string): string { - return url.trim().replace(/^webcal:\/\//i, 'https://'); +/** Subscriptions belonging to the signed-in JMAP account (legacy ones without an accountId count as its own). */ +export function selectAccountSubscriptions( + 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 } { + let normalized = url.trim().replace(/^webcals?:\/\//i, 'https://'); + const headers: Record = {}; + 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[]> { - 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})`); + 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(); + if (text.length > MAX_FEED_BYTES) throw new Error('Feed is too large'); if (!/BEGIN:VCALENDAR/i.test(text)) { throw new Error('That URL did not return an iCalendar feed'); } @@ -52,20 +114,26 @@ async function fetchAndParseFeed(url: string): Promise[]> return events.filter((e) => !!e.start); } -// Reconcile the local calendar with the remote feed: delete events whose UID -// disappeared upstream, then import the current set (importEvents dedupes by -// UID, so unchanged events are left in place). +// Reconcile the local calendar with the remote feed: drop events whose UID +// disappeared upstream (unlinking rather than deleting when the event also +// 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 { const parsed = await fetchAndParseFeed(url); const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]); try { - const allIds = await queryEvents([], '', ''); - const all = allIds.length > 0 ? await getEvents(allIds) : []; - const stale = all - .filter((e) => e.calendarIds?.[calendarId] && e.uid && !parsedUids.has(e.uid)) - .map((e) => e.id); - if (stale.length > 0) await deleteEvents(stale); + const ids = await queryEvents([calendarId], '', ''); + const existing = ids.length > 0 ? await getEvents(ids) : []; + const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid)); + const toDelete: string[] = []; + for (const e of 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 { // Non-fatal: if we can't enumerate existing events, still import new ones. } @@ -79,7 +147,7 @@ export const useCalendarSubscriptionsStore = create()( subscriptions: [], syncing: {}, - addSubscription: async ({ name, url, color }) => { + addSubscription: async ({ name, url, color, refreshIntervalMinutes }) => { // Create a dedicated local calendar to hold the feed's events. const calendar = await createCalendar(name, color); const sub: CalendarSubscription = { @@ -88,31 +156,55 @@ export const useCalendarSubscriptionsStore = create()( url: url.trim(), color, calendarId: calendar.id, + accountId: currentAccountId() ?? undefined, + refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES, lastSyncAt: null, lastError: null, }; - set({ subscriptions: [...get().subscriptions, sub] }); // Make the new calendar visible immediately. await useCalendarStore.getState().fetchCalendars(); try { set({ syncing: { ...get().syncing, [sub.id]: true } }); await syncFeedIntoCalendar(sub.calendarId, sub.url); - set({ - subscriptions: get().subscriptions.map((s) => - s.id === sub.id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s, - ), - }); + set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] }); + await useCalendarStore.getState().refresh(); + return { ...sub, lastSyncAt: Date.now() }; } catch (err) { - const message = err instanceof Error ? err.message : 'Sync failed'; - set({ - subscriptions: get().subscriptions.map((s) => - s.id === sub.id ? { ...s, lastError: message } : s, - ), - }); + // Roll back: a bad URL / 404 must not leave a phantom calendar behind. + try { + await deleteCalendar(calendar.id); + } catch { + // best-effort + } + await useCalendarStore.getState().fetchCalendars(); + throw err instanceof Error ? err : new Error('Sync failed'); } finally { 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) => { @@ -132,6 +224,7 @@ export const useCalendarSubscriptionsStore = create()( syncSubscription: async (id) => { const sub = get().subscriptions.find((s) => s.id === id); if (!sub) return; + if (get().syncing[id]) return; set({ syncing: { ...get().syncing, [id]: true } }); try { await syncFeedIntoCalendar(sub.calendarId, sub.url); @@ -153,11 +246,22 @@ export const useCalendarSubscriptionsStore = create()( }, syncAll: async () => { - const subs = get().subscriptions; + const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId()); for (const sub of subs) { 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',