mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
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:
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user