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(