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] 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(