+
{ev.title || t("events.no_title")}
{locationName && (
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index c7b21de67..9bde310bf 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -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":
diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx
index 400a8edeb..7cf86fcab 100644
--- a/components/calendar/calendar-day-view.tsx
+++ b/components/calendar/calendar-day-view.tsx
@@ -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
/>
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 (
);
@@ -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}
/>
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx
index 093f65e4d..733bb2a5d 100644
--- a/components/calendar/calendar-week-view.tsx
+++ b/components/calendar/calendar-week-view.tsx
@@ -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}
/>
);
@@ -513,6 +517,7 @@ export function CalendarWeekView({
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
+ currentUserEmails={currentUserEmails}
draggable
/>
{
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 }}
/>
- {event.title || t("events.no_title")}
+ {event.title || t("events.no_title")}
);
}
@@ -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 && (
{format(startDate, timeFmt)}
)}
- {event.title || t("events.no_title")}
+ {event.title || t("events.no_title")}
);
@@ -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 }}
>
-
{event.title || t("events.no_title")}
+
{event.title || t("events.no_title")}
{!event.showWithoutTime && (
{timeString}
diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts
index ea340e93f..9169d9660 100644
--- a/lib/__tests__/calendar-participants.test.ts
+++ b/lib/__tests__/calendar-participants.test.ts
@@ -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({
diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts
index d7eae1066..25ebb9ffd 100644
--- a/lib/calendar-participants.ts
+++ b/lib/calendar-participants.ts
@@ -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