diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index f91d89345..651ebb1e4 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -20,8 +20,9 @@ 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"; +import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -137,6 +138,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); @@ -155,21 +160,35 @@ 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]); // 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), @@ -538,7 +557,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 8450af7e4..3d2d0bdac 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -23,12 +23,13 @@ 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"; 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"; @@ -235,6 +236,21 @@ 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]); + + // 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); @@ -247,8 +263,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; @@ -589,7 +605,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 @@ -613,7 +632,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; @@ -697,7 +716,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 (

@@ -778,8 +797,13 @@ export function EventModal({
{participants.map(p => (
- {p.name || p.email} - + + {p.name || p.email} + {p.isOrganizer && ( + ({t("participants.organizer").toLowerCase()}) + )} + +
))}
@@ -789,44 +813,7 @@ export function EventModal({
-
- {t("participants.rsvp_label")} -
- - - -
-
+
); @@ -839,7 +826,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]); @@ -966,7 +953,7 @@ export function EventModal({ ({t("participants.organizer").toLowerCase()}) )} - + ))} @@ -1002,6 +989,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 */}
@@ -1448,14 +1445,65 @@ export function EventModal({ ); } -function StatusBadge({ status, isOrganizer, t }: { - status: CalendarParticipant['participationStatus']; - isOrganizer: boolean; +/** + * 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; }) { - if (isOrganizer) { - return {t("participants.organizer")}; - } + return ( +
+ {t("participants.rsvp_label")} +
+ + + +
+
+ ); +} + +function StatusBadge({ status, t }: { + status: CalendarParticipant['participationStatus']; + t: ReturnType; +}) { + // 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..b863c4ff8 --- /dev/null +++ b/hooks/__tests__/use-contact-name-resolver.test.ts @@ -0,0 +1,242 @@ +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'); + }); +}); + +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 new file mode 100644 index 000000000..0b79fe3fa --- /dev/null +++ b/hooks/use-contact-name-resolver.ts @@ -0,0 +1,164 @@ +"use client"; + +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. 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, + identities: ReadonlyArray = [], + accounts: ReadonlyArray = [] +): (email: string) => string | undefined { + 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 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 || 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(); + 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, 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); + 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] + ); +} 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/__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-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; +} 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(