fix: gate event editing on calendar rights and keep read-only calendars out of the pickers (#762)

This commit is contained in:
Linus Rath
2026-08-29 17:57:54 +02:00
parent edc26ce1ca
commit 3fd5f10afe
12 changed files with 272 additions and 53 deletions
+5 -5
View File
@@ -95,22 +95,22 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
### Invitations / scheduling
- [ ] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity`
- [x] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity` — fixed in 5dc4070
- What WEB does: `buildParticipantMap` emits an owner-only organizer participant with `calendarAddress`, attendees with `calendarAddress` + `scheduleAgent: 'server'`, and the modal sets `organizerCalendarAddress: mailto:<user>` — without it Stalwart emits no ORGANIZER and silently skips iTIP (ref `lib/calendar-participants.ts:164-214`, `components/calendar/event-modal.tsx:573-586`, changelog 1.7.0 "Send calendar invites by setting organizerCalendarAddress", 1.7.2 #500 "Use calendarAddress, drop retired sendTo", 1.8.1 #731).
- What RN does: `ParticipantInput.addParticipant` writes `{ email, sendTo: { imip }, roles: { attendee } }` (`src/components/calendar/ParticipantInput.tsx:71-92`), no organizer participant and no `organizerCalendarAddress` anywhere in `EventModal.handleSave` (`:262-281`). `sendSchedulingMessages: true` is passed (`src/stores/calendar-store.ts:293-297`) but Stalwart has nothing to schedule with. On Stalwart the attendee's `sendTo` is stored as an inert JSPROP.
- Fix hint: port `buildParticipantMap` + `collectUserCalendarAddresses`; on save with attendees set `participants = buildParticipantMap({name, email: activeAccount.email}, attendees)` and `organizerCalendarAddress = mailto:<email>`; on edit keep existing participant ids and never re-add the organizer (#731).
- [ ] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing`
- [x] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing` — fixed in 5dc4070
- What WEB does: `currentUserEmails` = identities + account aliases via `collectUserCalendarAddresses`, used for `getUserParticipantId`, organizer detection and editability (ref `lib/calendar-participants.ts:71-112`, changelog 1.9.0 "including alias organizers").
- What RN does: `findParticipantByEmail(event, [activeEmail])` with the single login address (`src/components/calendar/EventDetailSheet.tsx:115, 173`, `src/components/email/CalendarInvitationBanner.tsx:98`). Invitations addressed to an alias show no RSVP buttons.
- Fix hint: RN already loads identities for the composer; collect their emails (+ aliases from `x:Account/get` if available) and pass the array.
- [ ] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial`
- [x] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial` — fixed in 5dc4070
- What WEB does: only when the event lacks `organizerCalendarAddress` it sets it from `replyTo.imip`; never sends `replyTo` (retired) (ref `stores/calendar-store.ts:782-789`).
- What RN does: `rsvpEvent` sets `patch.replyTo = replyTo` whenever `buildReplyTo` returns something (`src/api/calendar.ts:344`, `src/lib/calendar-invitation.ts:74-83`). Harmless on Stalwart (ignored) but the repair path for imported invites lacking an organizer is missing, so those REPLYs are not routed.
- Fix hint: mirror WEB: `if (replyTo?.imip && !storeEvent.organizerCalendarAddress) patch.organizerCalendarAddress = replyTo.imip`.
- [ ] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug`
- [x] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug` — fixed in 5dc4070
- What WEB does: participant ids are opaque and RFC 6901-escaped; only empty ids are rejected (ref `stores/calendar-store.ts:763-770`).
- What RN does: `if (!participantId || participantId.includes('..')) throw` (`src/stores/calendar-store.ts:332-334`); a server-generated id like `a..b` cannot RSVP.
- Fix hint: drop the `..` check.
@@ -214,7 +214,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: `event.description` rendered with `<MapPin/>` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read.
- Fix hint: show `Object.values(event.locations)[0]?.name` with MapPin; drop or move the description.
- [ ] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug`
- [x] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug` — fixed in 5dc4070
- What WEB does: multi-day all-day events show the inclusive end date (changelog 1.6.x #318, `getEventDisplayEndDate`).
- What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`).
- Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting.
-2
View File
@@ -532,8 +532,6 @@ export interface CalendarRights {
mayRSVP?: boolean;
mayShare?: boolean;
mayDelete?: boolean;
// Legacy short flag still read by the sidebar drawer.
mayWrite?: boolean;
}
export interface Calendar {
@@ -18,6 +18,7 @@ import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { isWritableCalendar } from '../../lib/calendar-editability';
interface CalendarSidebarDrawerProps {
visible: boolean;
@@ -90,12 +91,8 @@ export function CalendarSidebarDrawer({
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
const sharedCalendars = calendars.filter((cal) => cal.isShared);
const myCalendars = calendars.filter(
(cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false),
);
const subscribed = calendars.filter(
(cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false,
);
const myCalendars = calendars.filter((cal) => !cal.isShared && isWritableCalendar(cal));
const subscribed = calendars.filter((cal) => !cal.isShared && !isWritableCalendar(cal));
const sectionProps = {
hiddenSet,
+42 -25
View File
@@ -46,6 +46,7 @@ import {
getUserParticipantId,
isOrganizer,
} from '../../lib/calendar-participants';
import { getEventEditability } from '../../lib/calendar-editability';
import { useSheetDrag } from '../../lib/use-sheet-drag';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -56,6 +57,8 @@ interface EventDetailSheetProps {
timeFormat?: TimeFormat;
// Login address + identities + aliases; finds "me" among the participants.
currentUserEmails?: string[];
// Client-side iCal subscriptions are always read-only.
isSubscriptionCalendar?: (calendarId: string) => boolean;
onClose: () => void;
onEdit?: (event: CalendarEvent) => void;
onDelete?: (event: CalendarEvent) => void;
@@ -112,6 +115,7 @@ export function EventDetailSheet({
calendars,
timeFormat,
currentUserEmails = [],
isSubscriptionCalendar,
onClose,
onEdit,
onDelete,
@@ -180,7 +184,18 @@ export function EventDetailSheet({
// aliases) and the caller wired an onRsvp handler.
const myParticipantId = getUserParticipantId(event, currentUserEmails);
const userIsOrganizer = isOrganizer(event, currentUserEmails);
const canRsvp = Boolean(onRsvp && myParticipantId && !userIsOrganizer);
// Gate affordances on calendar rights first, then identity: read-only /
// subscription calendars never offer Edit/Delete, RSVP-only events offer
// just the reply buttons.
const editability = getEventEditability(event, {
calendarsById: new Map(calendars.map((cal) => [cal.id, cal])),
userCalendarAddresses: currentUserEmails,
isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false),
});
const canEdit = editability === 'editable';
const canRsvp = Boolean(
onRsvp && myParticipantId && !userIsOrganizer && editability !== 'read-only',
);
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
const doRsvp = async (status: RsvpStatus) => {
@@ -339,30 +354,32 @@ export function EventDetailSheet({
)}
</ScrollView>
<View style={styles.actions}>
{onEdit && (
<ActionButton
icon={<Pencil size={18} color={c.text} />}
label="Edit"
onPress={() => onEdit(event)}
/>
)}
{onDuplicate && (
<ActionButton
icon={<Copy size={18} color={c.text} />}
label="Duplicate"
onPress={() => onDuplicate(event)}
/>
)}
{onDelete && (
<ActionButton
icon={<Trash2 size={18} color={c.error} />}
label="Delete"
onPress={() => onDelete(event)}
destructive
/>
)}
</View>
{(canEdit || onDuplicate) && (
<View style={styles.actions}>
{onEdit && canEdit && (
<ActionButton
icon={<Pencil size={18} color={c.text} />}
label="Edit"
onPress={() => onEdit(event)}
/>
)}
{onDuplicate && (
<ActionButton
icon={<Copy size={18} color={c.text} />}
label="Duplicate"
onPress={() => onDuplicate(event)}
/>
)}
{onDelete && canEdit && (
<ActionButton
icon={<Trash2 size={18} color={c.error} />}
label="Delete"
onPress={() => onDelete(event)}
destructive
/>
)}
</View>
)}
</SafeAreaView>
</Animated.View>
</Modal>
+23 -9
View File
@@ -44,6 +44,7 @@ import {
getPrimaryCalendarId,
} from '../../lib/calendar-utils';
import { getEffectiveTimeZone } from '../../lib/calendar-timezone';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import {
alertsToReminders,
remindersToAlerts,
@@ -82,6 +83,8 @@ interface EventModalProps {
// Login address + identities + aliases; the first one is the organizer
// address of invitations sent from this editor.
currentUserEmails?: string[];
// Client-side iCal subscriptions are read-only targets (#762).
isSubscriptionCalendar?: (calendarId: string) => boolean;
onSave: (data: Partial<CalendarEvent>, calendarId: string) => void | Promise<void>;
onDelete?: (event: CalendarEvent) => void;
onClose: () => void;
@@ -162,6 +165,7 @@ export function EventModal({
defaultDate,
defaultCalendarId,
currentUserEmails = [],
isSubscriptionCalendar,
onSave,
onDelete,
onClose,
@@ -177,9 +181,11 @@ export function EventModal({
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
// New events land in the explicitly requested calendar, else the account's
// default calendar, else the first one.
// Never default new events into a subscription / read-only calendar (#762).
const fallbackCalendarId =
defaultCalendarId
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|| calendars.find((cal) => cal.isDefault && !cal.isShared && canCreateEventsIn(cal, isSubscriptionCalendar))?.id
|| calendars.find((cal) => canCreateEventsIn(cal, isSubscriptionCalendar))?.id
|| calendars[0]?.id
|| '';
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
@@ -227,12 +233,7 @@ export function EventModal({
setAllDay(false);
setStart(d);
setEnd(addHours(d, 1));
setCalendarId(
defaultCalendarId
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|| calendars[0]?.id
|| '',
);
setCalendarId(fallbackCalendarId);
setAttendees([]);
setRecurrence('none');
setCustomRule(null);
@@ -241,7 +242,7 @@ export function EventModal({
setLocation('');
setVideoUrl('');
}
}, [visible, event, defaultDate, defaultCalendarId, calendars, currentUserEmails]);
}, [visible, event, defaultDate, fallbackCalendarId, calendars, currentUserEmails]);
React.useEffect(() => {
if (allDay) {
@@ -349,6 +350,19 @@ export function EventModal({
};
const selectedCalendar = calendars.find((c) => c.id === calendarId);
// Calendars offered as create/move targets: writable, non-subscription, in
// the same account as the event being edited (a cross-account move would
// need delete + recreate), plus the event's current calendar so an
// in-place edit never blanks the picker (#762).
const selectableCalendars = React.useMemo(() => {
const eventAccount = event ? (event.accountId ?? null) : null;
return calendars.filter((c) => {
if (c.id === calendarId) return true;
if (!canCreateEventsIn(c, isSubscriptionCalendar)) return false;
if (event && (c.accountId ?? null) !== eventAccount) return false;
return true;
});
}, [calendars, calendarId, event, isSubscriptionCalendar]);
const recurrenceLabel =
recurrence === 'custom'
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
@@ -430,7 +444,7 @@ export function EventModal({
</Pressable>
{calendarOpen && (
<View style={styles.popover}>
{calendars.map((c) => (
{selectableCalendars.map((c) => (
<Pressable
key={c.id}
style={[
+2 -1
View File
@@ -12,6 +12,7 @@ import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
import { isWritableCalendar } from '../../lib/calendar-editability';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -49,7 +50,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const insets = useSafeAreaInsets();
const writable = React.useMemo(
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
() => calendars.filter((cal) => isWritableCalendar(cal)),
[calendars],
);
+2 -1
View File
@@ -27,6 +27,7 @@ import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useSheetDrag } from '../../lib/use-sheet-drag';
interface TasksSheetProps {
@@ -82,7 +83,7 @@ export function TasksSheet({
});
const writableCalendars = React.useMemo(
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
() => calendars.filter((cal) => isWritableCalendar(cal)),
[calendars],
);
@@ -19,6 +19,8 @@ import {
type InvitationMethod,
} from '../../lib/calendar-invitation';
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
@@ -32,6 +34,7 @@ export function CalendarInvitationBanner({ email }: Props) {
const styles = React.useMemo(() => makeStyles(c), [c]);
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const calendars = useCalendarStore((s) => s.calendars);
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
const importEvents = useCalendarStore((s) => s.importEvents);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
// Login address + identities + aliases, so invitations addressed to an
@@ -95,7 +98,15 @@ export function CalendarInvitationBanner({ email }: Props) {
const me = findParticipantByEmail(event, currentUserEmails);
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
// Import into the account's default calendar; never into a shared calendar,
// an iCal subscription (the next feed sync would delete the event) or a
// read-only one. Fall back to the first calendar events may be created in.
const isSubscriptionCalendar = (id: string) =>
subscriptions.some((s) => s.calendarId === id);
const candidates = calendars.filter(
(cal) => !cal.isShared && canCreateEventsIn(cal, isSubscriptionCalendar),
);
const writableCalendar = candidates.find((cal) => cal.isDefault) ?? candidates[0];
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
if (busy || !writableCalendar) return;
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import type { Calendar, CalendarEvent } from '../../api/types';
import {
canCreateEventsIn,
eventHasNoOwner,
getEventEditability,
isWritableCalendar,
} from '../calendar-editability';
const base: CalendarEvent = {
id: 'ev',
uid: 'u',
title: 'T',
start: '2026-03-01T09:00:00',
calendarIds: { 'cal-1': true },
};
function ctx(rights: Calendar['myRights'] | undefined, subs: string[] = []) {
return {
calendarsById: new Map([['cal-1', { myRights: rights }]]),
userCalendarAddresses: ['me@example.com', 'alias@example.com'],
isSubscriptionCalendar: (id: string) => subs.includes(id),
};
}
describe('isWritableCalendar / canCreateEventsIn', () => {
it('treats missing rights as writable and never reads mayWrite', () => {
expect(isWritableCalendar({})).toBe(true);
expect(isWritableCalendar({ myRights: { mayWriteOwn: true } })).toBe(true);
expect(isWritableCalendar({ myRights: { mayReadItems: true } })).toBe(false);
});
it('excludes iCal subscriptions even when the calendar is writable (#762)', () => {
const cal = { id: 'cal-1', myRights: { mayWriteAll: true } };
expect(canCreateEventsIn(cal)).toBe(true);
expect(canCreateEventsIn(cal, (id) => id === 'cal-1')).toBe(false);
});
});
describe('getEventEditability', () => {
it('is read-only on a subscription calendar regardless of rights', () => {
expect(getEventEditability(base, ctx({ mayWriteAll: true }, ['cal-1']))).toBe('read-only');
});
it('is editable with mayWriteAll', () => {
expect(getEventEditability(base, ctx({ mayWriteAll: true }))).toBe('editable');
});
it('is editable with mayWriteOwn only for own/ownerless events (alias organizer counts)', () => {
expect(getEventEditability(base, ctx({ mayWriteOwn: true }))).toBe('editable');
const foreign = { ...base, organizerCalendarAddress: 'mailto:other@example.com' };
expect(getEventEditability(foreign, ctx({ mayWriteOwn: true }))).toBe('read-only');
const mine = { ...base, organizerCalendarAddress: 'mailto:alias@example.com' };
expect(getEventEditability(mine, ctx({ mayWriteOwn: true }))).toBe('editable');
});
it('is rsvp-only for a participant with mayRSVP but no write right', () => {
const invited = {
...base,
organizerCalendarAddress: 'mailto:other@example.com',
participants: { p1: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true } } },
};
expect(getEventEditability(invited, ctx({ mayRSVP: true }))).toBe('rsvp-only');
expect(getEventEditability(invited, ctx({ mayReadItems: true }))).toBe('read-only');
});
it('falls back on ownership when rights are not loaded', () => {
expect(getEventEditability(base, ctx(undefined))).toBe('editable');
expect(getEventEditability({ ...base, isShared: true }, ctx(undefined))).toBe('read-only');
});
});
describe('eventHasNoOwner', () => {
it('detects plain events', () => {
expect(eventHasNoOwner(base)).toBe(true);
expect(eventHasNoOwner({ ...base, replyTo: { imip: 'mailto:x@y' } })).toBe(false);
expect(eventHasNoOwner({ ...base, participants: { a: { roles: { owner: true } } } })).toBe(false);
});
});
-1
View File
@@ -19,7 +19,6 @@ export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayWrite: false,
mayDelete: false,
mayRSVP: false,
},
+88
View File
@@ -0,0 +1,88 @@
import type { CalendarEvent, Calendar, CalendarRights } from '../api/types';
import { isOrganizer, getUserParticipantId } from './calendar-participants';
// Port of the webmail's lib/calendar-editability.ts.
export type EventEditability = 'editable' | 'rsvp-only' | 'read-only';
export interface EditabilityContext {
/** Viewer's calendars by id; only their `myRights` is read. */
calendarsById: ReadonlyMap<string, Pick<Calendar, 'myRights'>>;
/** Identities + aliases; only refines owner/participant detection, never the gate. */
userCalendarAddresses: string[];
/** Client-side external iCal (webcal/ICS) subscriptions - always read-only. */
isSubscriptionCalendar: (calendarId: string) => boolean;
}
/**
* Whether the viewer may write to this calendar at all. Stalwart never sends
* a `mayWrite` flag; the RFC-style rights are `mayWriteAll` / `mayWriteOwn`.
* Missing rights (older server, own calendars) count as writable.
*/
export function isWritableCalendar(calendar: Pick<Calendar, 'myRights'>): boolean {
const r = calendar.myRights;
return !r || !!r.mayWriteAll || !!r.mayWriteOwn;
}
/**
* Whether new events may be created in / moved into this calendar: writable and
* not a (read-only) iCal subscription. Bulwark subscriptions are real Stalwart
* calendars with writable myRights, so the flag must be checked too (issue #762).
*/
export function canCreateEventsIn(
calendar: Pick<Calendar, 'id' | 'myRights'>,
isSubscriptionCalendar?: (calendarId: string) => boolean,
): boolean {
if (isSubscriptionCalendar?.(calendar.id)) return false;
return isWritableCalendar(calendar);
}
/** Ownerless (plain, non-scheduled) events are editable under `mayWriteOwn`. */
export function eventHasNoOwner(event: CalendarEvent): boolean {
if (event.organizerCalendarAddress) return false;
if (event.replyTo && Object.keys(event.replyTo).length > 0) return false;
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (p.roles?.owner) return false;
}
}
return true;
}
/**
* Editability gated on the calendar's `myRights` FIRST, then identity - so
* read-only/subscription calendars stay read-only and an alias organizer can't
* over-open events the user can't write. (Stalwart: organizer identity does not
* confer calendar write; the ACL does.)
*/
export function getEventEditability(
event: CalendarEvent,
ctx: EditabilityContext,
): EventEditability {
const calIds = Object.keys(event.calendarIds ?? {});
// Subscriptions have no write path and myRights can't describe them.
if (calIds.some((id) => ctx.isSubscriptionCalendar(id))) return 'read-only';
const rights = calIds
.map((id) => ctx.calendarsById.get(id)?.myRights)
.filter((r): r is CalendarRights => Boolean(r));
// Rights not loaded (own calendars on servers that omit them): the user's
// own account is writable; a shared calendar without rights is not.
if (rights.length === 0) return event.isShared ? 'read-only' : 'editable';
// Every calendar must grant the right (a read-only member blocks edit).
const may = (k: keyof CalendarRights) => rights.every((r) => r[k]);
const isOwner =
isOrganizer(event, ctx.userCalendarAddresses) || eventHasNoOwner(event);
if (may('mayWriteAll')) return 'editable';
if (may('mayWriteOwn') && isOwner) return 'editable';
const isParticipant =
getUserParticipantId(event, ctx.userCalendarAddresses) != null;
if (may('mayRSVP') && isParticipant) return 'rsvp-only';
return 'read-only';
}
+16 -2
View File
@@ -74,6 +74,7 @@ import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID }
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda';
@@ -324,9 +325,20 @@ export default function CalendarScreen() {
setModalVisible(true);
}, []);
// Client-side iCal subscriptions mirror a remote feed into a local
// calendar; edits there would be wiped by the next sync, so they're
// read-only targets everywhere (#762).
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
const isSubscriptionCalendar = React.useCallback(
(calendarId: string) => subscriptions.some((s) => s.calendarId === calendarId),
[subscriptions],
);
const isReadOnlyEvent = React.useCallback(
(event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID],
[],
(event: CalendarEvent) =>
!!event.calendarIds?.[BIRTHDAY_CALENDAR_ID] ||
Object.keys(event.calendarIds ?? {}).some(isSubscriptionCalendar),
[isSubscriptionCalendar],
);
// Editing a series member opens the editor directly; the this/future/all
@@ -630,6 +642,7 @@ export default function CalendarScreen() {
calendars={calendars}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
isSubscriptionCalendar={isSubscriptionCalendar}
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
@@ -655,6 +668,7 @@ export default function CalendarScreen() {
calendars={eventCalendars}
defaultDate={modalDate}
currentUserEmails={currentUserEmails}
isSubscriptionCalendar={isSubscriptionCalendar}
onSave={handleSave}
onDelete={handleDeleteFromModal}
onClose={() => setModalVisible(false)}