fix: list event participants once with contact names and organizer status

Ported from webmail 8cbc5bc5 and 6f99c017. Stalwart can emit the
organizer both as the ORGANIZER-derived participant and as an ATTENDEE,
so the detail sheet listed them twice and counted a phantom
participant. It also drops the organizer's display name, leaving a bare
address, and the organizer without a status got the pending dot.

- fold participants with the same address into one entry, keeping a
  real RSVP over needs-action, and count addresses in
  `getParticipantCount`
- show the organizer as accepted when they have no status, with the
  status label like everyone else
- fill missing names from contact cards (exact address, then the same
  local part on a related domain), then the account and identity
  names, via `useContactNameResolver`; the editor uses it for the
  organizer name of a new invitation
This commit is contained in:
Linus Rath
2026-09-24 19:12:03 +02:00
parent a6ef0abc2a
commit 1d755fc066
6 changed files with 444 additions and 12 deletions
+8 -4
View File
@@ -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({
</Text>
) : null}
</Text>
<Text style={styles.participantStatus}>
{p.isOrganizer ? '' : statusLabel(p.status, t)}
</Text>
{/* The organizer is marked by the suffix; this shows their status
(organizers default to accepted), like the dot. */}
<Text style={styles.participantStatus}>{statusLabel(p.status, t)}</Text>
</View>
))}
</View>
+7 -1
View File
@@ -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,
@@ -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);
});
});
@@ -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> = {}): 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');
});
});
+80 -7
View File
@@ -127,23 +127,83 @@ export function getUserStatus(
return null;
}
export function getParticipantList(event: Partial<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. 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<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<string, ParticipantInfo>();
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<CalendarEvent>): StatusCounts {
@@ -163,7 +223,20 @@ export function getStatusCounts(event: Partial<CalendarEvent>): StatusCounts {
export function getParticipantCount(event: Partial<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 count. Entries without any address cannot be merged, so
// they count individually.
const seen = new Set<string>();
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 {
+158
View File
@@ -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<ContactCard>,
identities: ReadonlyArray<IdentityLike> = [],
accounts: ReadonlyArray<AccountLike> = [],
): (email: string) => string | undefined {
const contactNamesByEmail = new Map<string, string>();
const contactNamesByLocalPart = new Map<string, Array<{ domain: string; name: string }>>();
const accountNamesByEmail = new Map<string, string>();
const identityNamesByEmail = new Map<string, string>();
/** 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<string, string>, 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<string>();
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],
);
}