fix: rsvp controls

* adds "canRSVP" in addition to the existing "getEventEditability"
* wires calendar to leverage "canRSVP" before editability checks
* adds tests for RSVP
This commit is contained in:
wrycu
2026-09-03 22:12:15 -07:00
parent 6632834767
commit 3a3ebc9155
4 changed files with 208 additions and 46 deletions
+18 -7
View File
@@ -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<EditabilityContext>(() => {
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 && (
<div className="px-4 py-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("participants.rsvp_label")}
+75 -39
View File
@@ -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({
</div>
<div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
</div>
<RsvpBar status={userCurrentStatus} onRespond={handleRsvp} t={t} />
</div>
</div>
);
@@ -997,6 +971,16 @@ export function EventModal({
</div>
</div>
{/* 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 && (
<div className="px-6 py-3 border-t border-border flex-shrink-0">
<RsvpBar status={userCurrentStatus} onRespond={handleRsvp} t={t} />
</div>
)}
{/* Action Bar */}
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between">
<div className="flex items-center gap-1">
@@ -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<typeof useTranslations>;
}) {
return (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={status === "accepted" ? "default" : "outline"}
onClick={() => onRespond("accepted")}
className={status === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{status === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={status === "tentative" ? "default" : "outline"}
onClick={() => onRespond("tentative")}
className={status === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{status === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={status === "declined" ? "default" : "ghost"}
onClick={() => onRespond("declined")}
className={status === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{status === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
</div>
);
}
function StatusBadge({ status, isOrganizer, t }: {
status: CalendarParticipant['participationStatus'];
isOrganizer: boolean;
@@ -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);
});
});
+30
View File
@@ -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;
}