This commit is contained in:
Linus Rath
2026-09-17 22:03:02 +02:00
8 changed files with 841 additions and 68 deletions
+27 -8
View File
@@ -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<EditabilityContext>(() => {
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 && (
<div className="px-4 py-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("participants.rsvp_label")}
+102 -54
View File
@@ -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 (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
@@ -778,8 +797,13 @@ export function EventModal({
<div className="space-y-1 ps-5">
{participants.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="truncate">{p.name || p.email}</span>
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
<span className="truncate">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} t={t} />
</div>
))}
</div>
@@ -789,44 +813,7 @@ export function EventModal({
</div>
<div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
</div>
<RsvpBar status={userCurrentStatus} onRespond={handleRsvp} t={t} />
</div>
</div>
);
@@ -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({
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
<StatusBadge status={p.status} t={t} />
</div>
))}
</div>
@@ -1002,6 +989,16 @@ export function EventModal({
</div>
</div>
{/* 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 && (
<div className="px-6 py-3 border-t border-border flex-shrink-0">
<RsvpBar status={userCurrentStatus} onRespond={handleRsvp} t={t} />
</div>
)}
{/* Action Bar */}
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between">
<div className="flex items-center gap-1">
@@ -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<typeof useTranslations>;
}) {
if (isOrganizer) {
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
}
return (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={status === "accepted" ? "default" : "outline"}
onClick={() => onRespond("accepted")}
className={status === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{status === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={status === "tentative" ? "default" : "outline"}
onClick={() => onRespond("tentative")}
className={status === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{status === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={status === "declined" ? "default" : "ghost"}
onClick={() => onRespond("declined")}
className={status === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{status === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
</div>
);
}
function StatusBadge({ status, t }: {
status: CalendarParticipant['participationStatus'];
t: ReturnType<typeof useTranslations>;
}) {
// 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<string, string> = {
accepted: "text-success",
declined: "text-destructive",
@@ -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> = {}
): 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('爱丽丝');
});
});
+164
View File
@@ -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<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 (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]
);
}
@@ -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);
});
});
+114
View File
@@ -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', () => {
+30
View File
@@ -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;
}
+77 -6
View File
@@ -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<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: 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<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 function buildParticipantMap(