diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 959fc9d0..02365934 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -49,6 +49,7 @@ import { isOrganizer, } from '../../lib/calendar-participants'; import { getEventEditability } from '../../lib/calendar-editability'; +import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { useSheetDrag } from '../../lib/use-sheet-drag'; @@ -143,6 +144,9 @@ export function EventDetailSheet({ const { locale, t } = useCalendarLocale(); const [rsvpBusy, setRsvpBusy] = React.useState(false); const styles = React.useMemo(() => makeStyles(c), [c]); + // 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 slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; const visible = !!event; @@ -192,7 +196,7 @@ export function EventDetailSheet({ const range = formatRange(event, timeFormat, locale); const recurrence = recurrenceLabel(event, t, locale); const reminders = alertsToReminders(event.alerts); - const participants = getParticipantList(event); + const participants = getParticipantList(event, { resolveName: resolveContactName }); const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined; const isCancelled = event.status === 'cancelled'; @@ -365,9 +369,9 @@ export function EventDetailSheet({ ) : null} - - {p.isOrganizer ? '' : statusLabel(p.status, t)} - + {/* The organizer is marked by the suffix; this shows their status + (organizers default to accepted), like the dot. */} + {statusLabel(p.status, t)} ))} diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 07b5f496..35d2b7bd 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -68,6 +68,7 @@ import { } from '../../lib/calendar-participants'; import { Button } from '..'; import { ParticipantInput } from './ParticipantInput'; +import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { RecurrenceEditor } from './RecurrenceEditor'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; @@ -187,6 +188,7 @@ export function EventModal({ const timeFormat = useSettingsStore((s) => s.calendarTimeFormat); const timeLabelPattern = timePattern(timeFormat); const isEdit = !!event; + const resolveContactName = useContactNameResolver(); const [title, setTitle] = React.useState(''); const [description, setDescription] = React.useState(''); @@ -352,8 +354,12 @@ export function EventModal({ }; const organizerEmail = currentUserEmails[0]; if (attendees.length > 0 && organizerEmail) { + // A new event has no participants yet, so it would store an empty + // organizer name; fall back to the contact card / account name. const organizerName = - (event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '') || ''; + (event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '') + || resolveContactName(organizerEmail) + || ''; data.participants = buildParticipantMap( { name: organizerName, email: organizerEmail }, attendees, diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts index 03157401..a5819ec1 100644 --- a/src/lib/__tests__/calendar-participants.test.ts +++ b/src/lib/__tests__/calendar-participants.test.ts @@ -3,6 +3,7 @@ import type { CalendarEvent } from '../../api/types'; import { buildParticipantMap, collectUserCalendarAddresses, + getParticipantCount, getParticipantList, getStatusCounts, getUserParticipantId, @@ -89,3 +90,84 @@ describe('seedAttendees', () => { expect(emails).toHaveLength(3); }); }); + +describe('getParticipantList dedupe, names and organizer status', () => { + const org = { name: 'Alice', email: 'alice@example.com', roles: { owner: true }, participationStatus: 'accepted' as const }; + const bob = { name: 'Bob', email: 'bob@example.com', roles: { attendee: true }, participationStatus: 'needs-action' as const }; + + it('renders the organizer once when the server also emits them as an attendee', () => { + const list = getParticipantList({ + organizerCalendarAddress: 'mailto:alice@example.com', + participants: { + org: { ...org, name: '' }, + att1: bob, + att2: { ...bob, name: 'Carol', email: 'carol@example.com' }, + orgDup: { ...bob, name: '', email: 'alice@example.com' }, + }, + }); + 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 first entry of an address never replied', () => { + const list = getParticipantList({ + organizerCalendarAddress: 'mailto:alice@example.com', + participants: { + att1: { ...bob, name: '', email: 'alice@example.com' }, + org, + }, + }); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ isOrganizer: true, status: 'accepted', name: 'Alice' }); + }); + + it('merges duplicate attendees case-insensitively, first-seen name wins', () => { + const list = getParticipantList({ + participants: { + att1: bob, + att2: { ...bob, name: 'Bobby', email: 'BOB@example.com', participationStatus: 'accepted' }, + }, + }); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ name: 'Bob', status: 'accepted' }); + }); + + it('shows a statusless organizer as accepted, not pending', () => { + const list = getParticipantList(event); + expect(list.find((p) => p.isOrganizer)?.status).toBe('accepted'); + }); + + it('fills missing names from resolveName without overriding event data', () => { + const resolveName = (email: string) => (email.startsWith('alice') || email.startsWith('bob') ? 'Contact' : undefined); + const list = getParticipantList( + { participants: { org: { ...org, name: '' }, att1: bob } }, + { resolveName }, + ); + expect(list.find((p) => p.email === 'alice@example.com')?.name).toBe('Contact'); + expect(list.find((p) => p.email === 'bob@example.com')?.name).toBe('Bob'); + }); + + it('keeps entries without an address unmerged', () => { + const list = getParticipantList({ + participants: { + org, + ghost1: { name: 'No Address', participationStatus: 'needs-action' }, + ghost2: { name: 'Also No Address', participationStatus: 'accepted' }, + }, + }); + expect(list).toHaveLength(3); + }); + + it('counts addresses, not raw entries', () => { + expect(getParticipantCount({ + participants: { + org: { ...org, name: '' }, + att1: bob, + orgDup: { ...bob, name: '', email: 'alice@example.com' }, + ghost: { name: 'No Address' }, + }, + })).toBe(3); + }); +}); diff --git a/src/lib/__tests__/contact-name-resolver.test.ts b/src/lib/__tests__/contact-name-resolver.test.ts new file mode 100644 index 00000000..2515b58a --- /dev/null +++ b/src/lib/__tests__/contact-name-resolver.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import type { ContactCard } from '../../api/types'; +import { buildContactNameResolver } from '../contact-name-resolver'; + +// A bare address in the event data must display the contact card's name, +// while names the event already carries stay authoritative — the resolver +// only ever fills (webmail #738). + +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, case-insensitively', () => { + const resolver = buildContactNameResolver([ + makeContact(['alice@example.com', 'alice.smith@example.com'], 'Alice'), + ]); + expect(resolver('alice@example.com')).toBe('Alice'); + expect(resolver(' ALICE.SMITH@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('skips cards whose only name is an address, and unknown or blank addresses', () => { + const resolver = buildContactNameResolver([makeContact(['noname@example.com'])]); + expect(resolver('noname@example.com')).toBeUndefined(); + expect(resolver('unknown@example.com')).toBeUndefined(); + expect(resolver(' ')).toBeUndefined(); + }); + + it('falls back to a card with the same local part on a related domain', () => { + const resolver = buildContactNameResolver([ + makeContact(['zhang@example.com'], 'Zhang'), + makeContact(['carol@node-example.com'], 'Carol'), + ]); + expect(resolver('zhang@node-example.com')).toBe('Zhang'); + expect(resolver('zhang@mail.example.com')).toBe('Zhang'); + expect(resolver('carol@example.com')).toBe('Carol'); + }); + + it('does not fall back across unrelated or merely string-suffixed domains', () => { + const resolver = buildContactNameResolver([makeContact(['dave@example.com'], 'Dave')]); + expect(resolver('dave@gmail.com')).toBeUndefined(); + expect(resolver('dave@xexample.com')).toBeUndefined(); + }); + + it('returns nothing when related-domain cards disagree on the name', () => { + const disagree = buildContactNameResolver([ + makeContact(['eve@example.com'], 'Eve One'), + makeContact(['eve@mail.example.com'], 'Eve Two'), + ]); + expect(disagree('eve@x.mail.example.com')).toBeUndefined(); + const agree = buildContactNameResolver([ + makeContact(['eve@example.com'], 'Eve'), + makeContact(['eve@mail.example.com'], 'Eve'), + ]); + expect(agree('eve@x.mail.example.com')).toBe('Eve'); + }); + + it('uses the account name, then the identity name, for the user\'s own addresses', () => { + const resolver = buildContactNameResolver( + [], + [{ name: 'Old Name', email: 'zhang@node-example.com' }, { name: 'Ident', email: 'z@other.example' }], + [ + { name: 'New Name', email: 'zhang@node-example.com', username: 'zhang' }, + { name: 'Login', email: undefined, username: 'login@example.com' }, + ], + ); + expect(resolver('zhang@node-example.com')).toBe('New Name'); + expect(resolver('z@other.example')).toBe('Ident'); + expect(resolver('login@example.com')).toBe('Login'); + // A short username is not an address. + expect(resolver('zhang@elsewhere.example')).toBeUndefined(); + }); + + it('skips account names that are just the address', () => { + const resolver = buildContactNameResolver([], [], [ + { name: 'zhang@node-example.com', email: 'zhang@node-example.com' }, + ]); + expect(resolver('zhang@node-example.com')).toBeUndefined(); + }); + + it('ranks contact cards (exact, then related-domain) above account and identity names', () => { + const exact = buildContactNameResolver( + [makeContact(['zhang@node-example.com'], 'Card')], + [{ name: 'Identity', email: 'zhang@node-example.com' }], + [{ name: 'Account', email: 'zhang@node-example.com' }], + ); + expect(exact('zhang@node-example.com')).toBe('Card'); + const related = buildContactNameResolver( + [makeContact(['zhang@example.com'], 'Card remark')], + [], + [{ name: 'Account', email: 'zhang@node-example.com' }], + ); + expect(related('zhang@node-example.com')).toBe('Card remark'); + }); +}); diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts index 584a2152..ec7842ab 100644 --- a/src/lib/calendar-participants.ts +++ b/src/lib/calendar-participants.ts @@ -127,23 +127,83 @@ export function getUserStatus( return null; } -export function getParticipantList(event: Partial): 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. Between two real replies the first-seen entry + * wins; such conflicts do not occur in practice. */ +function betterStatus( + current: Participant['participationStatus'] | undefined, + incoming: Participant['participationStatus'] | undefined, +): Participant['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: Partial, + 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: Partial): StatusCounts { @@ -163,7 +223,20 @@ export function getStatusCounts(event: Partial): StatusCounts { export function getParticipantCount(event: Partial): 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 count. 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 interface Attendee { diff --git a/src/lib/contact-name-resolver.ts b/src/lib/contact-name-resolver.ts new file mode 100644 index 00000000..be7c7c96 --- /dev/null +++ b/src/lib/contact-name-resolver.ts @@ -0,0 +1,158 @@ +import React from 'react'; +import type { ContactCard } from '../api/types'; +import { useAccountStore } from '../stores/account-store'; +import { useContactsStore } from '../stores/contacts-store'; +import { useSettingsStore } from '../stores/settings-store'; +import { getContactDisplayName } from './contact-utils'; + +// Port of the webmail's hooks/use-contact-name-resolver.ts (8cbc5bc5, +// 6f99c017): names for participants whose event data carries only a bare +// address. + +/** 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 (the + * alias-domain case above). Only applied when exactly one name matches; + * two cards disagreeing means we cannot know which person it is. + * 3. The account's display name for the user's own addresses — the organizer + * of a self-created event is the user, and Stalwart strips the participant + * name on its iCalendar round-trip. + * 4. The identity From-name. + * + * 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 show a contact's display 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 = useContactsStore((s) => s.contacts); + const identities = useSettingsStore((s) => s.identities); + const accounts = useAccountStore((s) => s.accounts); + return React.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], + ); +}