mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 08:06:59 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user