mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +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/`).
|
||||
- Fix hint: when adding calendar creation, issue `MKCALENDAR` against the account's calendar-home with `supported-calendar-component-set`, then `Calendar/get` to find the new id.
|
||||
|
||||
- [ ] **No first-touch gate: duplicate default calendars on clustered Stalwart (#907)** — `P2` — `bugfix-parity`
|
||||
- [x] **No first-touch gate: duplicate default calendars on clustered Stalwart (#907)** — `P2` — `bugfix-parity` — fixed in 58e3ef6 (in-flight dedupe of fetchCalendars/fetchTasks; ensureRange awaits it)
|
||||
- What WEB does: `FirstTouchGate` serialises the first `Calendar/*`/`CalendarEvent/*` request per account so concurrent first-touch requests cannot each create a default calendar (ref `lib/jmap/first-touch-gate.ts:56-101`, `lib/jmap/client.ts:678, 1129`, changelog 1.9.0 #907).
|
||||
- What RN does: `CalendarScreen` fires `fetchCalendars()` on mount and, in a second effect, `ensureRange()` which sees `calendars.length === 0` and calls `fetchCalendars()` again then `CalendarEvent/query` (`src/screens/CalendarScreen.tsx:261-269`, `src/stores/calendar-store.ts:235-251`); `auth-store` also kicks `fetchCalendars`/`refresh` after login (`src/stores/auth-store.ts:92-98`). Two to three concurrent first-touch calls — exactly the #907 race. RN affected.
|
||||
- Fix hint: port `FirstTouchGate` into `src/api/jmap-client.ts` `request()`, or at minimum dedupe in-flight `fetchCalendars` and make `ensureRange` await the mount fetch.
|
||||
|
||||
- [ ] **Tasks-only calendar detection misses CalDAV tasks without `@type`** — `P2` — `bugfix-parity`
|
||||
- [x] **Tasks-only calendar detection misses CalDAV tasks without `@type`** — `P2` — `bugfix-parity` — fixed in 58e3ef6
|
||||
- What WEB does: `isTaskLikeObject` treats `@type: Task` OR presence of `due`/`progress`/`percentComplete` (and `@type !== 'Event'`) as a task, both for the tasks list and for hiding tasks-only calendars (ref `lib/calendar-component-detection.ts:30-58`, `lib/jmap/client.ts:6300-6316`, changelog 1.5.x #84 "Detect tasks created by external CalDAV clients such as Thunderbird").
|
||||
- What RN does: `fetchEvents` classifies solely on `'@type' === 'Task'` (`src/stores/calendar-store.ts:208-227`); Thunderbird/Todoist VTODOs that Stalwart returns without `@type` are neither shown as tasks nor counted for `taskOnlyCalendarIds` (they fall through as start-less "events" and are dropped by `!!e.start`).
|
||||
- Fix hint: port `isTaskLikeObject` and use it in both filters.
|
||||
|
||||
- [ ] **Shared accounts without calendar access are re-probed on every fetch** — `P3` — `bugfix-parity`
|
||||
- [x] **Shared accounts without calendar access are re-probed on every fetch** — `P3` — `bugfix-parity` — fixed in 58e3ef6
|
||||
- What WEB does: remembers `forbidden`/`accountNotFound` per account in `calendarAccessDenied` and skips it (ref `lib/jmap/client.ts:5806-5813`, changelog 1.8.1 "Stop re-probing shared accounts that have no calendar access").
|
||||
- What RN does: `getCalendars` and `fetchEvents` try every calendar-capable session account each time and swallow errors (`src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:194-204`).
|
||||
- Fix hint: keep a module-level `Set` of denied account ids, reset on reconnect.
|
||||
@@ -226,7 +226,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
||||
|
||||
### Data loading / sync
|
||||
|
||||
- [ ] **Range queries fetch every event in the account (no `after`/`before`), capped at 1000** — `P2` — `bug`
|
||||
- [x] **Range queries fetch every event in the account (no `after`/`before`), capped at 1000** — `P2` — `bug` — fixed in 58e3ef6
|
||||
- What WEB does: `CalendarEvent/query` with `{ after, before }` + `timeZone` (changelog 1.7.0 "timezone-aware calendar queries", 1.7.1 "Stalwart-compatible calendar filters") (ref `lib/jmap/client.ts:5722-5745`).
|
||||
- What RN does: `queryEvents` ignores its `_after`/`_before` arguments citing "Stalwart rejects after/before filters" (outdated) and uses `limit: 1000` (`src/api/calendar.ts:188-208`); `fetchEvents` then re-fetches everything whenever `ensureRange` widens the union (`src/stores/calendar-store.ts:235-251`). Accounts with more than 1000 objects silently lose events; every navigation past the loaded range re-downloads all events.
|
||||
- Fix hint: send `filter: { after, before, inCalendar... }` as WEB does (`{ operator: 'AND', conditions: [...] }` when combined), keep `timeZone`; then `loadedRange` can be the requested window rather than a growing union.
|
||||
@@ -246,7 +246,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
||||
- What RN does: `fetch(url)` from the phone (`src/stores/calendar-subscriptions-store.ts:42-53`). No server is exposed, so the SSRF class does not apply; the device can be pointed at LAN hosts, which is the user's own network. Redirects are followed by `fetch` without validation (fine). N/A for the advisory; worth a 10 MB size cap and a `text/calendar`-ish content check (already checks `BEGIN:VCALENDAR`).
|
||||
- Fix hint: optional size cap.
|
||||
|
||||
- [ ] **Created recurring event appears as a single instance until refresh; created event lacks `utcStart`/`utcEnd`** — `P3` — `partial`
|
||||
- [x] **Created recurring event appears as a single instance until refresh; created event lacks `utcStart`/`utcEnd`** — `P3` — `partial` — fixed in 58e3ef6
|
||||
- What WEB does: `createCalendarEvent` refetches the created event with full properties (`lib/jmap/client.ts:5942-5960`) and the store refetches when occurrences are in view.
|
||||
- What RN does: `createEvent` merges the `/set` echo over the payload (`src/api/calendar.ts:272-274`) and appends without expansion (`src/stores/calendar-store.ts:286-304`).
|
||||
- Fix hint: after create, `CalendarEvent/get` the id (RN `getEvents([id])`) and, if `recurrenceRules` is set, run `expandRecurringEvents` for `loadedRange` or call `refresh()`.
|
||||
|
||||
+116
-6
@@ -1,6 +1,6 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Calendar, CalendarEvent } from './types';
|
||||
import type { Calendar, CalendarEvent, CalendarRights } from './types';
|
||||
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
|
||||
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
|
||||
|
||||
@@ -489,12 +489,15 @@ export async function parseCalendarBlob(blobId: string): Promise<Partial<Calenda
|
||||
export async function createCalendar(
|
||||
name: string,
|
||||
color?: string,
|
||||
description?: string,
|
||||
): Promise<Calendar> {
|
||||
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(
|
||||
[['Calendar/set', {
|
||||
accountId,
|
||||
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } },
|
||||
create: { 'new-cal': props },
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
@@ -527,12 +530,119 @@ export async function setDefaultCalendar(
|
||||
methodResult(res);
|
||||
}
|
||||
|
||||
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
|
||||
// Destroy a calendar. `onDestroyRemoveEvents` removes its events too —
|
||||
// Stalwart otherwise refuses to delete a non-empty calendar.
|
||||
export async function deleteCalendar(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
export async function deleteCalendar(id: string, targetAccountId?: string): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
|
||||
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;
|
||||
sortOrder?: number;
|
||||
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
|
||||
// primary account; set for calendars shared with the user.
|
||||
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,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Check, X, Upload, Rss, Shuffle, Star } from 'lucide-react-native';
|
||||
import {
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2,
|
||||
} from 'lucide-react-native';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -28,11 +30,19 @@ interface CalendarSidebarDrawerProps {
|
||||
onClose: () => void;
|
||||
onImport?: () => void;
|
||||
onManageSubscriptions?: () => void;
|
||||
// Long-press actions. Set-default applies to the user's own calendars;
|
||||
// color change/reset applies to shared calendars (per-viewer recolor).
|
||||
onCreate?: () => void;
|
||||
// Long-press actions. Set-default / rename / share / clear / delete apply
|
||||
// to the user's own calendars; color change applies to own calendars
|
||||
// (server-side) and shared calendars (per-viewer recolor).
|
||||
onSetDefault?: (calendar: Calendar) => void;
|
||||
onSetColor?: (calendar: Calendar, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
onRename?: (calendar: Calendar) => void;
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
// Client-side iCal subscriptions are managed from the subscriptions sheet.
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}
|
||||
|
||||
export function CalendarSidebarDrawer({
|
||||
@@ -43,9 +53,15 @@ export function CalendarSidebarDrawer({
|
||||
onClose,
|
||||
onImport,
|
||||
onManageSubscriptions,
|
||||
onCreate,
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
onRename,
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
isSubscriptionCalendar,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -102,6 +118,11 @@ export function CalendarSidebarDrawer({
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
onRename,
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
isSubscriptionCalendar,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -144,8 +165,17 @@ export function CalendarSidebarDrawer({
|
||||
<Section title="Subscribed" calendars={subscribed} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{(onImport || onManageSubscriptions) && (
|
||||
{(onImport || onManageSubscriptions || onCreate) && (
|
||||
<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 && (
|
||||
<Pressable
|
||||
onPress={onManageSubscriptions}
|
||||
@@ -183,6 +213,11 @@ function Section({
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
onRename,
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
isSubscriptionCalendar,
|
||||
}: {
|
||||
title: string;
|
||||
calendars: Calendar[];
|
||||
@@ -193,6 +228,11 @@ function Section({
|
||||
onSetDefault?: (calendar: Calendar) => void;
|
||||
onSetColor?: (calendar: Calendar, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
onRename?: (calendar: Calendar) => void;
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -202,9 +242,17 @@ function Section({
|
||||
{calendars.map((cal) => {
|
||||
const visible = !hiddenSet.has(cal.id);
|
||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||
const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault;
|
||||
const canRecolor = !!onSetColor && !!cal.isShared;
|
||||
const hasActions = canSetDefault || canRecolor;
|
||||
const isSubscription = !!isSubscriptionCalendar?.(cal.id);
|
||||
const isOwn = !cal.isShared && !isBirthday && !isSubscription;
|
||||
const canSetDefault = !!onSetDefault && isOwn && !cal.isDefault;
|
||||
// Own calendars recolour server-side; shared ones locally (#345).
|
||||
const canRecolor = !!onSetColor && (!!cal.isShared || isOwn);
|
||||
const canRename = !!onRename && isOwn;
|
||||
const canShare = !!onShare && isOwn && (!cal.myRights || cal.myRights.mayShare !== false);
|
||||
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
|
||||
// Like webmail: the default calendar and shared calendars can't be deleted.
|
||||
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
|
||||
const expanded = expandedId === cal.id && hasActions;
|
||||
return (
|
||||
<View key={cal.id}>
|
||||
@@ -246,6 +294,15 @@ function Section({
|
||||
<Text style={styles.panelRowText}>Set as default calendar</Text>
|
||||
</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 && (
|
||||
<>
|
||||
<View style={styles.paletteRow}>
|
||||
@@ -266,7 +323,7 @@ function Section({
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{!!onResetColor && cal.colorIsLocalOverride && (
|
||||
{!!onResetColor && cal.isShared && cal.colorIsLocalOverride && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onResetColor(cal); }}
|
||||
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>
|
||||
|
||||
@@ -9,7 +9,20 @@ import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils';
|
||||
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
||||
import {
|
||||
DEFAULT_REFRESH_INTERVAL_MINUTES,
|
||||
selectAccountSubscriptions,
|
||||
useCalendarSubscriptionsStore,
|
||||
type CalendarSubscription,
|
||||
} from '../../stores/calendar-subscriptions-store';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
|
||||
const INTERVAL_OPTIONS: { minutes: number; label: string }[] = [
|
||||
{ minutes: 15, label: '15 min' },
|
||||
{ minutes: 60, label: '1 h' },
|
||||
{ minutes: 360, label: '6 h' },
|
||||
{ minutes: 1440, label: '1 day' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -20,26 +33,54 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
const allSubscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
// Only the signed-in account's feeds: a sub created under another account
|
||||
// mirrors into a calendar id that doesn't exist (or collides) here.
|
||||
const subscriptions = React.useMemo(
|
||||
() => selectAccountSubscriptions(allSubscriptions, jmapClient.isConnected ? jmapClient.accountId : null),
|
||||
[allSubscriptions],
|
||||
);
|
||||
const syncing = useCalendarSubscriptionsStore((s) => s.syncing);
|
||||
const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription);
|
||||
const updateSubscription = useCalendarSubscriptionsStore((s) => s.updateSubscription);
|
||||
const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription);
|
||||
const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription);
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [url, setUrl] = React.useState('');
|
||||
const [color, setColor] = React.useState<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 [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 overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setEditingId(null);
|
||||
setName('');
|
||||
setUrl('');
|
||||
setColor(CALENDAR_COLOR_PALETTE[0]);
|
||||
setInterval(DEFAULT_REFRESH_INTERVAL_MINUTES);
|
||||
setFormError(null);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
@@ -53,27 +94,26 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const handleSubmit = async () => {
|
||||
const n = name.trim();
|
||||
const u = url.trim();
|
||||
if (!n || !u) {
|
||||
setFormError('Enter a name and a feed URL.');
|
||||
return;
|
||||
}
|
||||
if (!/^(https?|webcal):\/\//i.test(u)) {
|
||||
setFormError('URL must start with https://, http:// or webcal://');
|
||||
if (!/^(https?|webcals?):\/\//i.test(u)) {
|
||||
setFormError('URL must start with https://, http://, webcal:// or webcals://');
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
setAdding(true);
|
||||
try {
|
||||
const sub = await addSubscription({ name: n, url: u, color });
|
||||
if (sub.lastError) {
|
||||
setFormError(sub.lastError);
|
||||
if (editingId) {
|
||||
await updateSubscription(editingId, { name: n, url: u, color, refreshIntervalMinutes: interval });
|
||||
} else {
|
||||
setName('');
|
||||
setUrl('');
|
||||
await addSubscription({ name: n, url: u, color, refreshIntervalMinutes: interval });
|
||||
}
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : 'Could not add subscription.');
|
||||
} finally {
|
||||
@@ -97,7 +137,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
</View>
|
||||
|
||||
<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
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
@@ -128,19 +168,43 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
/>
|
||||
))}
|
||||
</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 && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{formError}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => { void handleAdd(); }}
|
||||
disabled={adding}
|
||||
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
|
||||
>
|
||||
{adding ? 'Subscribing…' : 'Subscribe'}
|
||||
</Button>
|
||||
<View style={styles.formActions}>
|
||||
{editingId && (
|
||||
<Button variant="outline" onPress={resetForm} disabled={adding}>
|
||||
Cancel
|
||||
</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 && (
|
||||
<>
|
||||
@@ -150,7 +214,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
return (
|
||||
<View key={sub.id} style={styles.subRow}>
|
||||
<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.subMeta} numberOfLines={1}>
|
||||
{sub.lastError
|
||||
@@ -159,7 +223,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
|
||||
: 'Not synced yet'}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void syncSubscription(sub.id); }}
|
||||
hitSlop={6}
|
||||
@@ -232,6 +296,20 @@ function makeStyles(c: ThemePalette) {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
|
||||
fieldLabel: { ...typography.caption, color: c.textSecondary, marginBottom: spacing.xs },
|
||||
intervalRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
|
||||
intervalChip: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
intervalChipActive: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
intervalChipText: { ...typography.caption, color: c.text },
|
||||
intervalChipTextActive: { color: c.primaryForeground },
|
||||
formActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
|
||||
colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' },
|
||||
colorDotActive: { borderColor: c.text },
|
||||
errorBox: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AppState,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
@@ -52,6 +53,8 @@ import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDra
|
||||
import { TasksSheet } from '../components/calendar/TasksSheet';
|
||||
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
|
||||
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
|
||||
import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet';
|
||||
import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet';
|
||||
import {
|
||||
applySharedCalendarColors,
|
||||
buildEventDayIndex,
|
||||
@@ -177,6 +180,17 @@ export default function CalendarScreen() {
|
||||
const deleteTask = useCalendarStore((s) => s.deleteTask);
|
||||
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
|
||||
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
|
||||
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||
const updateCalendar = useCalendarStore((s) => s.updateCalendar);
|
||||
const removeCalendar = useCalendarStore((s) => s.removeCalendar);
|
||||
const clearCalendarEvents = useCalendarStore((s) => s.clearCalendarEvents);
|
||||
const shareCalendar = useCalendarStore((s) => s.shareCalendar);
|
||||
// Calendar management sheets (create / edit / share) opened from the drawer.
|
||||
const [calendarEditTarget, setCalendarEditTarget] = React.useState<
|
||||
{ mode: 'create' } | { mode: 'edit'; calendar: Calendar } | null
|
||||
>(null);
|
||||
const [shareTarget, setShareTarget] = React.useState<Calendar | null>(null);
|
||||
const syncDueSubscriptions = useCalendarSubscriptionsStore((s) => s.syncDue);
|
||||
const storeCalendars = useCalendarStore((s) => s.calendars);
|
||||
const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds);
|
||||
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
|
||||
@@ -278,6 +292,16 @@ export default function CalendarScreen() {
|
||||
void fetchCalendarsAction();
|
||||
}, [hydrate, fetchCalendarsAction]);
|
||||
|
||||
// Refresh iCal subscriptions whose interval elapsed: on mount and whenever
|
||||
// the app returns to the foreground (like the webmail's periodic refresh).
|
||||
React.useEffect(() => {
|
||||
void syncDueSubscriptions();
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') void syncDueSubscriptions();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [syncDueSubscriptions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
|
||||
void ensureRange(after.toISOString(), before.toISOString());
|
||||
@@ -509,6 +533,86 @@ export default function CalendarScreen() {
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const reportError = React.useCallback(
|
||||
(err: unknown) => {
|
||||
Alert.alert(
|
||||
t('calendar.notifications.event_error', 'Something went wrong'),
|
||||
err instanceof Error ? err.message : undefined,
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleCalendarEditSave = React.useCallback(
|
||||
async (values: CalendarEditValues) => {
|
||||
if (!calendarEditTarget) return;
|
||||
if (calendarEditTarget.mode === 'create') {
|
||||
await createCalendar(values.name, values.color, values.description);
|
||||
} else {
|
||||
await updateCalendar(calendarEditTarget.calendar.id, {
|
||||
name: values.name,
|
||||
color: values.color,
|
||||
description: values.description || null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[calendarEditTarget, createCalendar, updateCalendar],
|
||||
);
|
||||
|
||||
const handleSetCalendarColor = React.useCallback(
|
||||
(cal: Calendar, color: string) => {
|
||||
if (cal.isShared) {
|
||||
// Per-viewer recolor (#345): the owner's colour is left alone.
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
return;
|
||||
}
|
||||
updateCalendar(cal.id, { color }).catch(reportError);
|
||||
},
|
||||
[setSharedCalendarColor, updateCalendar, reportError],
|
||||
);
|
||||
|
||||
const handleClearCalendar = React.useCallback(
|
||||
(cal: Calendar) => {
|
||||
Alert.alert(
|
||||
t('calendar.management.clear_title', 'Remove all events?'),
|
||||
t(
|
||||
'calendar.management.clear_description',
|
||||
'Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.',
|
||||
),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('calendar.management.clear_confirm', 'Remove all'),
|
||||
style: 'destructive',
|
||||
onPress: () => { clearCalendarEvents(cal.id).catch(reportError); },
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
[t, clearCalendarEvents, reportError],
|
||||
);
|
||||
|
||||
const handleDeleteCalendar = React.useCallback(
|
||||
(cal: Calendar) => {
|
||||
Alert.alert(
|
||||
t('calendar.management.delete_title', 'Delete calendar?'),
|
||||
t(
|
||||
'calendar.management.delete_description',
|
||||
'The calendar and all of its events will be deleted. This cannot be undone.',
|
||||
),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('common.delete', 'Delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => { removeCalendar(cal.id).catch(reportError); },
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
[t, removeCalendar, reportError],
|
||||
);
|
||||
|
||||
const isSelectedToday = isToday(selectedDate);
|
||||
|
||||
return (
|
||||
@@ -689,13 +793,19 @@ export default function CalendarScreen() {
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
|
||||
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
|
||||
onSetDefault={(cal) => { void setDefaultCalendar(cal.id); }}
|
||||
onSetColor={(cal, color) => setSharedCalendarColor(sharedCalendarColorKey(cal), color)}
|
||||
onCreate={() => { setSidebarVisible(false); setCalendarEditTarget({ mode: 'create' }); }}
|
||||
onSetDefault={(cal) => { setDefaultCalendar(cal.id).catch(reportError); }}
|
||||
onSetColor={handleSetCalendarColor}
|
||||
onResetColor={(cal) => {
|
||||
// Drop the local override; the auto-assign effect picks a fresh
|
||||
// unused color (so it never reverts to a collision).
|
||||
removeSharedCalendarColor(sharedCalendarColorKey(cal));
|
||||
}}
|
||||
onRename={(cal) => { setSidebarVisible(false); setCalendarEditTarget({ mode: 'edit', calendar: cal }); }}
|
||||
onShare={(cal) => { setSidebarVisible(false); setShareTarget(cal); }}
|
||||
onClear={handleClearCalendar}
|
||||
onDelete={handleDeleteCalendar}
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
/>
|
||||
|
||||
<TasksSheet
|
||||
@@ -724,6 +834,19 @@ export default function CalendarScreen() {
|
||||
visible={subscriptionsVisible}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Calendar, CalendarEvent, StateChange } from '../api/types';
|
||||
import type { Calendar, CalendarEvent, CalendarRights, StateChange } from '../api/types';
|
||||
import {
|
||||
type CalendarUpdates,
|
||||
updateCalendar as apiUpdateCalendar,
|
||||
deleteCalendar as apiDeleteCalendar,
|
||||
clearCalendarEvents as apiClearCalendarEvents,
|
||||
setCalendarShare as apiSetCalendarShare,
|
||||
getCalendars as fetchCalendars,
|
||||
queryEvents,
|
||||
getEvents as fetchEvents,
|
||||
@@ -108,7 +113,11 @@ export interface CalendarState {
|
||||
replyTo?: Record<string, string> | null,
|
||||
) => Promise<void>;
|
||||
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>;
|
||||
// Tasks
|
||||
createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>;
|
||||
@@ -508,12 +517,52 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
return count;
|
||||
},
|
||||
|
||||
createCalendar: async (name, color) => {
|
||||
const created = await apiCreateCalendar(name, color);
|
||||
createCalendar: async (name, color, description) => {
|
||||
const created = await apiCreateCalendar(name, color, description);
|
||||
set({ calendars: [...get().calendars, created] });
|
||||
return created;
|
||||
},
|
||||
|
||||
updateCalendar: async (id, updates) => {
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
await apiUpdateCalendar(cal?.originalId || id, updates, cal?.accountId);
|
||||
set({
|
||||
calendars: get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c)),
|
||||
});
|
||||
},
|
||||
|
||||
removeCalendar: async (id) => {
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
await apiDeleteCalendar(cal?.originalId || id, cal?.accountId);
|
||||
set({
|
||||
calendars: get().calendars.filter((c) => c.id !== id),
|
||||
events: get().events.filter((e) => !e.calendarIds?.[id]),
|
||||
tasks: get().tasks.filter((t) => !t.calendarIds?.[id]),
|
||||
hiddenCalendarIds: get().hiddenCalendarIds.filter((x) => x !== id),
|
||||
});
|
||||
},
|
||||
|
||||
clearCalendarEvents: async (id) => {
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
const removed = await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId);
|
||||
await get().refresh();
|
||||
return removed;
|
||||
},
|
||||
|
||||
shareCalendar: async (id, principalId, rights) => {
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
await apiSetCalendarShare(cal?.originalId || id, principalId, rights, cal?.accountId);
|
||||
set({
|
||||
calendars: get().calendars.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const next = { ...(c.shareWith ?? {}) };
|
||||
if (rights === null) delete next[principalId];
|
||||
else next[principalId] = rights;
|
||||
return { ...c, shareWith: next };
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
setDefaultCalendar: async (id) => {
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId);
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
queryEvents,
|
||||
getEvents,
|
||||
deleteEvents,
|
||||
updateEvent,
|
||||
updateCalendar,
|
||||
} from '../api/calendar';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { uploadBytes } from '../api/blob';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
|
||||
@@ -20,29 +23,88 @@ export interface CalendarSubscription {
|
||||
color?: string;
|
||||
/** The local Stalwart calendar that mirrors this remote feed. */
|
||||
calendarId: string;
|
||||
/** JMAP account the mirror calendar lives in; subs of other accounts are hidden. */
|
||||
accountId?: string;
|
||||
/** Minutes between automatic refreshes (default 60). */
|
||||
refreshIntervalMinutes?: number;
|
||||
lastSyncAt: number | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_REFRESH_INTERVAL_MINUTES = 60;
|
||||
|
||||
// 10 MB is far beyond any real feed; a bigger response is not a calendar.
|
||||
const MAX_FEED_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
interface SubscriptionsState {
|
||||
subscriptions: CalendarSubscription[];
|
||||
syncing: Record<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>;
|
||||
syncSubscription: (id: string) => 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.
|
||||
function normalizeFeedUrl(url: string): string {
|
||||
return url.trim().replace(/^webcal:\/\//i, 'https://');
|
||||
/** Subscriptions belonging to the signed-in JMAP account (legacy ones without an accountId count as its own). */
|
||||
export function selectAccountSubscriptions(
|
||||
subscriptions: CalendarSubscription[],
|
||||
accountId: string | null,
|
||||
): CalendarSubscription[] {
|
||||
return subscriptions.filter((s) => !s.accountId || !accountId || s.accountId === accountId);
|
||||
}
|
||||
|
||||
function currentAccountId(): string | null {
|
||||
return jmapClient.isConnected ? jmapClient.accountId : null;
|
||||
}
|
||||
|
||||
// webcal:// and webcals:// are just iCalendar over HTTP(S) — clients map
|
||||
// them to https. Credentials embedded in the URL (#275) are moved into an
|
||||
// Authorization header because RN's WHATWG fetch rejects userinfo in URLs.
|
||||
export function normalizeFeedUrl(url: string): { url: string; headers: Record<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>[]> {
|
||||
const res = await fetch(normalizeFeedUrl(url));
|
||||
const { url: feedUrl, headers } = normalizeFeedUrl(url);
|
||||
const res = await fetch(feedUrl, { headers });
|
||||
if (!res.ok) throw new Error(`Could not fetch feed (HTTP ${res.status})`);
|
||||
const length = Number(res.headers.get('content-length') || 0);
|
||||
if (length > MAX_FEED_BYTES) throw new Error('Feed is too large');
|
||||
const text = await res.text();
|
||||
if (text.length > MAX_FEED_BYTES) throw new Error('Feed is too large');
|
||||
if (!/BEGIN:VCALENDAR/i.test(text)) {
|
||||
throw new Error('That URL did not return an iCalendar feed');
|
||||
}
|
||||
@@ -52,20 +114,26 @@ async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]>
|
||||
return events.filter((e) => !!e.start);
|
||||
}
|
||||
|
||||
// Reconcile the local calendar with the remote feed: delete events whose UID
|
||||
// disappeared upstream, then import the current set (importEvents dedupes by
|
||||
// UID, so unchanged events are left in place).
|
||||
// Reconcile the local calendar with the remote feed: drop events whose UID
|
||||
// disappeared upstream (unlinking rather than deleting when the event also
|
||||
// lives in another calendar), then import the current set (importEvents
|
||||
// dedupes by UID, so unchanged events are left in place).
|
||||
async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<void> {
|
||||
const parsed = await fetchAndParseFeed(url);
|
||||
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
|
||||
|
||||
try {
|
||||
const allIds = await queryEvents([], '', '');
|
||||
const all = allIds.length > 0 ? await getEvents(allIds) : [];
|
||||
const stale = all
|
||||
.filter((e) => e.calendarIds?.[calendarId] && e.uid && !parsedUids.has(e.uid))
|
||||
.map((e) => e.id);
|
||||
if (stale.length > 0) await deleteEvents(stale);
|
||||
const ids = await queryEvents([calendarId], '', '');
|
||||
const existing = ids.length > 0 ? await getEvents(ids) : [];
|
||||
const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
|
||||
const toDelete: string[] = [];
|
||||
for (const e of stale) {
|
||||
const others = { ...(e.calendarIds || {}) };
|
||||
delete others[calendarId];
|
||||
if (Object.keys(others).length === 0) toDelete.push(e.id);
|
||||
else await updateEvent(e.id, { calendarIds: others });
|
||||
}
|
||||
if (toDelete.length > 0) await deleteEvents(toDelete);
|
||||
} catch {
|
||||
// Non-fatal: if we can't enumerate existing events, still import new ones.
|
||||
}
|
||||
@@ -79,7 +147,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||
subscriptions: [],
|
||||
syncing: {},
|
||||
|
||||
addSubscription: async ({ name, url, color }) => {
|
||||
addSubscription: async ({ name, url, color, refreshIntervalMinutes }) => {
|
||||
// Create a dedicated local calendar to hold the feed's events.
|
||||
const calendar = await createCalendar(name, color);
|
||||
const sub: CalendarSubscription = {
|
||||
@@ -88,31 +156,55 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||
url: url.trim(),
|
||||
color,
|
||||
calendarId: calendar.id,
|
||||
accountId: currentAccountId() ?? undefined,
|
||||
refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES,
|
||||
lastSyncAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
set({ subscriptions: [...get().subscriptions, sub] });
|
||||
// Make the new calendar visible immediately.
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
try {
|
||||
set({ syncing: { ...get().syncing, [sub.id]: true } });
|
||||
await syncFeedIntoCalendar(sub.calendarId, sub.url);
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === sub.id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
|
||||
),
|
||||
});
|
||||
set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] });
|
||||
await useCalendarStore.getState().refresh();
|
||||
return { ...sub, lastSyncAt: Date.now() };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Sync failed';
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === sub.id ? { ...s, lastError: message } : s,
|
||||
),
|
||||
});
|
||||
// Roll back: a bad URL / 404 must not leave a phantom calendar behind.
|
||||
try {
|
||||
await deleteCalendar(calendar.id);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
throw err instanceof Error ? err : new Error('Sync failed');
|
||||
} finally {
|
||||
set({ syncing: { ...get().syncing, [sub.id]: false } });
|
||||
}
|
||||
return sub;
|
||||
},
|
||||
|
||||
updateSubscription: async (id, updates) => {
|
||||
const sub = get().subscriptions.find((s) => s.id === id);
|
||||
if (!sub) return;
|
||||
const next: CalendarSubscription = {
|
||||
...sub,
|
||||
...(updates.name !== undefined ? { name: updates.name.trim() } : {}),
|
||||
...(updates.url !== undefined ? { url: updates.url.trim() } : {}),
|
||||
...(updates.color !== undefined ? { color: updates.color } : {}),
|
||||
...(updates.refreshIntervalMinutes !== undefined
|
||||
? { refreshIntervalMinutes: updates.refreshIntervalMinutes }
|
||||
: {}),
|
||||
};
|
||||
if (next.name !== sub.name || next.color !== sub.color) {
|
||||
try {
|
||||
await updateCalendar(sub.calendarId, { name: next.name, color: next.color });
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
} catch {
|
||||
// The mirror calendar keeps its old name/colour; the sub still updates.
|
||||
}
|
||||
}
|
||||
set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? next : s)) });
|
||||
if (next.url !== sub.url) await get().syncSubscription(id);
|
||||
},
|
||||
|
||||
removeSubscription: async (id) => {
|
||||
@@ -132,6 +224,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||
syncSubscription: async (id) => {
|
||||
const sub = get().subscriptions.find((s) => s.id === id);
|
||||
if (!sub) return;
|
||||
if (get().syncing[id]) return;
|
||||
set({ syncing: { ...get().syncing, [id]: true } });
|
||||
try {
|
||||
await syncFeedIntoCalendar(sub.calendarId, sub.url);
|
||||
@@ -153,11 +246,22 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||
},
|
||||
|
||||
syncAll: async () => {
|
||||
const subs = get().subscriptions;
|
||||
const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId());
|
||||
for (const sub of subs) {
|
||||
await get().syncSubscription(sub.id);
|
||||
}
|
||||
},
|
||||
|
||||
syncDue: async () => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const now = Date.now();
|
||||
const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId());
|
||||
for (const sub of subs) {
|
||||
const interval = (sub.refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES) * 60_000;
|
||||
if (sub.lastSyncAt && now - sub.lastSyncAt < interval) continue;
|
||||
await get().syncSubscription(sub.id);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'calendar-subscriptions',
|
||||
|
||||
Reference in New Issue
Block a user