mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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")}
|
||||
|
||||
@@ -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('爱丽丝');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user