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 1/3] 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; +} From 8cbc5bc53925aeb00599ed6bbe0d8518aa57c88d Mon Sep 17 00:00:00 2001 From: ibayue <11729209+ibayue@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:46:03 +0800 Subject: [PATCH 2/3] fix(calendar): dedupe participants, resolve contact names, show organizer status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs when viewing an event's participants (#738): 1. Phantom participant. Stalwart can emit the organizer both as the ORGANIZER-derived participant and as an ATTENDEE line, so the same address rendered twice — "4 participants" for three people. getParticipantList() now folds same-address entries into the first-seen one (real RSVP beats needs-action on merge), and getParticipantCount() counts addresses so event-card and agenda badges agree. 2. Bare email for a known contact. The organizer's display name is dropped on Stalwart's iCalendar round-trip, so the organizer rendered as a bare address despite having a contact card with a 备注 name. New useContactNameResolver() hook fills missing participant names from the contact store (fill-only — event data stays authoritative), threaded through the panel, the hover popover, the RSVP view, and the organizer name written on save. 3. Organizer status. The organizer with no explicit status read as 待处理; they owe no reply, so they now display as accepted, and the panel's status badge no longer repeats the organizer label the (组织者) suffix already carries. Co-Authored-By: Claude --- components/calendar/event-detail-popover.tsx | 10 +- components/calendar/event-modal.tsx | 40 +++--- .../use-contact-name-resolver.test.ts | 92 ++++++++++++++ hooks/use-contact-name-resolver.ts | 47 ++++++++ lib/__tests__/calendar-participants.test.ts | 114 ++++++++++++++++++ lib/calendar-participants.ts | 83 ++++++++++++- 6 files changed, 365 insertions(+), 21 deletions(-) create mode 100644 hooks/__tests__/use-contact-name-resolver.test.ts create mode 100644 hooks/use-contact-name-resolver.ts diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index d0b0389bf..4f125860a 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -21,6 +21,7 @@ import { } from "@/lib/calendar-participants"; import { getEventEditability } from "@/lib/calendar-editability"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; +import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -136,6 +137,10 @@ export function EventDetailPopover({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isSavingNote, setIsSavingNote] = useState(false); + // Bare addresses (the organizer above all — Stalwart drops its display name) + // render with the contact card's name instead of the raw email. + const resolveContactName = useContactNameResolver(); + const color = getEventColor(event, calendar); const startDate = getEventStartDate(event); const durationMinutes = parseDuration(event.duration); @@ -154,7 +159,10 @@ export function EventDetailPopover({ return first?.uri || null; }, [event.virtualLocations]); - const participants = useMemo(() => getParticipantList(event), [event]); + const participants = useMemo( + () => getParticipantList(event, { resolveName: resolveContactName }), + [event, resolveContactName] + ); const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]); const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 4c8f74c23..26b7b7474 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -28,6 +28,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; +import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { calendarHooks } from "@/lib/plugin-hooks"; import type { ConflictWarning } from "@/lib/plugin-types"; @@ -234,6 +235,10 @@ export function EventModal({ const canEditBody = editability === "editable"; const rsvpMode = editability === "rsvp-only"; + // Bare addresses (the organizer above all — Stalwart drops its display name) + // render with the contact card's name instead of the raw email. + const resolveContactName = useContactNameResolver(); + const userParticipantId = useMemo(() => { if (!event) return null; return getUserParticipantId(event, currentUserEmails); @@ -246,8 +251,8 @@ export function EventModal({ const existingParticipants = useMemo(() => { if (!event) return []; - return getParticipantList(event); - }, [event]); + return getParticipantList(event, { resolveName: resolveContactName }); + }, [event, resolveContactName]); const organizerInfo = useMemo(() => { if (!event?.participants) return null; @@ -588,7 +593,10 @@ export function EventModal({ if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) { const organizerEmail = currentUserEmails[0]; - const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || ""; + // On create there are no existing participants, so a fresh event would + // store an empty organizer name — fall back to the contact card. + const organizerName = + existingParticipants.find(p => p.isOrganizer)?.name || resolveContactName(organizerEmail) || ""; data.participants = buildParticipantMap( { name: organizerName, email: organizerEmail }, effectiveAttendees @@ -612,7 +620,7 @@ export function EventModal({ } finally { setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]); const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { if (!event || !userParticipantId || !onRsvp) return; @@ -696,7 +704,7 @@ export function EventModal({ const startD = getEventStartDate(event); const endD = getEventEndDate(event); const locationName = event.locations ? Object.values(event.locations)[0]?.name : null; - const participants = getParticipantList(event); + const participants = getParticipantList(event, { resolveName: resolveContactName }); return (
@@ -775,8 +783,13 @@ export function EventModal({
{participants.map(p => (
- {p.name || p.email} - + + {p.name || p.email} + {p.isOrganizer && ( + ({t("participants.organizer").toLowerCase()}) + )} + +
))}
@@ -836,7 +849,7 @@ export function EventModal({ const endD = getEventEndDate(event); const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null; const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; - const viewParticipants = getParticipantList(event); + const viewParticipants = getParticipantList(event, { resolveName: resolveContactName }); const recurrenceLabel = getRecurrenceLabel(event, t, locale); const alertLabel = getAlertLabel(event, t); const eventCalendar = calendars.find(c => event.calendarIds[c.id]); @@ -963,7 +976,7 @@ export function EventModal({ ({t("participants.organizer").toLowerCase()}) )} - +
))}
@@ -1443,14 +1456,13 @@ export function EventModal({ ); } -function StatusBadge({ status, isOrganizer, t }: { +function StatusBadge({ status, t }: { status: CalendarParticipant['participationStatus']; - isOrganizer: boolean; t: ReturnType; }) { - if (isOrganizer) { - return {t("participants.organizer")}; - } + // The organizer is marked by the "(组织者)" suffix on the name; the badge + // shows their participation status (organizers default to accepted) rather + // than repeating the organizer label. const colors: Record = { accepted: "text-success", declined: "text-destructive", diff --git a/hooks/__tests__/use-contact-name-resolver.test.ts b/hooks/__tests__/use-contact-name-resolver.test.ts new file mode 100644 index 000000000..940482283 --- /dev/null +++ b/hooks/__tests__/use-contact-name-resolver.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { buildContactNameResolver } from '../use-contact-name-resolver'; +import type { ContactCard } from '@/lib/jmap/types'; + +/** + * The resolver backs participant rendering (#738): a bare address in the + * event data must display the contact card's name (e.g. its 备注 name), while + * names already carried by the event stay authoritative — the resolver only + * ever fills, never replaces. + */ + +function makeContact( + emails: string[], + name?: string, + overrides: Partial = {} +): ContactCard { + return { + id: Math.random().toString(36).slice(2), + addressBookIds: { ab1: true }, + emails: Object.fromEntries( + emails.map((address, i) => [`${i}`, { address }]) + ), + ...(name ? { name: { full: name } } : {}), + ...overrides, + } as ContactCard; +} + +describe('buildContactNameResolver', () => { + it('maps every address of a contact to its display name', () => { + const resolver = buildContactNameResolver([ + makeContact(['alice@example.com', 'alice.smith@example.com'], '爱丽丝'), + ]); + expect(resolver('alice@example.com')).toBe('爱丽丝'); + expect(resolver('alice.smith@example.com')).toBe('爱丽丝'); + }); + + it('matches addresses case-insensitively and trims whitespace', () => { + const resolver = buildContactNameResolver([makeContact(['alice@example.com'], 'Alice')]); + expect(resolver('ALICE@EXAMPLE.COM')).toBe('Alice'); + expect(resolver(' alice@example.com ')).toBe('Alice'); + }); + + it('keeps the first contact when several hold the same address', () => { + const resolver = buildContactNameResolver([ + makeContact(['bob@example.com'], 'First Bob'), + makeContact(['bob@example.com'], 'Second Bob'), + ]); + expect(resolver('bob@example.com')).toBe('First Bob'); + }); + + it('prefers name components over name.full, like getContactDisplayName', () => { + const resolver = buildContactNameResolver([ + makeContact(['carol@example.com'], undefined, { + name: { + components: [ + { kind: 'given', value: '王' }, + { kind: 'surname', value: '小' }, + ], + isOrdered: true, + }, + }), + ]); + expect(resolver('carol@example.com')).toBe('王 小'); + }); + + it('skips contacts without a resolvable name', () => { + const resolver = buildContactNameResolver([ + makeContact(['noname@example.com']), // only an email on the card + ]); + expect(resolver('noname@example.com')).toBeUndefined(); + }); + + it('returns undefined for unknown or blank addresses', () => { + const resolver = buildContactNameResolver([makeContact(['alice@example.com'], 'Alice')]); + expect(resolver('unknown@example.com')).toBeUndefined(); + expect(resolver('')).toBeUndefined(); + expect(resolver(' ')).toBeUndefined(); + }); + + it('handles an empty contact list', () => { + const resolver = buildContactNameResolver([]); + expect(resolver('alice@example.com')).toBeUndefined(); + }); + + it('ignores blank addresses on cards', () => { + const resolver = buildContactNameResolver([ + makeContact(['alice@example.com'], 'Alice'), + makeContact([' '], 'Blank'), + ]); + expect(resolver('alice@example.com')).toBe('Alice'); + }); +}); diff --git a/hooks/use-contact-name-resolver.ts b/hooks/use-contact-name-resolver.ts new file mode 100644 index 000000000..620b2f03e --- /dev/null +++ b/hooks/use-contact-name-resolver.ts @@ -0,0 +1,47 @@ +"use client"; + +import { useMemo } from "react"; +import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; +import type { ContactCard } from "@/lib/jmap/types"; + +/** + * Build an email → display-name lookup from contact cards. The first contact + * holding an address wins; cards with no resolvable name contribute nothing, + * so whatever name the event itself carries always stays authoritative and + * the contact card is a pure fallback for bare addresses. + */ +export function buildContactNameResolver( + contacts: ReadonlyArray +): (email: string) => string | undefined { + const namesByEmail = new Map(); + for (const contact of contacts) { + const name = getContactDisplayName(contact).trim(); + if (!name || !contact.emails) continue; + // getContactDisplayName falls back to the first email address, so a card + // whose "name" is just one of its addresses has nothing to add over the + // bare address every renderer already shows. + const nameIsJustAnAddress = Object.values(contact.emails).some( + (e) => e.address?.trim().toLowerCase() === name.toLowerCase() + ); + if (nameIsJustAnAddress) continue; + for (const { address } of Object.values(contact.emails)) { + const key = address?.trim().toLowerCase(); + if (key && !namesByEmail.has(key)) namesByEmail.set(key, name); + } + } + return (email: string) => { + const key = email?.trim().toLowerCase(); + return key ? namesByEmail.get(key) : undefined; + }; +} + +/** + * Participant rows (calendar, and any address-rendering UI) show a contact's + * display name — e.g. its 备注 name — when the event data itself carries only + * a bare address. Memoised on the contact list, so it recomputes only when + * contacts actually change. + */ +export function useContactNameResolver(): (email: string) => string | undefined { + const contacts = useContactStore((s) => s.contacts); + return useMemo(() => buildContactNameResolver(contacts), [contacts]); +} diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index 0b287ae5a..ea340e93f 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -351,6 +351,100 @@ describe('getParticipantList', () => { ); expect(getParticipantList(event)[0].isOrganizer).toBe(true); }); + + // #738: the organizer arrives twice — once as the participant our client + // wrote (owner role, accepted) and once as an ATTENDEE line the server's + // scheduling added — so every list showed a phantom fourth participant + // still "awaiting" a reply. + it('renders the organizer once when the server also emits them as an attendee', () => { + const event = makeEvent( + { + org: { ...orgParticipant, name: '' }, + att1: attendeeParticipant, + att2: { ...attendeeParticipant, name: 'Carol', email: 'carol@example.com' }, + orgDup: { ...attendeeParticipant, name: '', email: 'alice@example.com', sendTo: { imip: 'mailto:alice@example.com' } }, + }, + { organizerCalendarAddress: 'mailto:alice@example.com' } + ); + const list = getParticipantList(event); + expect(list).toHaveLength(3); + const alices = list.filter(p => p.email === 'alice@example.com'); + expect(alices).toHaveLength(1); + expect(alices[0]).toMatchObject({ isOrganizer: true, status: 'accepted' }); + }); + + it('keeps the real RSVP when the duplicate is the needs-action entry', () => { + // Reverse insertion order: the spurious attendee entry comes first with + // no reply, the owner entry (accepted) second. + const event = makeEvent( + { + att1: { ...attendeeParticipant, name: '', email: 'alice@example.com' }, + org: orgParticipant, + }, + { organizerCalendarAddress: 'mailto:alice@example.com' } + ); + const list = getParticipantList(event); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ isOrganizer: true, status: 'accepted', name: 'Alice' }); + }); + + it('deduplicates plain attendees regardless of insertion order', () => { + const event = makeEvent({ + att1: { ...attendeeParticipant, name: 'Bob' }, + att2: { ...attendeeParticipant, name: 'Bobby', participationStatus: 'accepted' }, + att3: { ...attendeeParticipant, name: 'Carol', email: 'carol@example.com' }, + }); + const list = getParticipantList(event); + expect(list).toHaveLength(2); + const bobs = list.filter(p => p.email === 'bob@example.com'); + expect(bobs).toHaveLength(1); + expect(bobs[0].status).toBe('accepted'); + expect(bobs[0].name).toBe('Bob'); // first-seen name wins + }); + + it('merges duplicate addresses case-insensitively', () => { + const event = makeEvent({ + org: { ...orgParticipant, email: 'Alice@Example.com' }, + orgDup: { ...attendeeParticipant, name: '', email: 'alice@example.com' }, + }); + expect(getParticipantList(event)).toHaveLength(1); + }); + + // The organizer owes no reply to their own invitation; the statusless + // participant Stalwart derives from ORGANIZER must not read as "pending". + it('shows the statusless organizer as accepted', () => { + const event = makeEvent( + { org: stalwartOrganizerParticipant }, + { organizerCalendarAddress: 'mailto:alice@example.com' } + ); + const list = getParticipantList(event); + expect(list[0].isOrganizer).toBe(true); + expect(list[0].status).toBe('accepted'); + }); + + it('fills missing names from the resolveName option without overriding event data', () => { + const event = makeEvent({ + org: { ...orgParticipant, name: '' }, + att1: attendeeParticipant, + }); + const resolveName = (email: string) => + email === 'alice@example.com' ? '爱丽丝' : undefined; + const list = getParticipantList(event, { resolveName }); + expect(list.find(p => p.email === 'alice@example.com')!.name).toBe('爱丽丝'); + // Bob has a name in the event; the resolver must not clobber it (it + // returns undefined for him anyway, but the contract is "fill, not replace"). + expect(list.find(p => p.email === 'bob@example.com')!.name).toBe('Bob'); + }); + + it('keeps entries without an address unmerged', () => { + const event = makeEvent({ + org: orgParticipant, + ghost1: { '@type': 'Participant', name: 'No Address', participationStatus: 'needs-action' }, + ghost2: { '@type': 'Participant', name: 'Also No Address', participationStatus: 'accepted' }, + }); + const list = getParticipantList(event); + expect(list).toHaveLength(3); + }); }); describe('getStatusCounts', () => { @@ -402,6 +496,26 @@ describe('getParticipantCount', () => { const event = makeEvent(null); expect(getParticipantCount(event)).toBe(0); }); + + // #738: the participant badge on event cards counted raw entries, so an + // organizer the server emits twice showed "4 participants" for three people. + it('counts addresses, not raw entries', () => { + const event = makeEvent({ + org: { ...orgParticipant, name: '' }, + att1: attendeeParticipant, + att2: { ...attendeeParticipant, name: 'Carol', email: 'carol@example.com' }, + orgDup: { ...attendeeParticipant, name: '', email: 'alice@example.com' }, + }); + expect(getParticipantCount(event)).toBe(3); + }); + + it('counts address-less entries individually', () => { + const event = makeEvent({ + org: orgParticipant, + ghost: { '@type': 'Participant', name: 'No Address' }, + }); + expect(getParticipantCount(event)).toBe(2); + }); }); describe('buildParticipantMap', () => { diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index 1f0d32434..d7eae1066 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -123,22 +123,80 @@ export function getUserStatus( return null; } -export function getParticipantList(event: CalendarEvent): ParticipantInfo[] { +/** 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 + * the first-seen entry wins; such conflicts do not occur in practice. */ +function betterStatus( + current: CalendarParticipant['participationStatus'] | undefined, + incoming: CalendarParticipant['participationStatus'] | undefined +): CalendarParticipant['participationStatus'] { + if (current === 'needs-action' && incoming && incoming !== 'needs-action') return incoming; + return current || 'needs-action'; +} + +export interface ParticipantListOptions { + /** + * Fills `ParticipantInfo.name` for participants whose event data carries no + * name. Stalwart drops the ORGANIZER display name on its iCalendar + * round-trip, so the organizer (and attendees added by bare address) render + * as a bare email until the contact card's name is looked up. + */ + resolveName?: (email: string) => string | undefined; +} + +export function getParticipantList(event: CalendarEvent, options?: ParticipantListOptions): ParticipantInfo[] { if (!event.participants) return []; // Stalwart rebuilds the ORGANIZER line into a participant that carries only // `calendarAddress` — no `roles` at all — so `roles.owner` alone would treat // the organizer as a plain attendee on every re-read (#731). const organizerEmails = getEventOrganizerEmails(event); - return Object.entries(event.participants).map(([id, p]) => { + const resolveName = options?.resolveName; + + // The same address can legitimately arrive twice: the organizer as both the + // ORGANIZER-derived participant and an ATTENDEE line (server-side + // scheduling, or events written before #731), or an attendee pasted twice. + // Render each address once by folding later entries into the first-seen + // one — otherwise every list and count shows a phantom participant. + const list: ParticipantInfo[] = []; + const byEmail = new Map(); + + for (const [id, p] of Object.entries(event.participants)) { const email = getParticipantEmail(p); - return { + const key = email.trim().toLowerCase(); + const isOrganizer = !!p.roles?.owner || (!!key && organizerEmails.includes(key)); + + const existing = key ? byEmail.get(key) : undefined; + if (existing) { + if (!existing.name && p.name) existing.name = p.name; + if (isOrganizer) existing.isOrganizer = true; + existing.status = betterStatus(existing.status, p.participationStatus); + continue; + } + + const entry: ParticipantInfo = { id, name: p.name || '', email, status: p.participationStatus || 'needs-action', - isOrganizer: !!p.roles?.owner || (!!email && organizerEmails.includes(email.toLowerCase())), + isOrganizer, }; - }); + if (key) byEmail.set(key, entry); + list.push(entry); + } + + for (const entry of list) { + if (!entry.name && entry.email && resolveName) { + entry.name = resolveName(entry.email) || ''; + } + // The organizer owes no reply to their own invitation; a missing status + // must not read as "pending" next to the organizer marker. Matches + // getStatusCounts, which excludes the organizer from pending totals. + if (entry.isOrganizer && entry.status === 'needs-action') { + entry.status = 'accepted'; + } + } + return list; } export function getStatusCounts(event: CalendarEvent): StatusCounts { @@ -158,7 +216,20 @@ export function getStatusCounts(event: CalendarEvent): StatusCounts { export function getParticipantCount(event: CalendarEvent): number { if (!event.participants) return 0; - return Object.keys(event.participants).length; + // Count addresses, not raw entries: an organizer the server emits twice must + // not inflate the participant badge on event cards and agenda rows. Entries + // without any address cannot be merged, so they count individually. + const seen = new Set(); + let count = 0; + for (const p of Object.values(event.participants)) { + const key = getParticipantEmail(p).trim().toLowerCase(); + if (key) { + if (seen.has(key)) continue; + seen.add(key); + } + count++; + } + return count; } export function buildParticipantMap( From 6f99c017a90c73143e7d383305e1b8ca223b28a1 Mon Sep 17 00:00:00 2001 From: ibayue <11729209+ibayue@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:05:47 +0800 Subject: [PATCH 3/3] fix(calendar): resolve bare-address participant names across alias domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The organizer of a self-created event still rendered as a bare address (#738 follow-up): the event stores the organizer under the server's node domain while the name lives elsewhere — the account/principal name, the identity From-name, or a contact card on the account's primary domain. Exact contact matching alone never hit. useContactNameResolver now resolves in layers, first hit wins: 1. Contact card holding the exact address (curated data outranks all). 2. Contact card with the same local part on a *related* domain — one domain extends the other at a "."/"-" label boundary (node-example.com vs example.com, mail.example.com vs example.com). Applied only when the related cards agree on a single name. 3. The account display name (live principal "Full name") for the user's own addresses. 4. The identity From-name (a one-time snapshot of the same, so it only serves when the account layer had nothing). Name sources that just echo the address itself are skipped — they add nothing over the bare address renderers already show. Co-Authored-By: Claude --- .../use-contact-name-resolver.test.ts | 150 ++++++++++++++++++ hooks/use-contact-name-resolver.ts | 143 +++++++++++++++-- 2 files changed, 280 insertions(+), 13 deletions(-) diff --git a/hooks/__tests__/use-contact-name-resolver.test.ts b/hooks/__tests__/use-contact-name-resolver.test.ts index 940482283..b863c4ff8 100644 --- a/hooks/__tests__/use-contact-name-resolver.test.ts +++ b/hooks/__tests__/use-contact-name-resolver.test.ts @@ -90,3 +90,153 @@ describe('buildContactNameResolver', () => { expect(resolver('alice@example.com')).toBe('Alice'); }); }); + +describe('buildContactNameResolver — related-domain fallback', () => { + // #738: the event stores the organizer as zhang@node-example.com (the + // account's node domain) while the contact card with the 备注 name carries + // zhang@example.com — same person, different domain. + it('resolves an address whose local part matches a card on a related domain', () => { + const resolver = buildContactNameResolver([ + makeContact(['zhang@example.com'], '张三'), + ]); + expect(resolver('zhang@node-example.com')).toBe('张三'); + }); + + it('resolves subdomain-related domains (mail.example.com vs example.com)', () => { + const resolver = buildContactNameResolver([ + makeContact(['bob@example.com'], 'Bob'), + ]); + expect(resolver('bob@mail.example.com')).toBe('Bob'); + }); + + it('works in the other direction too (card on the longer domain)', () => { + const resolver = buildContactNameResolver([ + makeContact(['carol@node-example.com'], 'Carol'), + ]); + expect(resolver('carol@example.com')).toBe('Carol'); + }); + + it('does NOT fall back across unrelated domains', () => { + const resolver = buildContactNameResolver([ + makeContact(['john@company.com'], 'John'), + ]); + expect(resolver('john@gmail.com')).toBeUndefined(); + }); + + it('does NOT treat a plain string-suffix domain as related', () => { + // "xexample.com" ends with "example.com" but the split char is "x", not a + // "." or "-" label boundary — different registrable domain. + const resolver = buildContactNameResolver([ + makeContact(['dave@example.com'], 'Dave'), + ]); + expect(resolver('dave@xexample.com')).toBeUndefined(); + }); + + it('returns nothing when two related-domain cards disagree on the name', () => { + // x.mail.example.com is related to both example.com and mail.example.com, + // and the two cards disagree — guessing would be worse than showing the + // bare address. + const resolver = buildContactNameResolver([ + makeContact(['eve@example.com'], 'Eve One'), + makeContact(['eve@mail.example.com'], 'Eve Two'), + ]); + expect(resolver('eve@x.mail.example.com')).toBeUndefined(); + }); + + it('resolves when two related-domain cards agree on the name', () => { + const resolver = buildContactNameResolver([ + makeContact(['eve@example.com'], 'Eve'), + makeContact(['eve@mail.example.com'], 'Eve'), + ]); + expect(resolver('eve@x.mail.example.com')).toBe('Eve'); + }); +}); + +describe('buildContactNameResolver — account and identity layers', () => { + // The organizer of a self-created event is the user; Stalwart strips the + // participant name, so the user's own name has to come from the account + // (live principal name) or the identity (From-name snapshot). + it('resolves the account display name for the account address', () => { + const resolver = buildContactNameResolver( + [], + [], + [{ name: '张三', email: 'zhang@node-example.com', username: 'zhang' }] + ); + expect(resolver('zhang@node-example.com')).toBe('张三'); + }); + + it('matches the account username when it is a full address', () => { + const resolver = buildContactNameResolver( + [], + [], + [{ name: '张三', email: undefined, username: 'zhang@node-example.com' }] + ); + expect(resolver('zhang@node-example.com')).toBe('张三'); + }); + + it('ignores short login usernames that are not addresses', () => { + const resolver = buildContactNameResolver( + [], + [], + [{ name: '张三', email: undefined, username: 'zhang' }] + ); + expect(resolver('zhang@node-example.com')).toBeUndefined(); + }); + + it('skips account names that are just the address itself', () => { + const resolver = buildContactNameResolver( + [], + [], + [{ name: 'zhang@node-example.com', email: 'zhang@node-example.com' }] + ); + expect(resolver('zhang@node-example.com')).toBeUndefined(); + }); + + it('resolves the identity From-name when the account has nothing', () => { + const resolver = buildContactNameResolver( + [], + [{ name: '张三', email: 'zhang@node-example.com' }], + [] + ); + expect(resolver('zhang@node-example.com')).toBe('张三'); + }); + + it('prefers the account name over the identity snapshot', () => { + // On Stalwart the identity name is a one-time snapshot; the account name + // is refreshed from the principal, so it is the more current source. + const resolver = buildContactNameResolver( + [], + [{ name: 'Old Name', email: 'zhang@node-example.com' }], + [{ name: 'New Name', email: 'zhang@node-example.com' }] + ); + expect(resolver('zhang@node-example.com')).toBe('New Name'); + }); + + it('ranks a related-domain contact card above the account name', () => { + // The 备注 the user curated on the card beats a server-side default. + const resolver = buildContactNameResolver( + [makeContact(['zhang@example.com'], '卡片备注名')], + [], + [{ name: '账号名', email: 'zhang@node-example.com' }] + ); + expect(resolver('zhang@node-example.com')).toBe('卡片备注名'); + }); + + it('ranks an exact contact card above everything else', () => { + const resolver = buildContactNameResolver( + [makeContact(['zhang@node-example.com'], '精确卡片名')], + [{ name: 'Identity Name', email: 'zhang@node-example.com' }], + [{ name: 'Account Name', email: 'zhang@node-example.com' }] + ); + expect(resolver('zhang@node-example.com')).toBe('精确卡片名'); + }); + + it('resolves the exact card even when it also matches account data', () => { + const resolver = buildContactNameResolver( + [makeContact(['alice@example.com'], '爱丽丝')], + [{ name: 'Alice Identity', email: 'alice@example.com' }], + [] + ); + expect(resolver('alice@example.com')).toBe('爱丽丝'); + }); +}); diff --git a/hooks/use-contact-name-resolver.ts b/hooks/use-contact-name-resolver.ts index 620b2f03e..0b79fe3fa 100644 --- a/hooks/use-contact-name-resolver.ts +++ b/hooks/use-contact-name-resolver.ts @@ -2,46 +2,163 @@ import { useMemo } from "react"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; +import { useIdentityStore } from "@/stores/identity-store"; +import { useAccountStore } from "@/stores/account-store"; import type { ContactCard } from "@/lib/jmap/types"; +/** Domains are related when one extends the other at a label boundary — + * "node-example.com" vs "example.com" (host-prefixed) or "mail.example.com" vs + * "example.com" (subdomain). Stalwart deployments commonly expose one account + * under several such domains, so a participant address and the contact card + * holding that person's name may differ in domain while naming the same + * person. A pure string suffix ("xexample.com" vs "example.com") is NOT + * related — the character at the split must be "." or "-". */ +function isRelatedDomain(a: string, b: string): boolean { + if (a === b) return true; + const longer = a.length >= b.length ? a : b; + const shorter = a.length >= b.length ? b : a; + if (!longer.endsWith(shorter)) return false; + const boundary = longer[longer.length - shorter.length - 1]; + return boundary === "." || boundary === "-"; +} + +interface IdentityLike { + name?: string; + email?: string; +} + +interface AccountLike { + name?: string; + email?: string; + username?: string; +} + /** - * Build an email → display-name lookup from contact cards. The first contact - * holding an address wins; cards with no resolvable name contribute nothing, - * so whatever name the event itself carries always stays authoritative and - * the contact card is a pure fallback for bare addresses. + * Build an email → display-name lookup. Resolution order (first hit wins): + * + * 1. Contact card holding the exact address — the user's curated data, so it + * outranks everything else. + * 2. Contact card with the same local part on a related domain — covers the + * alias-domain case above (event says zhang@node-example.com, the card + * with the 备注 name says zhang@example.com). Only applied when exactly + * one name matches; two cards disagreeing means we cannot know which + * person it is. Ranked above the account/identity names on purpose: a + * remark the user wrote on the card is a stronger signal than a server + * default. + * 3. The account's display name for the user's own addresses. On Stalwart + * the live "Full name" lives on the principal and is cached here — the + * organizer of a self-created event is the user, and Stalwart strips the + * participant name on its iCalendar round-trip, leaving a bare address + * otherwise. + * 4. The identity From-name — a one-time snapshot of the principal name + * (see auth-store's account displayName refresh), so it only serves when + * the account layer had nothing. + * + * Cards with no resolvable name contribute nothing, so whatever name the event + * itself carries always stays authoritative and this resolver is a pure + * fallback for bare addresses. */ export function buildContactNameResolver( - contacts: ReadonlyArray + contacts: ReadonlyArray, + identities: ReadonlyArray = [], + accounts: ReadonlyArray = [] ): (email: string) => string | undefined { - const namesByEmail = new Map(); + const contactNamesByEmail = new Map(); + const contactNamesByLocalPart = new Map>(); + const accountNamesByEmail = new Map(); + const identityNamesByEmail = new Map(); + + /** Register `name` for `address` unless it is just the address itself — + * several name sources default to the email and would add nothing over the + * bare address every renderer already shows. */ + const register = (map: Map, name: string | undefined, address: string | undefined) => { + const trimmedName = name?.trim(); + const key = address?.trim().toLowerCase(); + if (!trimmedName || !key || map.has(key)) return; + if (trimmedName.toLowerCase() === key) return; + map.set(key, trimmedName); + }; + for (const contact of contacts) { const name = getContactDisplayName(contact).trim(); if (!name || !contact.emails) continue; // getContactDisplayName falls back to the first email address, so a card - // whose "name" is just one of its addresses has nothing to add over the - // bare address every renderer already shows. + // whose "name" is just one of its addresses is equally useless here. const nameIsJustAnAddress = Object.values(contact.emails).some( (e) => e.address?.trim().toLowerCase() === name.toLowerCase() ); if (nameIsJustAnAddress) continue; for (const { address } of Object.values(contact.emails)) { const key = address?.trim().toLowerCase(); - if (key && !namesByEmail.has(key)) namesByEmail.set(key, name); + if (!key || contactNamesByEmail.has(key)) continue; + contactNamesByEmail.set(key, name); + const [localPart, ...rest] = key.split("@"); + const domain = rest.join("@"); + if (localPart && domain) { + const bucket = contactNamesByLocalPart.get(localPart) ?? []; + bucket.push({ domain, name }); + contactNamesByLocalPart.set(localPart, bucket); + } } } + + for (const account of accounts) { + register(accountNamesByEmail, account.name, account.email); + // A short login username ("zhang") is not an address; only match + // usernames that actually carry a domain. + if (account.username?.includes("@")) { + register(accountNamesByEmail, account.name, account.username); + } + } + + for (const identity of identities) { + register(identityNamesByEmail, identity.name, identity.email); + } + return (email: string) => { const key = email?.trim().toLowerCase(); - return key ? namesByEmail.get(key) : undefined; + if (!key) return undefined; + + // 1. Exact address on a contact card. + const exact = contactNamesByEmail.get(key); + if (exact) return exact; + + // 2. Same local part on a related domain. Ambiguous matches resolve to + // nothing rather than to a guess. + const [localPart, ...rest] = key.split("@"); + const domain = rest.join("@"); + if (localPart && domain) { + const candidates = new Set(); + for (const candidate of contactNamesByLocalPart.get(localPart) ?? []) { + if (isRelatedDomain(candidate.domain, domain)) candidates.add(candidate.name); + } + if (candidates.size === 1) return candidates.values().next().value; + } + + // 3. The user's own account display name, then 4. the identity From-name. + return accountNamesByEmail.get(key) ?? identityNamesByEmail.get(key); }; } /** * Participant rows (calendar, and any address-rendering UI) show a contact's * display name — e.g. its 备注 name — when the event data itself carries only - * a bare address. Memoised on the contact list, so it recomputes only when - * contacts actually change. + * a bare address. Memoised on the contact, account and identity lists, so it + * recomputes only when one of them actually changes. */ export function useContactNameResolver(): (email: string) => string | undefined { const contacts = useContactStore((s) => s.contacts); - return useMemo(() => buildContactNameResolver(contacts), [contacts]); + const identities = useIdentityStore((s) => s.identities); + const accounts = useAccountStore((s) => s.accounts); + return useMemo( + () => + buildContactNameResolver( + contacts, + identities, + // AccountEntry calls the display name `displayName`; map it onto the + // generic `name` the resolver works with. + accounts.map((a) => ({ name: a.displayName, email: a.email, username: a.username })) + ), + [contacts, identities, accounts] + ); }