diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index 9902d3a3..adb90685 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -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:` — 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:`; 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 `` (`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. diff --git a/src/api/types.ts b/src/api/types.ts index 23d9fc3d..f03cb741 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 { diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index a9d04fb4..ed0175b8 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -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, diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 63f0be06..d5792314 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -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({ )} - - {onEdit && ( - } - label="Edit" - onPress={() => onEdit(event)} - /> - )} - {onDuplicate && ( - } - label="Duplicate" - onPress={() => onDuplicate(event)} - /> - )} - {onDelete && ( - } - label="Delete" - onPress={() => onDelete(event)} - destructive - /> - )} - + {(canEdit || onDuplicate) && ( + + {onEdit && canEdit && ( + } + label="Edit" + onPress={() => onEdit(event)} + /> + )} + {onDuplicate && ( + } + label="Duplicate" + onPress={() => onDuplicate(event)} + /> + )} + {onDelete && canEdit && ( + } + label="Delete" + onPress={() => onDelete(event)} + destructive + /> + )} + + )} diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 26876b18..f1b8407f 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -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, calendarId: string) => void | Promise; 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(() => 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(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({ {calendarOpen && ( - {calendars.map((c) => ( + {selectableCalendars.map((c) => ( calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false), + () => calendars.filter((cal) => isWritableCalendar(cal)), [calendars], ); diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 58b040a5..94e3ecf4 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -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], ); diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 7aaa24d0..2bd2722a 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -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; diff --git a/src/lib/__tests__/calendar-editability.test.ts b/src/lib/__tests__/calendar-editability.test.ts new file mode 100644 index 00000000..15e8c5b1 --- /dev/null +++ b/src/lib/__tests__/calendar-editability.test.ts @@ -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); + }); +}); diff --git a/src/lib/birthday-calendar.ts b/src/lib/birthday-calendar.ts index f4363ee9..e8a568f1 100644 --- a/src/lib/birthday-calendar.ts +++ b/src/lib/birthday-calendar.ts @@ -19,7 +19,6 @@ export function createBirthdayCalendar(name = 'Birthdays'): Calendar { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, - mayWrite: false, mayDelete: false, mayRSVP: false, }, diff --git a/src/lib/calendar-editability.ts b/src/lib/calendar-editability.ts new file mode 100644 index 00000000..ab8d47b7 --- /dev/null +++ b/src/lib/calendar-editability.ts @@ -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>; + /** 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): 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, + 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'; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 4135e6ff..62e02412 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -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)}