From 3a3ebc9155e059a80cc0d0fdff8290025ccb527b Mon Sep 17 00:00:00 2001 From: wrycu <4709746+wrycu@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:12:15 -0700 Subject: [PATCH] fix: rsvp controls * adds "canRSVP" in addition to the existing "getEventEditability" * wires calendar to leverage "canRSVP" before editability checks * adds tests for RSVP --- components/calendar/event-detail-popover.tsx | 25 ++-- components/calendar/event-modal.tsx | 114 ++++++++++++------- lib/__tests__/calendar-editability.test.ts | 85 ++++++++++++++ lib/calendar-editability.ts | 30 +++++ 4 files changed, 208 insertions(+), 46 deletions(-) diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index d0b0389bf..0071f2259 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -19,7 +19,7 @@ import { getUserStatus, getParticipantList, } from "@/lib/calendar-participants"; -import { getEventEditability } from "@/lib/calendar-editability"; +import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; interface EventDetailPopoverProps { @@ -159,16 +159,27 @@ export function EventDetailPopover({ const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]); // Gate affordances on calendar rights, not identity (see calendar-editability). - const editability = useMemo(() => { + const editabilityCtx = useMemo(() => { const calendarsById = new Map(calendar ? [[calendar.id, calendar]] : []); - return getEventEditability(event, { + return { calendarsById, userCalendarAddresses: currentUserEmails, isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false), - }); - }, [event, calendar, currentUserEmails, isSubscriptionCalendar]); + }; + }, [calendar, currentUserEmails, isSubscriptionCalendar]); + + const editability = useMemo( + () => getEventEditability(event, editabilityCtx), + [event, editabilityCtx] + ); const canEditBody = editability === "editable"; - const rsvpMode = editability === "rsvp-only"; + + // Asked separately from editability: a received invite lands in the user's own + // calendar and resolves to 'editable', which renders no RSVP bar (#937). + const canRsvp = useMemo( + () => canUserRsvp(event, editabilityCtx), + [event, editabilityCtx] + ); const userParticipantId = useMemo( () => getUserParticipantId(event, currentUserEmails), @@ -537,7 +548,7 @@ export function EventDetailPopover({ )} {/* RSVP Bar (for attendees) */} - {rsvpMode && onRsvp && userParticipantId && ( + {canRsvp && onRsvp && userParticipantId && (

{t("participants.rsvp_label")} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 4c8f74c23..5dc4d8ae5 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -22,7 +22,7 @@ import { getStatusCounts, buildParticipantMap, } from "@/lib/calendar-participants"; -import { getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability"; +import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; @@ -234,6 +234,17 @@ export function EventModal({ const canEditBody = editability === "editable"; const rsvpMode = editability === "rsvp-only"; + // A received invite lands in the user's own calendar, resolves to 'editable', + // and so never reaches the rsvp-only view below. + const canRsvp = useMemo(() => { + if (!event) return false; + return canUserRsvp(event, { + calendarsById: new Map(calendars.map((c) => [c.id, c])), + userCalendarAddresses: currentUserEmails, + isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false), + }); + }, [event, calendars, currentUserEmails, isSubscriptionCalendar]); + const userParticipantId = useMemo(() => { if (!event) return null; return getUserParticipantId(event, currentUserEmails); @@ -786,44 +797,7 @@ export function EventModal({

-
- {t("participants.rsvp_label")} -
- - - -
-
+
); @@ -997,6 +971,16 @@ export function EventModal({ + {/* RSVP: + The viewer owes a reply but may also edit their own copy, + so the rsvp-only branch above does not apply to this event. + */} + {canRsvp && onRsvp && userParticipantId && ( +
+ +
+ )} + {/* Action Bar */}
@@ -1443,6 +1427,58 @@ export function EventModal({ ); } +/** + * The accept / tentative / decline row. Shared by the rsvp-only view and by the + * read-only view that an *editable* received invite lands in - the latter is the + * case that had no way to answer an invite at all (#937). + */ +function RsvpBar({ status, onRespond, t }: { + status: CalendarParticipant['participationStatus'] | null; + onRespond: (status: CalendarParticipant['participationStatus']) => void; + t: ReturnType; +}) { + return ( +
+ {t("participants.rsvp_label")} +
+ + + +
+
+ ); +} + function StatusBadge({ status, isOrganizer, t }: { status: CalendarParticipant['participationStatus']; isOrganizer: boolean; diff --git a/lib/__tests__/calendar-editability.test.ts b/lib/__tests__/calendar-editability.test.ts index b1c7d5147..f7d89fda7 100644 --- a/lib/__tests__/calendar-editability.test.ts +++ b/lib/__tests__/calendar-editability.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import type { CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types'; import { getEventEditability, + canUserRsvp, canCreateEventsIn, eventHasNoOwner, type EventEditability, @@ -260,3 +261,87 @@ describe('getEventEditability - matrix sanity', () => { expect(getEventEditability(ev, ctx(rights))).toBe(expected); }); }); + +/** + * Stalwart shape: participants carry only `calendarAddress` (no email/sendTo) + * and the organizer is additionally named by `organizerCalendarAddress`. + */ +function receivedInvite(attendeeAddress = SELF): CalendarEvent { + return makeEvent({ + isOrigin: false, + organizerCalendarAddress: `mailto:${OTHER}`, + participants: { + att: { + '@type': 'Participant', calendarAddress: `mailto:${attendeeAddress}`, + participationStatus: 'needs-action', expectReply: true, + roles: { attendee: true, required: true }, + }, + org: { + '@type': 'Participant', calendarAddress: `mailto:${OTHER}`, + participationStatus: 'accepted', + roles: { owner: true, required: true }, + }, + } as never, + }); +} + +describe('canUserRsvp (issue #937)', () => { + it('is true for an externally organized invite in the user\'s own calendar', () => { + expect(canUserRsvp(receivedInvite(), ctx(ALL_RIGHTS))).toBe(true); + }); + + it('leaves the 1.9 rights-first editability of that invite untouched', () => { + // Must not regress 1.9 functionality + expect(getEventEditability(receivedInvite(), ctx(ALL_RIGHTS))).toBe('editable'); + }); + + it('is true when the invited address is an account alias', () => { + expect(canUserRsvp(receivedInvite(ALIAS), ctx(ALL_RIGHTS, [SELF, ALIAS]))).toBe(true); + }); + + it('agrees with the rsvp-only editability state', () => { + const ev = receivedInvite(); + expect(getEventEditability(ev, ctx(RSVP_ONLY))).toBe('rsvp-only'); + expect(canUserRsvp(ev, ctx(RSVP_ONLY))).toBe(true); + }); + + it('is false for the organizer of their own event', () => { + const ev = makeEvent({ organizerCalendarAddress: `mailto:${SELF}`, + participants: { a: owner(SELF), b: attendee(OTHER) } as never }); + expect(canUserRsvp(ev, ctx(ALL_RIGHTS))).toBe(false); + }); + + it('is false when the user is not a participant at all', () => { + expect(canUserRsvp(receivedInvite(OTHER), ctx(ALL_RIGHTS, ['dave@example.com']))).toBe(false); + }); + + it('is false for a plain event with no organizer or participants', () => { + expect(canUserRsvp(makeEvent(), ctx(ALL_RIGHTS))).toBe(false); + }); + + it('is false without mayRSVP on the calendar', () => { + expect(canUserRsvp(receivedInvite(), ctx(READ_ONLY))).toBe(false); + }); + + it('is false in an iCal subscription', () => { + expect(canUserRsvp(receivedInvite(), ctx(ALL_RIGHTS, [SELF], ['cal1']))).toBe(false); + }); + + it('is false while the calendar rights are still unloaded', () => { + expect(canUserRsvp(receivedInvite(), ctx(null))).toBe(false); + }); + + it('requires mayRSVP on every calendar the event belongs to', () => { + const ev = receivedInvite(); + ev.calendarIds = { cal1: true, cal2: true }; + const context: EditabilityContext = { + calendarsById: new Map([ + ['cal1', { myRights: ALL_RIGHTS }], + ['cal2', { myRights: READ_ONLY }], + ]), + userCalendarAddresses: [SELF], + isSubscriptionCalendar: () => false, + }; + expect(canUserRsvp(ev, context)).toBe(false); + }); +}); diff --git a/lib/calendar-editability.ts b/lib/calendar-editability.ts index bfe8c9a97..7aa34035f 100644 --- a/lib/calendar-editability.ts +++ b/lib/calendar-editability.ts @@ -74,3 +74,33 @@ export function getEventEditability( return 'read-only'; } + +/** + * Whether the viewer may RSVP to an event. + * + * Required because a received invite is delivered into the user's own calendar, + * which generally has mayWriteAll as `true` ([#937]). + */ +export function canUserRsvp( + event: CalendarEvent, + ctx: EditabilityContext, +): boolean { + const calIds = Object.keys(event.calendarIds ?? {}); + + // Subscriptions are a one-way copy; there is no scheduling path to reply on. + if (calIds.some((id) => ctx.isSubscriptionCalendar(id))) return false; + + const rights = calIds + .map((id) => ctx.calendarsById.get(id)?.myRights) + .filter((r): r is CalendarRights => Boolean(r)); + + // Rights not loaded: stay quiet rather than offer a reply that may be refused. + if (rights.length === 0) return false; + if (!rights.every((r) => r.mayRSVP)) return false; + + // The organizer does not reply to their own invite, and an ownerless event carries no scheduling request at all. + if (isOrganizer(event, ctx.userCalendarAddresses)) return false; + if (eventHasNoOwner(event)) return false; + + return getUserParticipantId(event, ctx.userCalendarAddresses) != null; +}