mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: manage calendars from the drawer and scope, edit and auto-refresh iCal subscriptions
This commit is contained in:
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user