mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show events you declined struck through in the calendar #1110
This commit is contained in:
@@ -169,4 +169,20 @@ describe('CalendarAgendaView infinite scroll', () => {
|
||||
const titles = screen.getAllByText(/^Event /).map((el) => el.textContent);
|
||||
expect(titles).toEqual(['Event sooner', 'Event later']);
|
||||
});
|
||||
|
||||
it('strikes through events the user declined (#1110)', () => {
|
||||
const invite = (id: string, status: string) => ({
|
||||
...makeEvent(id, '2026-09-12T09:00:00'),
|
||||
participants: {
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', participationStatus: status },
|
||||
},
|
||||
}) as unknown as CalendarEvent;
|
||||
renderView({
|
||||
events: [invite('declined', 'declined'), invite('accepted', 'accepted')],
|
||||
currentUserEmails: ['me@example.com'],
|
||||
});
|
||||
expect(screen.getByText('Event declined')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Event declined').closest('button')).toHaveClass('opacity-60');
|
||||
expect(screen.getByText('Event accepted')).not.toHaveClass('line-through');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { EventCard } from '../event-card';
|
||||
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
|
||||
// #1110: an invitation the user declined - the whole series or one occurrence
|
||||
// of an accepted series - stays on the calendar, struck through and dimmed
|
||||
// like a cancelled event, instead of looking like one they will attend.
|
||||
|
||||
function makeOccurrence(id: string, myStatus: CalendarParticipant['participationStatus']): CalendarEvent {
|
||||
return {
|
||||
id,
|
||||
'@type': 'Event',
|
||||
uid: 'series',
|
||||
title: 'Standup ' + id,
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT30M',
|
||||
showWithoutTime: false,
|
||||
status: 'confirmed',
|
||||
calendarIds: { 'cal-1': true },
|
||||
participants: {
|
||||
org: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.com', participationStatus: 'accepted' },
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', participationStatus: myStatus },
|
||||
},
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
const me = ['me@example.com'];
|
||||
|
||||
describe('EventCard', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it.each(['chip', 'span', 'block'] as const)('strikes through an occurrence the user declined (%s)', (variant) => {
|
||||
render(<EventCard event={makeOccurrence('a', 'declined')} variant={variant} currentUserEmails={me} />);
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveClass('opacity-60');
|
||||
expect(card.getAttribute('aria-label')).toMatch(/, participants\.declined$/);
|
||||
expect(screen.getByText('Standup a')).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('leaves accepted and unanswered occurrences alone', () => {
|
||||
render(
|
||||
<>
|
||||
<EventCard event={makeOccurrence('a', 'accepted')} variant="block" currentUserEmails={me} />
|
||||
<EventCard event={makeOccurrence('b', 'needs-action')} variant="block" currentUserEmails={me} />
|
||||
</>
|
||||
);
|
||||
for (const card of screen.getAllByRole('button')) {
|
||||
expect(card).not.toHaveClass('opacity-60');
|
||||
expect(card.getAttribute('aria-label')).not.toMatch(/declined/);
|
||||
}
|
||||
expect(screen.getByText('Standup a')).not.toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('only looks at the user\'s own participant entry', () => {
|
||||
const event = makeOccurrence('a', 'accepted');
|
||||
event.participants!.org.participationStatus = 'declined';
|
||||
render(<EventCard event={event} variant="block" currentUserEmails={me} />);
|
||||
expect(screen.getByRole('button')).not.toHaveClass('opacity-60');
|
||||
});
|
||||
|
||||
it('labels a cancelled event as cancelled even when the user declined it', () => {
|
||||
const event = { ...makeOccurrence('a', 'declined'), status: 'cancelled' } as CalendarEvent;
|
||||
render(<EventCard event={event} variant="block" currentUserEmails={me} />);
|
||||
expect(screen.getByRole('button').getAttribute('aria-label')).toMatch(/, detail\.cancelled$/);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
|
||||
import { getEventColor } from "./event-card";
|
||||
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { displayNow, isDisplayToday } from "@/lib/timezone";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
|
||||
import { useScrollWindow } from "@/hooks/use-scroll-window";
|
||||
import type { ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
@@ -22,6 +22,8 @@ interface CalendarAgendaViewProps extends ScrollWindowViewProps {
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
/** The user's calendar addresses, to mark events they declined (#1110). */
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
interface DayGroup {
|
||||
@@ -45,6 +47,7 @@ export function CalendarAgendaView({
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
timeFormat = "24h",
|
||||
currentUserEmails,
|
||||
}: CalendarAgendaViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
// Grid days / event dates are display dates (local fields = wall-clock in
|
||||
@@ -207,8 +210,9 @@ export function CalendarAgendaView({
|
||||
const start = getEventStartDate(ev);
|
||||
const end = getEventEndDate(ev);
|
||||
// iTIP CANCEL marks the attendee's copy with status "cancelled"
|
||||
// instead of deleting it (#572).
|
||||
const isCancelled = ev.status === "cancelled";
|
||||
// instead of deleting it (#572); a declined invitation stays
|
||||
// listed too (#1110).
|
||||
const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
: null;
|
||||
@@ -222,7 +226,7 @@ export function CalendarAgendaView({
|
||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||
className={cn(
|
||||
"w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start",
|
||||
isCancelled && "opacity-60"
|
||||
isInactive && "opacity-60"
|
||||
)}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
@@ -245,7 +249,7 @@ export function CalendarAgendaView({
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
|
||||
<div className={cn("text-sm font-medium truncate", isInactive && "line-through")}>
|
||||
{ev.title || t("events.no_title")}
|
||||
</div>
|
||||
{locationName && (
|
||||
|
||||
@@ -1494,6 +1494,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
tasks={calendarTasks}
|
||||
onToggleTaskComplete={handleToggleTaskComplete}
|
||||
onSelectTask={openEditTaskModal}
|
||||
currentUserEmails={currentUserEmails}
|
||||
/>
|
||||
);
|
||||
case "week":
|
||||
@@ -1517,6 +1518,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
tasks={calendarTasks}
|
||||
onToggleTaskComplete={handleToggleTaskComplete}
|
||||
onSelectTask={openEditTaskModal}
|
||||
currentUserEmails={currentUserEmails}
|
||||
/>
|
||||
);
|
||||
case "day":
|
||||
@@ -1538,6 +1540,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
tasks={calendarTasks}
|
||||
onToggleTaskComplete={handleToggleTaskComplete}
|
||||
onSelectTask={openEditTaskModal}
|
||||
currentUserEmails={currentUserEmails}
|
||||
/>
|
||||
);
|
||||
case "agenda":
|
||||
@@ -1551,6 +1554,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
/>
|
||||
);
|
||||
case "tasks":
|
||||
|
||||
@@ -33,6 +33,8 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
onSelectTask?: (task: CalendarTask) => void;
|
||||
/** The user's calendar addresses, to mark events they declined (#1110). */
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
@@ -63,6 +65,7 @@ export function CalendarDayView({
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
onSelectTask,
|
||||
currentUserEmails,
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
// Grid days / event dates are display dates (local fields = wall-clock in
|
||||
@@ -328,6 +331,7 @@ export function CalendarDayView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -456,6 +460,7 @@ export function CalendarDayView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventCard } from "./event-card";
|
||||
import { CalendarTaskChip } from "./task-chip";
|
||||
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
|
||||
import { isDeclinedByUser } from "@/lib/calendar-participants";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -35,6 +36,8 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
onSelectTask?: (task: CalendarTask) => void;
|
||||
/** The user's calendar addresses, to mark events they declined (#1110). */
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
/** Fraction of the viewport height at which the "current month" is sampled. */
|
||||
@@ -64,6 +67,7 @@ export function CalendarMonthView({
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
onSelectTask,
|
||||
currentUserEmails,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||
@@ -344,10 +348,11 @@ export function CalendarMonthView({
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||
const inactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
|
||||
return (
|
||||
<span
|
||||
key={ev.id}
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
className={cn("w-1.5 h-1.5 rounded-full", inactive && "opacity-40")}
|
||||
style={{ backgroundColor: evColor }}
|
||||
/>
|
||||
);
|
||||
@@ -436,6 +441,7 @@ export function CalendarMonthView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
draggable
|
||||
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
onSelectTask?: (task: CalendarTask) => void;
|
||||
/** The user's calendar addresses, to mark events they declined (#1110). */
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 60;
|
||||
@@ -70,6 +72,7 @@ export function CalendarWeekView({
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
onSelectTask,
|
||||
currentUserEmails,
|
||||
}: CalendarWeekViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
// Grid days / event dates are display dates (local fields = wall-clock in
|
||||
@@ -361,6 +364,7 @@ export function CalendarWeekView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -513,6 +517,7 @@ export function CalendarWeekView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { format } from "date-fns";
|
||||
import { Users } from "@/components/icons";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
@@ -24,6 +24,8 @@ interface EventCardProps {
|
||||
continuesAfter?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** The user's calendar addresses, to tell whether they declined the event. */
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
|
||||
@@ -71,7 +73,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
||||
return el;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style, currentUserEmails }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
@@ -89,9 +91,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
};
|
||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
||||
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
|
||||
// deleting it (#572) - render it struck through and dimmed.
|
||||
// deleting it (#572), and a declined invitation stays on the calendar too
|
||||
// (#1110) - render both struck through and dimmed.
|
||||
const isCancelled = event.status === "cancelled";
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
|
||||
const isDeclined = !isCancelled && isDeclinedByUser(event, currentUserEmails);
|
||||
const isInactive = isCancelled || isDeclined;
|
||||
const statusLabel = isCancelled ? t("detail.cancelled") : isDeclined ? t("participants.declined") : null;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -139,7 +145,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"hover:opacity-80 transition-opacity",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
isCancelled && !isBeingDragged && "opacity-60",
|
||||
isInactive && !isBeingDragged && "opacity-60",
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: `${color}20`, color, ...style }}
|
||||
@@ -148,7 +154,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
<span className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -169,7 +175,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
continuesAfter && "pe-2",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
isCancelled && !isBeingDragged && "opacity-60",
|
||||
isInactive && !isBeingDragged && "opacity-60",
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||
@@ -178,7 +184,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
{showTimeInMonthView && !event.showWithoutTime && (
|
||||
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
||||
)}
|
||||
<span className={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
<span className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -198,12 +204,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
isCancelled && !isBeingDragged && "opacity-60",
|
||||
isInactive && !isBeingDragged && "opacity-60",
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||
>
|
||||
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
|
||||
<div className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</div>
|
||||
{!event.showWithoutTime && (
|
||||
<div className="opacity-80 text-[10px]">
|
||||
{timeString}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
collectUserCalendarAddresses,
|
||||
getUserParticipantId,
|
||||
getUserStatus,
|
||||
isDeclinedByUser,
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
getParticipantCount,
|
||||
@@ -284,6 +285,29 @@ describe('getUserStatus', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDeclinedByUser', () => {
|
||||
it("is true only for the user's own declined entry", () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant, dave: declinedAttendee });
|
||||
expect(isDeclinedByUser(event, ['Dave@Example.com'])).toBe(true);
|
||||
expect(isDeclinedByUser(event, ['bob@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('matches the calendar address an occurrence override carries', () => {
|
||||
// A reply to one occurrence comes back from Stalwart as an override
|
||||
// participant with only a calendarAddress (#1110).
|
||||
const event = makeEvent({
|
||||
att: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', participationStatus: 'declined' },
|
||||
});
|
||||
expect(isDeclinedByUser(event, ['alias@example.com', 'bob@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('is false without user addresses or participants', () => {
|
||||
expect(isDeclinedByUser(makeEvent({ dave: declinedAttendee }), [])).toBe(false);
|
||||
expect(isDeclinedByUser(makeEvent({ dave: declinedAttendee }), undefined)).toBe(false);
|
||||
expect(isDeclinedByUser(makeEvent(null), ['dave@example.com'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParticipantList', () => {
|
||||
it('returns all participants as info objects', () => {
|
||||
const event = makeEvent({
|
||||
|
||||
@@ -123,6 +123,15 @@ export function getUserStatus(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user declined this event. An occurrence carries its override's
|
||||
* participants, so one declined instance of an accepted series counts (#1110).
|
||||
*/
|
||||
export function isDeclinedByUser(event: CalendarEvent, userEmails: string[] | undefined): boolean {
|
||||
if (!userEmails || userEmails.length === 0) return false;
|
||||
return getUserStatus(event, userEmails) === 'declined';
|
||||
}
|
||||
|
||||
/** When the same address appears in two participant entries, a real RSVP on
|
||||
* either one beats a missing/"needs-action" one — the duplicate is always the
|
||||
* entry that never replied (the needs-action side). Between two real replies
|
||||
|
||||
Reference in New Issue
Block a user