mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: send invitations with a server-scheduled organizer and match RSVPs against identities and aliases (#731)
This commit is contained in:
@@ -22,7 +22,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
|||||||
- What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`).
|
- What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`).
|
||||||
- Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`.
|
- Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`.
|
||||||
|
|
||||||
- [ ] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity`
|
- [x] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity` — fixed in 58a6f45
|
||||||
- What WEB does: `createRecurrenceRule` deliberately omits `rscale`/`skip` because Stalwart serialises them into the RRULE and DAVx5 rejects it (ref `lib/recurrence-rule.ts:5-33`, changelog 1.9.2 #805).
|
- What WEB does: `createRecurrenceRule` deliberately omits `rscale`/`skip` because Stalwart serialises them into the RRULE and DAVx5 rejects it (ref `lib/recurrence-rule.ts:5-33`, changelog 1.9.2 #805).
|
||||||
- What RN does: `buildRuleFromEditorValue` sets `rscale: 'gregorian', skip: 'omit'` on every custom rule (ref `src/lib/recurrence.ts:220-221`). RN affected: any custom recurrence created on mobile breaks Android CalDAV sync.
|
- What RN does: `buildRuleFromEditorValue` sets `rscale: 'gregorian', skip: 'omit'` on every custom rule (ref `src/lib/recurrence.ts:220-221`). RN affected: any custom recurrence created on mobile breaks Android CalDAV sync.
|
||||||
- Fix hint: delete the two properties in `buildRuleFromEditorValue`; the simple presets in `EventModal.tsx:143-146` are already clean.
|
- Fix hint: delete the two properties in `buildRuleFromEditorValue`; the simple presets in `EventModal.tsx:143-146` are already clean.
|
||||||
@@ -32,7 +32,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
|||||||
- What RN does: always fetches raw events and expands on the client (`src/stores/calendar-store.ts:228`, `src/lib/recurrence-expansion.ts`). Functionally fine on all server versions; RN's expansion file is a line-for-line port of WEB's (diffed: only comments, formatting and the `isServerRecurrenceInstance` pass-through differ).
|
- What RN does: always fetches raw events and expands on the client (`src/stores/calendar-store.ts:228`, `src/lib/recurrence-expansion.ts`). Functionally fine on all server versions; RN's expansion file is a line-for-line port of WEB's (diffed: only comments, formatting and the `isServerRecurrenceInstance` pass-through differ).
|
||||||
- Fix hint: optional. If adopted, port `recurrence-instances.ts` wholesale and the `isServerRecurrenceInstance` guard in `expandRecurringEvents`.
|
- Fix hint: optional. If adopted, port `recurrence-instances.ts` wholesale and the `isServerRecurrenceInstance` guard in `expandRecurringEvents`.
|
||||||
|
|
||||||
- [ ] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug`
|
- [x] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug` — fixed in 58a6f45
|
||||||
- What WEB does: sends explicit `null` for `recurrenceRules` (+ `recurrenceOverrides`, `excludedRecurrenceRules`), `alerts`, `participants` (+ `replyTo`, `organizerCalendarAddress`), `locations`, `virtualLocations` when the field was cleared (ref `components/calendar/event-modal.tsx:528-549, 560-563, 588-592`).
|
- What WEB does: sends explicit `null` for `recurrenceRules` (+ `recurrenceOverrides`, `excludedRecurrenceRules`), `alerts`, `participants` (+ `replyTo`, `organizerCalendarAddress`), `locations`, `virtualLocations` when the field was cleared (ref `components/calendar/event-modal.tsx:528-549, 560-563, 588-592`).
|
||||||
- What RN does: `handleSave` emits `undefined` for cleared fields (`recurrenceRules: undefined`, `participants: undefined`, `alerts: remindersToAlerts([]) === undefined`, `locations: undefined`) which JSON serialisation drops, so the server keeps the old value (ref `src/components/calendar/EventModal.tsx:262-281`, `src/lib/calendar-alerts.ts:72-73`). Setting "Does not repeat" on a recurring event, removing all attendees, deleting the last reminder or clearing the location silently does nothing.
|
- What RN does: `handleSave` emits `undefined` for cleared fields (`recurrenceRules: undefined`, `participants: undefined`, `alerts: remindersToAlerts([]) === undefined`, `locations: undefined`) which JSON serialisation drops, so the server keeps the old value (ref `src/components/calendar/EventModal.tsx:262-281`, `src/lib/calendar-alerts.ts:72-73`). Setting "Does not repeat" on a recurring event, removing all attendees, deleting the last reminder or clearing the location silently does nothing.
|
||||||
- Fix hint: in edit mode compare against `event` and send `null` for fields that existed and are now empty; RN's `cleanRecurrenceRules` already turns `[]`/`null` into `recurrenceRule: null` (`src/api/calendar.ts:115-118`), so `recurrenceRules: []` works today for recurrence.
|
- Fix hint: in edit mode compare against `event` and send `null` for fields that existed and are now empty; RN's `cleanRecurrenceRules` already turns `[]`/`null` into `recurrenceRule: null` (`src/api/calendar.ts:115-118`), so `recurrenceRules: []` works today for recurrence.
|
||||||
@@ -43,12 +43,12 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
|||||||
|
|
||||||
### Events: create / edit
|
### Events: create / edit
|
||||||
|
|
||||||
- [ ] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug`
|
- [x] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug` — fixed in 58a6f45
|
||||||
- What WEB does: seeds the end field from `getEventDisplayEndDate` (inclusive, end - 1 ms) for all-day events and builds `buildAllDayDuration(start, inclusiveEnd)` (ref `components/calendar/event-modal.tsx:274-280, 471-477`).
|
- What WEB does: seeds the end field from `getEventDisplayEndDate` (inclusive, end - 1 ms) for all-day events and builds `buildAllDayDuration(start, inclusiveEnd)` (ref `components/calendar/event-modal.tsx:274-280, 471-477`).
|
||||||
- What RN does: edit seeds `end` with `getEventEndDate(event)` which is the exclusive end (start + P1D = next day 00:00) (`src/components/calendar/EventModal.tsx:198`); the all-day toggle effect pushes `end` to the next day whenever `end <= start` (`:231-242`); `handleSave` then calls `buildAllDayDuration(start, end)` which is inclusive (`:269`, `src/lib/calendar-utils.ts:183-189`), yielding `P2D` for a one-day event. Every create-via-toggle and every re-save of an all-day event adds a day.
|
- What RN does: edit seeds `end` with `getEventEndDate(event)` which is the exclusive end (start + P1D = next day 00:00) (`src/components/calendar/EventModal.tsx:198`); the all-day toggle effect pushes `end` to the next day whenever `end <= start` (`:231-242`); `handleSave` then calls `buildAllDayDuration(start, end)` which is inclusive (`:269`, `src/lib/calendar-utils.ts:183-189`), yielding `P2D` for a one-day event. Every create-via-toggle and every re-save of an all-day event adds a day.
|
||||||
- Fix hint: seed/display the inclusive end (`getEventDisplayEndDate`) for all-day events, and in the all-day effect set `e = s` (same day) when `end <= start`; keep `buildAllDayDuration` inclusive.
|
- Fix hint: seed/display the inclusive end (`getEventDisplayEndDate`) for all-day events, and in the all-day effect set `e = s` (same day) when `end <= start`; keep `buildAllDayDuration` inclusive.
|
||||||
|
|
||||||
- [ ] **Events created without `timeZone` (floating time)** — `P1` — `bug`
|
- [x] **Events created without `timeZone` (floating time)** — `P1` — `bug` — fixed in 58a6f45
|
||||||
- What WEB does: every timed event carries `timeZone: getEffectiveTimeZone()` (all-day: `null`) so other clients and invitees resolve the wall-clock correctly (ref `components/calendar/event-modal.tsx:494-502`, `lib/timezone.ts:64-75`).
|
- What WEB does: every timed event carries `timeZone: getEffectiveTimeZone()` (all-day: `null`) so other clients and invitees resolve the wall-clock correctly (ref `components/calendar/event-modal.tsx:494-502`, `lib/timezone.ts:64-75`).
|
||||||
- What RN does: `handleSave` never sets `timeZone` (`src/components/calendar/EventModal.tsx:262-281`) and `createEvent` does not add one (`src/api/calendar.ts:248-275`). Stalwart stores a floating DTSTART; attendees in other zones, CalDAV clients, and the user after travelling see wrong times; iMIP invites sent for such events are ambiguous.
|
- What RN does: `handleSave` never sets `timeZone` (`src/components/calendar/EventModal.tsx:262-281`) and `createEvent` does not add one (`src/api/calendar.ts:248-275`). Stalwart stores a floating DTSTART; attendees in other zones, CalDAV clients, and the user after travelling see wrong times; iMIP invites sent for such events are ambiguous.
|
||||||
- Fix hint: set `timeZone: allDay ? null : Intl.DateTimeFormat().resolvedOptions().timeZone` (reuse `getUserTimeZone` from `src/api/calendar.ts:15`); once a custom time-zone setting exists (see below) use that.
|
- Fix hint: set `timeZone: allDay ? null : Intl.DateTimeFormat().resolvedOptions().timeZone` (reuse `getUserTimeZone` from `src/api/calendar.ts:15`); once a custom time-zone setting exists (see below) use that.
|
||||||
|
|||||||
+6
-2
@@ -328,11 +328,15 @@ export async function deleteEvents(
|
|||||||
|
|
||||||
// RSVP to an invitation: patch the participant's participationStatus via a JSON
|
// RSVP to an invitation: patch the participant's participationStatus via a JSON
|
||||||
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
|
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
|
||||||
|
// Stalwart routes the REPLY to the stored ORGANIZER (organizerCalendarAddress);
|
||||||
|
// the RFC 8984 replyTo property is retired in jscalendarbis and ignored. The
|
||||||
|
// caller passes `repairOrganizerAddress` only for events that lack an
|
||||||
|
// organizer (e.g. imported ones) — attendees may not modify an existing one.
|
||||||
export async function rsvpEvent(
|
export async function rsvpEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
participantId: string,
|
participantId: string,
|
||||||
status: 'accepted' | 'declined' | 'tentative',
|
status: 'accepted' | 'declined' | 'tentative',
|
||||||
replyTo?: Record<string, string> | null,
|
repairOrganizerAddress?: string | null,
|
||||||
targetAccountId?: string,
|
targetAccountId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const accountId = targetAccountId || jmapClient.accountId;
|
const accountId = targetAccountId || jmapClient.accountId;
|
||||||
@@ -341,7 +345,7 @@ export async function rsvpEvent(
|
|||||||
const patch: Record<string, unknown> = {
|
const patch: Record<string, unknown> = {
|
||||||
[`participants/${escaped}/participationStatus`]: status,
|
[`participants/${escaped}/participationStatus`]: status,
|
||||||
};
|
};
|
||||||
if (replyTo) patch.replyTo = replyTo;
|
if (repairOrganizerAddress) patch.organizerCalendarAddress = repairOrganizerAddress;
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
|
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
|
||||||
USING,
|
USING,
|
||||||
|
|||||||
@@ -35,16 +35,17 @@ import { useColors } from '../../theme/colors';
|
|||||||
import {
|
import {
|
||||||
eventTimeRange,
|
eventTimeRange,
|
||||||
getEventColor,
|
getEventColor,
|
||||||
|
getEventDisplayEndDate,
|
||||||
getPrimaryCalendarId,
|
getPrimaryCalendarId,
|
||||||
timePattern,
|
timePattern,
|
||||||
type TimeFormat,
|
type TimeFormat,
|
||||||
} from '../../lib/calendar-utils';
|
} from '../../lib/calendar-utils';
|
||||||
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
||||||
import {
|
import {
|
||||||
findParticipantByEmail,
|
getParticipantList,
|
||||||
isOrganizerParticipant,
|
getUserParticipantId,
|
||||||
} from '../../lib/calendar-invitation';
|
isOrganizer,
|
||||||
import { useAccountStore } from '../../stores/account-store';
|
} from '../../lib/calendar-participants';
|
||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
|
|
||||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||||
@@ -53,6 +54,8 @@ interface EventDetailSheetProps {
|
|||||||
event: CalendarEvent | null;
|
event: CalendarEvent | null;
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
timeFormat?: TimeFormat;
|
timeFormat?: TimeFormat;
|
||||||
|
// Login address + identities + aliases; finds "me" among the participants.
|
||||||
|
currentUserEmails?: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEdit?: (event: CalendarEvent) => void;
|
onEdit?: (event: CalendarEvent) => void;
|
||||||
onDelete?: (event: CalendarEvent) => void;
|
onDelete?: (event: CalendarEvent) => void;
|
||||||
@@ -61,7 +64,10 @@ interface EventDetailSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||||
const { start, end, allDay } = eventTimeRange(event);
|
const { start, allDay } = eventTimeRange(event);
|
||||||
|
// All-day events store an exclusive end (next day 00:00); show the
|
||||||
|
// inclusive last day so a one-day event doesn't read as two (#318).
|
||||||
|
const end = allDay ? getEventDisplayEndDate(event) : eventTimeRange(event).end;
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
if (
|
if (
|
||||||
start.getFullYear() === end.getFullYear() &&
|
start.getFullYear() === end.getFullYear() &&
|
||||||
@@ -105,6 +111,7 @@ export function EventDetailSheet({
|
|||||||
event,
|
event,
|
||||||
calendars,
|
calendars,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
|
currentUserEmails = [],
|
||||||
onClose,
|
onClose,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -112,7 +119,6 @@ export function EventDetailSheet({
|
|||||||
onRsvp,
|
onRsvp,
|
||||||
}: EventDetailSheetProps) {
|
}: EventDetailSheetProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
|
||||||
const [rsvpBusy, setRsvpBusy] = React.useState(false);
|
const [rsvpBusy, setRsvpBusy] = React.useState(false);
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||||
@@ -164,21 +170,24 @@ export function EventDetailSheet({
|
|||||||
const range = formatRange(event, timeFormat);
|
const range = formatRange(event, timeFormat);
|
||||||
const recurrence = recurrenceLabel(event);
|
const recurrence = recurrenceLabel(event);
|
||||||
const reminders = alertsToReminders(event.alerts);
|
const reminders = alertsToReminders(event.alerts);
|
||||||
const participants = event.participants ? Object.values(event.participants) : [];
|
const participants = getParticipantList(event);
|
||||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||||
|
const isCancelled = event.status === 'cancelled';
|
||||||
|
|
||||||
// Can the signed-in user RSVP? Only when they appear as a non-organizer
|
// Can the signed-in user RSVP? Only when they appear as a non-organizer
|
||||||
// participant and the caller wired an onRsvp handler.
|
// participant (matched against every address of theirs: login, identities,
|
||||||
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
|
// aliases) and the caller wired an onRsvp handler.
|
||||||
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
|
const myParticipantId = getUserParticipantId(event, currentUserEmails);
|
||||||
const myStatus = me?.participant.participationStatus;
|
const userIsOrganizer = isOrganizer(event, currentUserEmails);
|
||||||
|
const canRsvp = Boolean(onRsvp && myParticipantId && !userIsOrganizer);
|
||||||
|
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
|
||||||
|
|
||||||
const doRsvp = async (status: RsvpStatus) => {
|
const doRsvp = async (status: RsvpStatus) => {
|
||||||
if (!onRsvp || !me || rsvpBusy) return;
|
if (!onRsvp || !myParticipantId || rsvpBusy) return;
|
||||||
setRsvpBusy(true);
|
setRsvpBusy(true);
|
||||||
try {
|
try {
|
||||||
await onRsvp(event, me.id, status);
|
await onRsvp(event, myParticipantId, status);
|
||||||
} finally {
|
} finally {
|
||||||
setRsvpBusy(false);
|
setRsvpBusy(false);
|
||||||
}
|
}
|
||||||
@@ -206,7 +215,9 @@ export function EventDetailSheet({
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
||||||
<View style={styles.headerText}>
|
<View style={styles.headerText}>
|
||||||
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
|
<Text style={[styles.title, isCancelled && styles.titleCancelled]}>
|
||||||
|
{event.title || 'Untitled'}
|
||||||
|
</Text>
|
||||||
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||||
@@ -303,21 +314,24 @@ export function EventDetailSheet({
|
|||||||
{participants.length} participants
|
{participants.length} participants
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{participants.map((p, idx) => (
|
{participants.map((p) => (
|
||||||
<View key={idx} style={styles.participantRow}>
|
<View key={p.id} style={styles.participantRow}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.participantDot,
|
styles.participantDot,
|
||||||
{
|
{
|
||||||
backgroundColor: statusColor(c, p.participationStatus),
|
backgroundColor: statusColor(c, p.status),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.participantName} numberOfLines={1}>
|
<Text style={styles.participantName} numberOfLines={1}>
|
||||||
{p.name || p.email || 'Unknown'}
|
{p.name || p.email || 'Unknown'}
|
||||||
|
{p.isOrganizer ? (
|
||||||
|
<Text style={styles.participantOrganizer}> (organizer)</Text>
|
||||||
|
) : null}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.participantStatus}>
|
<Text style={styles.participantStatus}>
|
||||||
{statusLabel(p.participationStatus)}
|
{p.isOrganizer ? '' : statusLabel(p.status)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -508,6 +522,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
|
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
|
||||||
headerText: { flex: 1 },
|
headerText: { flex: 1 },
|
||||||
title: { ...typography.h3, color: c.text },
|
title: { ...typography.h3, color: c.text },
|
||||||
|
titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted },
|
||||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||||
closeBtn: { padding: 4 },
|
closeBtn: { padding: 4 },
|
||||||
|
|
||||||
@@ -567,6 +582,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
participantDot: { width: 8, height: 8, borderRadius: 4 },
|
participantDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
participantName: { flex: 1, ...typography.body, color: c.text },
|
participantName: { flex: 1, ...typography.body, color: c.text },
|
||||||
|
participantOrganizer: { ...typography.caption, color: c.textMuted },
|
||||||
participantStatus: { ...typography.caption, color: c.textMuted },
|
participantStatus: { ...typography.caption, color: c.textMuted },
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { addHours, addMinutes, format } from 'date-fns';
|
|||||||
import type {
|
import type {
|
||||||
Calendar,
|
Calendar,
|
||||||
CalendarEvent,
|
CalendarEvent,
|
||||||
Participant,
|
|
||||||
EventLocation,
|
EventLocation,
|
||||||
RecurrenceRule,
|
RecurrenceRule,
|
||||||
VirtualLocation,
|
VirtualLocation,
|
||||||
@@ -53,6 +52,12 @@ import {
|
|||||||
type Reminder,
|
type Reminder,
|
||||||
} from '../../lib/calendar-alerts';
|
} from '../../lib/calendar-alerts';
|
||||||
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
|
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
|
||||||
|
import {
|
||||||
|
buildParticipantMap,
|
||||||
|
getParticipantList,
|
||||||
|
seedAttendees,
|
||||||
|
type Attendee,
|
||||||
|
} from '../../lib/calendar-participants';
|
||||||
import { Button } from '..';
|
import { Button } from '..';
|
||||||
import { ParticipantInput } from './ParticipantInput';
|
import { ParticipantInput } from './ParticipantInput';
|
||||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||||
@@ -74,6 +79,9 @@ interface EventModalProps {
|
|||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultCalendarId?: string;
|
defaultCalendarId?: string;
|
||||||
|
// Login address + identities + aliases; the first one is the organizer
|
||||||
|
// address of invitations sent from this editor.
|
||||||
|
currentUserEmails?: string[];
|
||||||
onSave: (data: Partial<CalendarEvent>, calendarId: string) => void | Promise<void>;
|
onSave: (data: Partial<CalendarEvent>, calendarId: string) => void | Promise<void>;
|
||||||
onDelete?: (event: CalendarEvent) => void;
|
onDelete?: (event: CalendarEvent) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -153,6 +161,7 @@ export function EventModal({
|
|||||||
calendars,
|
calendars,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
defaultCalendarId,
|
defaultCalendarId,
|
||||||
|
currentUserEmails = [],
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -174,7 +183,8 @@ export function EventModal({
|
|||||||
|| calendars[0]?.id
|
|| calendars[0]?.id
|
||||||
|| '';
|
|| '';
|
||||||
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
|
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
|
||||||
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
|
// Attendee rows only — the organizer participant is rebuilt on save.
|
||||||
|
const [attendees, setAttendees] = React.useState<Attendee[]>([]);
|
||||||
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
|
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
|
||||||
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
|
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
|
||||||
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
|
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
|
||||||
@@ -202,7 +212,7 @@ export function EventModal({
|
|||||||
// grows the event by one day.
|
// grows the event by one day.
|
||||||
setEnd(event.showWithoutTime ? getEventDisplayEndDate(event) : getEventEndDate(event));
|
setEnd(event.showWithoutTime ? getEventDisplayEndDate(event) : getEventEndDate(event));
|
||||||
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
|
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
|
||||||
setParticipants(event.participants || {});
|
setAttendees(seedAttendees(event, currentUserEmails));
|
||||||
const detected = detectRecurrence(event);
|
const detected = detectRecurrence(event);
|
||||||
setRecurrence(detected);
|
setRecurrence(detected);
|
||||||
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
|
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
|
||||||
@@ -223,7 +233,7 @@ export function EventModal({
|
|||||||
|| calendars[0]?.id
|
|| calendars[0]?.id
|
||||||
|| '',
|
|| '',
|
||||||
);
|
);
|
||||||
setParticipants({});
|
setAttendees([]);
|
||||||
setRecurrence('none');
|
setRecurrence('none');
|
||||||
setCustomRule(null);
|
setCustomRule(null);
|
||||||
setRecurrenceEditorOpen(false);
|
setRecurrenceEditorOpen(false);
|
||||||
@@ -231,7 +241,7 @@ export function EventModal({
|
|||||||
setLocation('');
|
setLocation('');
|
||||||
setVideoUrl('');
|
setVideoUrl('');
|
||||||
}
|
}
|
||||||
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
|
}, [visible, event, defaultDate, defaultCalendarId, calendars, currentUserEmails]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
@@ -295,16 +305,30 @@ export function EventModal({
|
|||||||
// every viewer; label the wall-clock with the zone it was entered in.
|
// every viewer; label the wall-clock with the zone it was entered in.
|
||||||
// All-day events are date-only and carry no zone.
|
// All-day events are date-only and carry no zone.
|
||||||
timeZone: allDay ? null : getEffectiveTimeZone(),
|
timeZone: allDay ? null : getEffectiveTimeZone(),
|
||||||
participants: clearedOr(
|
|
||||||
Object.keys(participants).length > 0 ? participants : undefined,
|
|
||||||
'participants',
|
|
||||||
),
|
|
||||||
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
|
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
|
||||||
alerts: clearedOr(remindersToAlerts(reminders), 'alerts'),
|
alerts: clearedOr(remindersToAlerts(reminders), 'alerts'),
|
||||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||||
locations: clearedOr(buildLocations(location), 'locations'),
|
locations: clearedOr(buildLocations(location), 'locations'),
|
||||||
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
|
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
|
||||||
};
|
};
|
||||||
|
const organizerEmail = currentUserEmails[0];
|
||||||
|
if (attendees.length > 0 && organizerEmail) {
|
||||||
|
const organizerName =
|
||||||
|
(event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '') || '';
|
||||||
|
data.participants = buildParticipantMap(
|
||||||
|
{ name: organizerName, email: organizerEmail },
|
||||||
|
attendees,
|
||||||
|
);
|
||||||
|
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||||
|
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||||
|
// scheduling is silently skipped, so no invites are sent.
|
||||||
|
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||||
|
} else if (attendees.length === 0 && isEdit && had('participants')) {
|
||||||
|
data.participants = null;
|
||||||
|
// Also clear the retired replyTo that older releases wrote.
|
||||||
|
if (event?.replyTo) data.replyTo = null;
|
||||||
|
if (event?.organizerCalendarAddress) data.organizerCalendarAddress = null;
|
||||||
|
}
|
||||||
if (data.recurrenceRules === null) {
|
if (data.recurrenceRules === null) {
|
||||||
// Dropping the rule leaves per-occurrence data dangling.
|
// Dropping the rule leaves per-occurrence data dangling.
|
||||||
if (had('recurrenceOverrides')) data.recurrenceOverrides = null;
|
if (had('recurrenceOverrides')) data.recurrenceOverrides = null;
|
||||||
@@ -619,8 +643,11 @@ export function EventModal({
|
|||||||
label="Participants"
|
label="Participants"
|
||||||
>
|
>
|
||||||
<ParticipantInput
|
<ParticipantInput
|
||||||
participants={participants}
|
attendees={attendees}
|
||||||
onChange={setParticipants}
|
onAdd={(a) => setAttendees((prev) => [...prev, a])}
|
||||||
|
onRemove={(email) =>
|
||||||
|
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import { X } from 'lucide-react-native';
|
|||||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useContactsStore } from '../../stores/contacts-store';
|
import { useContactsStore } from '../../stores/contacts-store';
|
||||||
import type { Participant } from '../../api/types';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import type { Attendee } from '../../lib/calendar-participants';
|
||||||
|
|
||||||
interface ParticipantInputProps {
|
interface ParticipantInputProps {
|
||||||
participants: Record<string, Participant>;
|
attendees: Attendee[];
|
||||||
onChange: (next: Record<string, Participant>) => void;
|
onAdd: (attendee: Attendee) => void;
|
||||||
|
onRemove: (email: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Suggestion {
|
interface Suggestion {
|
||||||
@@ -27,10 +29,6 @@ function emailRegex(): RegExp {
|
|||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateParticipantId(): string {
|
|
||||||
return `p-${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenContactEmails(): Suggestion[] {
|
function flattenContactEmails(): Suggestion[] {
|
||||||
const contacts = useContactsStore.getState().contacts;
|
const contacts = useContactsStore.getState().contacts;
|
||||||
const out: Suggestion[] = [];
|
const out: Suggestion[] = [];
|
||||||
@@ -44,15 +42,18 @@ function flattenContactEmails(): Suggestion[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ParticipantInput({ participants, onChange }: ParticipantInputProps) {
|
// Attendee rows only: the organizer participant is added by
|
||||||
|
// buildParticipantMap on save (see lib/calendar-participants).
|
||||||
|
export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInputProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const [draft, setDraft] = React.useState('');
|
const [draft, setDraft] = React.useState('');
|
||||||
const [allSuggestions] = React.useState(() => flattenContactEmails());
|
const [allSuggestions] = React.useState(() => flattenContactEmails());
|
||||||
|
|
||||||
const existingEmails = React.useMemo(() => {
|
const existingEmails = React.useMemo(() => {
|
||||||
return new Set(Object.values(participants).map((p) => p.email?.toLowerCase()));
|
return new Set(attendees.map((a) => a.email.toLowerCase()));
|
||||||
}, [participants]);
|
}, [attendees]);
|
||||||
|
|
||||||
const filteredSuggestions = React.useMemo(() => {
|
const filteredSuggestions = React.useMemo(() => {
|
||||||
const q = draft.trim().toLowerCase();
|
const q = draft.trim().toLowerCase();
|
||||||
@@ -75,44 +76,24 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
|||||||
setDraft('');
|
setDraft('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = generateParticipantId();
|
onAdd({ name: name || '', email: trimmed });
|
||||||
const next: Record<string, Participant> = {
|
|
||||||
...participants,
|
|
||||||
[id]: {
|
|
||||||
email: trimmed,
|
|
||||||
name,
|
|
||||||
sendTo: { imip: `mailto:${trimmed}` },
|
|
||||||
roles: { attendee: true },
|
|
||||||
participationStatus: 'needs-action',
|
|
||||||
expectReply: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
onChange(next);
|
|
||||||
setDraft('');
|
setDraft('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeParticipant = (id: string) => {
|
|
||||||
const next = { ...participants };
|
|
||||||
delete next[id];
|
|
||||||
onChange(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (draft.trim()) addParticipant(draft);
|
if (draft.trim()) addParticipant(draft);
|
||||||
};
|
};
|
||||||
|
|
||||||
const entries = Object.entries(participants);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{entries.length > 0 && (
|
{attendees.length > 0 && (
|
||||||
<View style={styles.chips}>
|
<View style={styles.chips}>
|
||||||
{entries.map(([id, p]) => (
|
{attendees.map((a) => (
|
||||||
<View key={id} style={styles.chip}>
|
<View key={a.email.toLowerCase()} style={styles.chip}>
|
||||||
<Text style={styles.chipText} numberOfLines={1}>
|
<Text style={styles.chipText} numberOfLines={1}>
|
||||||
{p.name || p.email || 'Unknown'}
|
{a.name || a.email}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => removeParticipant(id)} hitSlop={6}>
|
<Pressable onPress={() => onRemove(a.email)} hitSlop={6}>
|
||||||
<X size={12} color={c.textMuted} />
|
<X size={12} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -123,12 +104,13 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
|||||||
<TextInput
|
<TextInput
|
||||||
value={draft}
|
value={draft}
|
||||||
onChangeText={setDraft}
|
onChangeText={setDraft}
|
||||||
placeholder="Add participant by email"
|
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
onSubmitEditing={handleSubmit}
|
onSubmitEditing={handleSubmit}
|
||||||
|
onBlur={handleSubmit}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
|||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { parseCalendarBlob } from '../../api/calendar';
|
import { parseCalendarBlob } from '../../api/calendar';
|
||||||
import { useCalendarStore } from '../../stores/calendar-store';
|
import { useCalendarStore } from '../../stores/calendar-store';
|
||||||
import { useAccountStore } from '../../stores/account-store';
|
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
import {
|
import {
|
||||||
findCalendarAttachment,
|
findCalendarAttachment,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
inferInvitationMethod,
|
inferInvitationMethod,
|
||||||
type InvitationMethod,
|
type InvitationMethod,
|
||||||
} from '../../lib/calendar-invitation';
|
} from '../../lib/calendar-invitation';
|
||||||
|
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
|
||||||
|
|
||||||
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
||||||
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||||
@@ -34,7 +34,9 @@ export function CalendarInvitationBanner({ email }: Props) {
|
|||||||
const calendars = useCalendarStore((s) => s.calendars);
|
const calendars = useCalendarStore((s) => s.calendars);
|
||||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
||||||
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
// Login address + identities + aliases, so invitations addressed to an
|
||||||
|
// alias still show the RSVP buttons.
|
||||||
|
const currentUserEmails = useUserCalendarAddresses();
|
||||||
|
|
||||||
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
|
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ export function CalendarInvitationBanner({ email }: Props) {
|
|||||||
const organizer = getOrganizerName(event);
|
const organizer = getOrganizerName(event);
|
||||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||||
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
|
const me = findParticipantByEmail(event, currentUserEmails);
|
||||||
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
|
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
|
||||||
|
|
||||||
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
|
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
|
||||||
@@ -104,8 +106,8 @@ export function CalendarInvitationBanner({ email }: Props) {
|
|||||||
await importEvents([event], writableCalendar.id);
|
await importEvents([event], writableCalendar.id);
|
||||||
// Re-read from the store to get the server-assigned id + participant.
|
// Re-read from the store to get the server-assigned id + participant.
|
||||||
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
|
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
|
||||||
const participant = stored && activeEmail
|
const participant = stored
|
||||||
? findParticipantByEmail(stored, [activeEmail])
|
? findParticipantByEmail(stored, currentUserEmails)
|
||||||
: me;
|
: me;
|
||||||
if (stored && participant) {
|
if (stored && participant) {
|
||||||
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
|
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { CalendarEvent } from '../../api/types';
|
||||||
|
import {
|
||||||
|
buildParticipantMap,
|
||||||
|
collectUserCalendarAddresses,
|
||||||
|
getParticipantList,
|
||||||
|
getStatusCounts,
|
||||||
|
getUserParticipantId,
|
||||||
|
isOrganizer,
|
||||||
|
seedAttendees,
|
||||||
|
} from '../calendar-participants';
|
||||||
|
|
||||||
|
const event: Partial<CalendarEvent> = {
|
||||||
|
organizerCalendarAddress: 'mailto:Alice@example.com',
|
||||||
|
participants: {
|
||||||
|
// Stalwart rebuilds ORGANIZER into a roles-less participant (#731).
|
||||||
|
org: { calendarAddress: 'mailto:alice@example.com', name: 'Alice' },
|
||||||
|
bob: { email: 'bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||||
|
carol: { calendarAddress: 'mailto:carol@example.com', roles: { attendee: true } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildParticipantMap', () => {
|
||||||
|
it('emits an owner-only organizer plus server-scheduled attendees, deduped (#731)', () => {
|
||||||
|
const map = buildParticipantMap({ name: 'Alice', email: 'alice@example.com' }, [
|
||||||
|
{ name: 'Bob', email: 'bob@example.com' },
|
||||||
|
{ name: 'Alice again', email: 'ALICE@example.com' },
|
||||||
|
{ name: 'Bob dup', email: 'Bob@example.com' },
|
||||||
|
]);
|
||||||
|
const list = Object.values(map);
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
const organizer = list.find((p) => p.roles?.owner)!;
|
||||||
|
expect(organizer.roles).toEqual({ owner: true });
|
||||||
|
expect(organizer.calendarAddress).toBe('mailto:alice@example.com');
|
||||||
|
expect(organizer.scheduleAgent).toBe('server');
|
||||||
|
expect(organizer.sendTo).toBeUndefined();
|
||||||
|
const bob = list.find((p) => p.email === 'bob@example.com')!;
|
||||||
|
expect(bob.roles).toEqual({ attendee: true });
|
||||||
|
expect(bob.calendarAddress).toBe('mailto:bob@example.com');
|
||||||
|
expect(bob.scheduleAgent).toBe('server');
|
||||||
|
expect(bob.expectReply).toBe(true);
|
||||||
|
expect(bob.participationStatus).toBe('needs-action');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collectUserCalendarAddresses', () => {
|
||||||
|
it('merges groups, dedupes case-insensitively and drops blanks', () => {
|
||||||
|
expect(
|
||||||
|
collectUserCalendarAddresses(['Me@example.com'], ['me@example.com', ' ', null, 'alias@example.com']),
|
||||||
|
).toEqual(['Me@example.com', 'alias@example.com']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('organizer / participant detection', () => {
|
||||||
|
it('recognises the organizer via organizerCalendarAddress and an alias', () => {
|
||||||
|
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
|
||||||
|
expect(isOrganizer(event, ['bob@example.com'])).toBe(false);
|
||||||
|
expect(isOrganizer(event, [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the user participant by calendarAddress', () => {
|
||||||
|
expect(getUserParticipantId(event, ['CAROL@example.com'])).toBe('carol');
|
||||||
|
expect(getUserParticipantId(event, ['nobody@example.com'])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the roles-less organizer participant as organizer', () => {
|
||||||
|
const list = getParticipantList(event);
|
||||||
|
expect(list.find((p) => p.id === 'org')?.isOrganizer).toBe(true);
|
||||||
|
expect(list.find((p) => p.id === 'bob')?.isOrganizer).toBe(false);
|
||||||
|
expect(list.find((p) => p.id === 'carol')?.email).toBe('carol@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count the organizer in status totals', () => {
|
||||||
|
expect(getStatusCounts(event)).toEqual({ accepted: 1, declined: 0, tentative: 0, 'needs-action': 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedAttendees', () => {
|
||||||
|
it('never seeds the organizer or a repeated address (#731)', () => {
|
||||||
|
const attendees = seedAttendees(event, ['alice@example.com']);
|
||||||
|
expect(attendees.map((a) => a.email)).toEqual(['bob@example.com', 'carol@example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through buildParticipantMap without duplicating the organizer', () => {
|
||||||
|
const attendees = seedAttendees(event, ['alice@example.com']);
|
||||||
|
const map = buildParticipantMap({ name: 'Alice', email: 'alice@example.com' }, attendees);
|
||||||
|
const emails = Object.values(map).map((p) => p.email?.toLowerCase());
|
||||||
|
expect(emails.filter((e) => e === 'alice@example.com')).toHaveLength(1);
|
||||||
|
expect(emails).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import type { CalendarEvent, Participant } from '../api/types';
|
||||||
|
import { generateUUID } from './uuid';
|
||||||
|
|
||||||
|
// Port of the webmail's lib/calendar-participants.ts.
|
||||||
|
|
||||||
|
export interface ParticipantInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
status: Participant['participationStatus'];
|
||||||
|
isOrganizer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusCounts {
|
||||||
|
accepted: number;
|
||||||
|
declined: number;
|
||||||
|
tentative: number;
|
||||||
|
'needs-action': number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a participant matches any of the given email addresses.
|
||||||
|
* Checks p.email, p.calendarAddress (mailto:...), and p.sendTo values.
|
||||||
|
*/
|
||||||
|
function participantMatchesEmail(p: Participant, lowerEmails: string[]): boolean {
|
||||||
|
if (p.email && lowerEmails.includes(p.email.toLowerCase())) return true;
|
||||||
|
if (p.calendarAddress) {
|
||||||
|
const addr = p.calendarAddress.replace(/^mailto:/i, '').toLowerCase();
|
||||||
|
if (addr && lowerEmails.includes(addr)) return true;
|
||||||
|
}
|
||||||
|
if (p.sendTo) {
|
||||||
|
for (const addr of Object.values(p.sendTo)) {
|
||||||
|
const normalized = addr.replace(/^mailto:/i, '').toLowerCase();
|
||||||
|
if (normalized && lowerEmails.includes(normalized)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort scheduling address for a participant, without the mailto: scheme. */
|
||||||
|
export function getParticipantEmail(p: Participant): string {
|
||||||
|
if (p.email) return p.email;
|
||||||
|
if (p.calendarAddress) return p.calendarAddress.replace(/^mailto:/i, '');
|
||||||
|
if (p.sendTo?.imip) return p.sendTo.imip.replace(/^mailto:/i, '');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects the event-level organizer calendar address(es).
|
||||||
|
* Stalwart conveys the organizer via `organizerCalendarAddress` / `replyTo`
|
||||||
|
* rather than a participant `roles.owner` flag, so self-organized events
|
||||||
|
* imported from another server have no owner participant to match against.
|
||||||
|
*/
|
||||||
|
export function getEventOrganizerEmails(event: Partial<CalendarEvent>): string[] {
|
||||||
|
const emails: string[] = [];
|
||||||
|
if (event.organizerCalendarAddress) {
|
||||||
|
emails.push(event.organizerCalendarAddress.replace(/^mailto:/i, '').toLowerCase());
|
||||||
|
}
|
||||||
|
if (event.replyTo) {
|
||||||
|
for (const addr of Object.values(event.replyTo)) {
|
||||||
|
emails.push(addr.replace(/^mailto:/i, '').toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return emails.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the user's calendar addresses (login address, identities, account
|
||||||
|
* aliases) so isOrganizer() recognises alias-organized events as the user's
|
||||||
|
* own. De-duplicated case-insensitively (first casing kept); blanks dropped.
|
||||||
|
*/
|
||||||
|
export function collectUserCalendarAddresses(
|
||||||
|
...groups: Array<ReadonlyArray<string | null | undefined>>
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const raw of group) {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOrganizer(event: Partial<CalendarEvent>, userEmails: string[]): boolean {
|
||||||
|
if (userEmails.length === 0) return false;
|
||||||
|
const lower = userEmails.map((e) => e.toLowerCase());
|
||||||
|
|
||||||
|
if (event.participants) {
|
||||||
|
const ownerMatch = Object.values(event.participants).some(
|
||||||
|
(p) => p.roles?.owner && participantMatchesEmail(p, lower),
|
||||||
|
);
|
||||||
|
if (ownerMatch) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the event-level organizer address (Stalwart / imported events
|
||||||
|
// mark the organizer here instead of via a participant `owner` role).
|
||||||
|
return getEventOrganizerEmails(event).some((email) => lower.includes(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserParticipantId(
|
||||||
|
event: Partial<CalendarEvent>,
|
||||||
|
userEmails: string[],
|
||||||
|
): string | null {
|
||||||
|
if (!event.participants) return null;
|
||||||
|
const lower = userEmails.map((e) => e.toLowerCase());
|
||||||
|
for (const [id, p] of Object.entries(event.participants)) {
|
||||||
|
if (participantMatchesEmail(p, lower)) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserStatus(
|
||||||
|
event: Partial<CalendarEvent>,
|
||||||
|
userEmails: string[],
|
||||||
|
): Participant['participationStatus'] | null {
|
||||||
|
if (!event.participants) return null;
|
||||||
|
const lower = userEmails.map((e) => e.toLowerCase());
|
||||||
|
for (const p of Object.values(event.participants)) {
|
||||||
|
if (participantMatchesEmail(p, lower)) return p.participationStatus;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParticipantList(event: Partial<CalendarEvent>): 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 email = getParticipantEmail(p);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: p.name || '',
|
||||||
|
email,
|
||||||
|
status: p.participationStatus || 'needs-action',
|
||||||
|
isOrganizer:
|
||||||
|
!!p.roles?.owner || (!!email && organizerEmails.includes(email.toLowerCase())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusCounts(event: Partial<CalendarEvent>): StatusCounts {
|
||||||
|
const counts: StatusCounts = { accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 };
|
||||||
|
if (!event.participants) return counts;
|
||||||
|
const organizerEmails = getEventOrganizerEmails(event);
|
||||||
|
for (const p of Object.values(event.participants)) {
|
||||||
|
// The organizer is not awaiting their own reply; counting the roles-less
|
||||||
|
// participant Stalwart derives from ORGANIZER inflates the pending total.
|
||||||
|
const email = getParticipantEmail(p).toLowerCase();
|
||||||
|
if (p.roles?.owner || (email && organizerEmails.includes(email))) continue;
|
||||||
|
const s = p.participationStatus || 'needs-action';
|
||||||
|
if (s in counts) counts[s as keyof StatusCounts]++;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParticipantCount(event: Partial<CalendarEvent>): number {
|
||||||
|
if (!event.participants) return 0;
|
||||||
|
return Object.keys(event.participants).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attendee {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildParticipantMap(
|
||||||
|
organizer: { name: string; email: string },
|
||||||
|
attendees: Attendee[],
|
||||||
|
): Record<string, Participant> {
|
||||||
|
const participants: Record<string, Participant> = {};
|
||||||
|
|
||||||
|
const generateId = () => generateUUID();
|
||||||
|
|
||||||
|
// calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis
|
||||||
|
// (implemented by Stalwart); the RFC 8984 sendTo property is retired there and
|
||||||
|
// stored as an inert JSPROP, so it is intentionally not sent.
|
||||||
|
participants[generateId()] = {
|
||||||
|
'@type': 'Participant',
|
||||||
|
name: organizer.name,
|
||||||
|
email: organizer.email,
|
||||||
|
calendarAddress: `mailto:${organizer.email}`,
|
||||||
|
// owner only, NOT attendee: with roles.attendee set, Stalwart's server-side
|
||||||
|
// scheduling emits the organizer as an ATTENDEE line in addition to the
|
||||||
|
// ORGANIZER line, so the recipient sees the organizer listed twice.
|
||||||
|
roles: { owner: true },
|
||||||
|
participationStatus: 'accepted',
|
||||||
|
scheduleAgent: 'server',
|
||||||
|
expectReply: false,
|
||||||
|
kind: 'individual',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The organizer already has an entry above, and an address must not appear
|
||||||
|
// twice in the invite list, so drop both cases case-insensitively (#731).
|
||||||
|
const seen = new Set<string>([organizer.email.trim().toLowerCase()]);
|
||||||
|
|
||||||
|
attendees.forEach((a) => {
|
||||||
|
const email = a.email.trim();
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
participants[generateId()] = {
|
||||||
|
'@type': 'Participant',
|
||||||
|
name: a.name,
|
||||||
|
email,
|
||||||
|
calendarAddress: `mailto:${email}`,
|
||||||
|
roles: { attendee: true },
|
||||||
|
participationStatus: 'needs-action',
|
||||||
|
scheduleAgent: 'server',
|
||||||
|
expectReply: true,
|
||||||
|
kind: 'individual',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return participants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attendee rows an editor should start from: every participant except the
|
||||||
|
* organizer (who is re-added by buildParticipantMap on save), without
|
||||||
|
* duplicate addresses (#731).
|
||||||
|
*/
|
||||||
|
export function seedAttendees(
|
||||||
|
event: Partial<CalendarEvent> | null | undefined,
|
||||||
|
userEmails: string[],
|
||||||
|
): Attendee[] {
|
||||||
|
if (!event?.participants) return [];
|
||||||
|
const excluded = new Set<string>();
|
||||||
|
if (isOrganizer(event, userEmails) && userEmails[0]) {
|
||||||
|
excluded.add(userEmails[0].toLowerCase());
|
||||||
|
}
|
||||||
|
const out: Attendee[] = [];
|
||||||
|
for (const p of getParticipantList(event)) {
|
||||||
|
if (p.isOrganizer) continue;
|
||||||
|
const key = p.email.trim().toLowerCase();
|
||||||
|
if (!key || excluded.has(key)) continue;
|
||||||
|
excluded.add(key);
|
||||||
|
out.push({ name: p.name, email: p.email.trim() });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAccountStore } from '../stores/account-store';
|
||||||
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
|
import { jmapClient } from '../api/jmap-client';
|
||||||
|
import { fetchPrincipal } from '../api/account-security';
|
||||||
|
import { collectUserCalendarAddresses } from './calendar-participants';
|
||||||
|
|
||||||
|
// Account aliases come from x:Account/get (Stalwart's principal object) and
|
||||||
|
// only change when an admin edits the account, so they're fetched once per
|
||||||
|
// JMAP account and remembered for the session. Failure (older server, no
|
||||||
|
// permission) simply leaves the list at login address + identities.
|
||||||
|
const aliasCache = new Map<string, string[]>();
|
||||||
|
const aliasInFlight = new Map<string, Promise<string[]>>();
|
||||||
|
const aliasListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function loadAliases(accountId: string): Promise<string[]> {
|
||||||
|
const cached = aliasCache.get(accountId);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
const pending = aliasInFlight.get(accountId);
|
||||||
|
if (pending) return pending;
|
||||||
|
const p = fetchPrincipal()
|
||||||
|
.then((info) => info.emails)
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
.then((emails) => {
|
||||||
|
aliasCache.set(accountId, emails);
|
||||||
|
aliasInFlight.delete(accountId);
|
||||||
|
for (const l of aliasListeners) l();
|
||||||
|
return emails;
|
||||||
|
});
|
||||||
|
aliasInFlight.set(accountId, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: forget cached aliases (also useful after re-login). */
|
||||||
|
export function resetUserCalendarAddressCache(): void {
|
||||||
|
aliasCache.clear();
|
||||||
|
aliasInFlight.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every address the signed-in user can be addressed at for scheduling: the
|
||||||
|
* login address, the sending identities and the account aliases. Used to
|
||||||
|
* find "me" among an event's participants (RSVP), to detect alias-organized
|
||||||
|
* events as the user's own, and as the organizer address of new invites.
|
||||||
|
*/
|
||||||
|
export function useUserCalendarAddresses(): string[] {
|
||||||
|
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
||||||
|
const identities = useSettingsStore((s) => s.identities);
|
||||||
|
const [, bump] = React.useReducer((n: number) => n + 1, 0);
|
||||||
|
|
||||||
|
const accountId = jmapClient.isConnected ? jmapClient.accountId : null;
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!accountId) return;
|
||||||
|
if (aliasCache.has(accountId)) return;
|
||||||
|
aliasListeners.add(bump);
|
||||||
|
void loadAliases(accountId);
|
||||||
|
return () => { aliasListeners.delete(bump); };
|
||||||
|
}, [accountId]);
|
||||||
|
|
||||||
|
const aliases = accountId ? aliasCache.get(accountId) ?? [] : [];
|
||||||
|
return React.useMemo(
|
||||||
|
() => collectUserCalendarAddresses(
|
||||||
|
[activeEmail],
|
||||||
|
identities.map((i) => i.email),
|
||||||
|
aliases,
|
||||||
|
),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[activeEmail, identities, aliases.join('|')],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
||||||
import { useContactsStore } from '../stores/contacts-store';
|
import { useContactsStore } from '../stores/contacts-store';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
|
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
|
||||||
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
|
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
|
||||||
|
|
||||||
type ViewMode = 'month' | 'week' | 'agenda';
|
type ViewMode = 'month' | 'week' | 'agenda';
|
||||||
@@ -126,6 +127,7 @@ export default function CalendarScreen() {
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const currentUserEmails = useUserCalendarAddresses();
|
||||||
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
|
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
|
||||||
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||||
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||||
@@ -627,6 +629,7 @@ export default function CalendarScreen() {
|
|||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
timeFormat={calendarTimeFormat}
|
timeFormat={calendarTimeFormat}
|
||||||
|
currentUserEmails={currentUserEmails}
|
||||||
onClose={() => setDetailEvent(null)}
|
onClose={() => setDetailEvent(null)}
|
||||||
onEdit={handleEditFromDetail}
|
onEdit={handleEditFromDetail}
|
||||||
onDelete={handleDeleteFromDetail}
|
onDelete={handleDeleteFromDetail}
|
||||||
@@ -651,6 +654,7 @@ export default function CalendarScreen() {
|
|||||||
event={modalEvent}
|
event={modalEvent}
|
||||||
calendars={eventCalendars}
|
calendars={eventCalendars}
|
||||||
defaultDate={modalDate}
|
defaultDate={modalDate}
|
||||||
|
currentUserEmails={currentUserEmails}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDelete={handleDeleteFromModal}
|
onDelete={handleDeleteFromModal}
|
||||||
onClose={() => setModalVisible(false)}
|
onClose={() => setModalVisible(false)}
|
||||||
|
|||||||
@@ -375,12 +375,20 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
rsvpEvent: async (eventId, participantId, status, replyTo) => {
|
rsvpEvent: async (eventId, participantId, status, replyTo) => {
|
||||||
if (!participantId || participantId.includes('..')) {
|
// JMAP participant ids are opaque strings (they can contain @, ., :, /);
|
||||||
|
// the api layer RFC 6901-escapes them, so only reject empty values.
|
||||||
|
if (!participantId) {
|
||||||
throw new Error('Invalid participant ID');
|
throw new Error('Invalid participant ID');
|
||||||
}
|
}
|
||||||
const storeEvent = get().events.find((e) => e.id === eventId);
|
const storeEvent = get().events.find((e) => e.id === eventId);
|
||||||
const realId = storeEvent?.originalId || eventId;
|
const realId = storeEvent?.originalId || eventId;
|
||||||
await apiRsvpEvent(realId, participantId, status, replyTo, storeEvent?.accountId);
|
// Repair events that are missing the organizer (e.g. imported ones) so
|
||||||
|
// Stalwart can route the REPLY; never touch an existing one.
|
||||||
|
const repair =
|
||||||
|
replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress
|
||||||
|
? replyTo.imip
|
||||||
|
: undefined;
|
||||||
|
await apiRsvpEvent(realId, participantId, status, repair, storeEvent?.accountId);
|
||||||
set({
|
set({
|
||||||
events: get().events.map((e) => {
|
events: get().events.map((e) => {
|
||||||
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
||||||
|
|||||||
Reference in New Issue
Block a user