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`).
|
||||
- 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 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.
|
||||
@@ -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).
|
||||
- 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 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.
|
||||
@@ -43,12 +43,12 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
||||
|
||||
### 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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
+6
-2
@@ -328,11 +328,15 @@ export async function deleteEvents(
|
||||
|
||||
// RSVP to an invitation: patch the participant's participationStatus via a JSON
|
||||
// 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(
|
||||
eventId: string,
|
||||
participantId: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
replyTo?: Record<string, string> | null,
|
||||
repairOrganizerAddress?: string | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
@@ -341,7 +345,7 @@ export async function rsvpEvent(
|
||||
const patch: Record<string, unknown> = {
|
||||
[`participants/${escaped}/participationStatus`]: status,
|
||||
};
|
||||
if (replyTo) patch.replyTo = replyTo;
|
||||
if (repairOrganizerAddress) patch.organizerCalendarAddress = repairOrganizerAddress;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
|
||||
USING,
|
||||
|
||||
@@ -35,16 +35,17 @@ import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
getEventDisplayEndDate,
|
||||
getPrimaryCalendarId,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
||||
import {
|
||||
findParticipantByEmail,
|
||||
isOrganizerParticipant,
|
||||
} from '../../lib/calendar-invitation';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
getParticipantList,
|
||||
getUserParticipantId,
|
||||
isOrganizer,
|
||||
} from '../../lib/calendar-participants';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
@@ -53,6 +54,8 @@ interface EventDetailSheetProps {
|
||||
event: CalendarEvent | null;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
// Login address + identities + aliases; finds "me" among the participants.
|
||||
currentUserEmails?: string[];
|
||||
onClose: () => void;
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
@@ -61,7 +64,10 @@ interface EventDetailSheetProps {
|
||||
}
|
||||
|
||||
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 (
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
@@ -105,6 +111,7 @@ export function EventDetailSheet({
|
||||
event,
|
||||
calendars,
|
||||
timeFormat,
|
||||
currentUserEmails = [],
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -112,7 +119,6 @@ export function EventDetailSheet({
|
||||
onRsvp,
|
||||
}: EventDetailSheetProps) {
|
||||
const c = useColors();
|
||||
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
||||
const [rsvpBusy, setRsvpBusy] = React.useState(false);
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
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 recurrence = recurrenceLabel(event);
|
||||
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 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
|
||||
// participant and the caller wired an onRsvp handler.
|
||||
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
|
||||
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
|
||||
const myStatus = me?.participant.participationStatus;
|
||||
// participant (matched against every address of theirs: login, identities,
|
||||
// aliases) and the caller wired an onRsvp handler.
|
||||
const myParticipantId = getUserParticipantId(event, currentUserEmails);
|
||||
const userIsOrganizer = isOrganizer(event, currentUserEmails);
|
||||
const canRsvp = Boolean(onRsvp && myParticipantId && !userIsOrganizer);
|
||||
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
|
||||
|
||||
const doRsvp = async (status: RsvpStatus) => {
|
||||
if (!onRsvp || !me || rsvpBusy) return;
|
||||
if (!onRsvp || !myParticipantId || rsvpBusy) return;
|
||||
setRsvpBusy(true);
|
||||
try {
|
||||
await onRsvp(event, me.id, status);
|
||||
await onRsvp(event, myParticipantId, status);
|
||||
} finally {
|
||||
setRsvpBusy(false);
|
||||
}
|
||||
@@ -206,7 +215,9 @@ export function EventDetailSheet({
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
||||
<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>}
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
@@ -303,21 +314,24 @@ export function EventDetailSheet({
|
||||
{participants.length} participants
|
||||
</Text>
|
||||
</View>
|
||||
{participants.map((p, idx) => (
|
||||
<View key={idx} style={styles.participantRow}>
|
||||
{participants.map((p) => (
|
||||
<View key={p.id} style={styles.participantRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.participantDot,
|
||||
{
|
||||
backgroundColor: statusColor(c, p.participationStatus),
|
||||
backgroundColor: statusColor(c, p.status),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.participantName} numberOfLines={1}>
|
||||
{p.name || p.email || 'Unknown'}
|
||||
{p.isOrganizer ? (
|
||||
<Text style={styles.participantOrganizer}> (organizer)</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<Text style={styles.participantStatus}>
|
||||
{statusLabel(p.participationStatus)}
|
||||
{p.isOrganizer ? '' : statusLabel(p.status)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -508,6 +522,7 @@ function makeStyles(c: ThemePalette) {
|
||||
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
|
||||
headerText: { flex: 1 },
|
||||
title: { ...typography.h3, color: c.text },
|
||||
titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted },
|
||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
closeBtn: { padding: 4 },
|
||||
|
||||
@@ -567,6 +582,7 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
participantDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
participantName: { flex: 1, ...typography.body, color: c.text },
|
||||
participantOrganizer: { ...typography.caption, color: c.textMuted },
|
||||
participantStatus: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
actions: {
|
||||
|
||||
@@ -29,7 +29,6 @@ import { addHours, addMinutes, format } from 'date-fns';
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
Participant,
|
||||
EventLocation,
|
||||
RecurrenceRule,
|
||||
VirtualLocation,
|
||||
@@ -53,6 +52,12 @@ import {
|
||||
type Reminder,
|
||||
} from '../../lib/calendar-alerts';
|
||||
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
|
||||
import {
|
||||
buildParticipantMap,
|
||||
getParticipantList,
|
||||
seedAttendees,
|
||||
type Attendee,
|
||||
} from '../../lib/calendar-participants';
|
||||
import { Button } from '..';
|
||||
import { ParticipantInput } from './ParticipantInput';
|
||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||
@@ -74,6 +79,9 @@ interface EventModalProps {
|
||||
calendars: Calendar[];
|
||||
defaultDate?: Date;
|
||||
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>;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
onClose: () => void;
|
||||
@@ -153,6 +161,7 @@ export function EventModal({
|
||||
calendars,
|
||||
defaultDate,
|
||||
defaultCalendarId,
|
||||
currentUserEmails = [],
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
@@ -174,7 +183,8 @@ export function EventModal({
|
||||
|| calendars[0]?.id
|
||||
|| '';
|
||||
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 [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
|
||||
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
|
||||
@@ -202,7 +212,7 @@ export function EventModal({
|
||||
// grows the event by one day.
|
||||
setEnd(event.showWithoutTime ? getEventDisplayEndDate(event) : getEventEndDate(event));
|
||||
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
|
||||
setParticipants(event.participants || {});
|
||||
setAttendees(seedAttendees(event, currentUserEmails));
|
||||
const detected = detectRecurrence(event);
|
||||
setRecurrence(detected);
|
||||
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
|
||||
@@ -223,7 +233,7 @@ export function EventModal({
|
||||
|| calendars[0]?.id
|
||||
|| '',
|
||||
);
|
||||
setParticipants({});
|
||||
setAttendees([]);
|
||||
setRecurrence('none');
|
||||
setCustomRule(null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
@@ -231,7 +241,7 @@ export function EventModal({
|
||||
setLocation('');
|
||||
setVideoUrl('');
|
||||
}
|
||||
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
|
||||
}, [visible, event, defaultDate, defaultCalendarId, calendars, currentUserEmails]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (allDay) {
|
||||
@@ -295,16 +305,30 @@ export function EventModal({
|
||||
// every viewer; label the wall-clock with the zone it was entered in.
|
||||
// All-day events are date-only and carry no zone.
|
||||
timeZone: allDay ? null : getEffectiveTimeZone(),
|
||||
participants: clearedOr(
|
||||
Object.keys(participants).length > 0 ? participants : undefined,
|
||||
'participants',
|
||||
),
|
||||
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
|
||||
alerts: clearedOr(remindersToAlerts(reminders), 'alerts'),
|
||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||
locations: clearedOr(buildLocations(location), 'locations'),
|
||||
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) {
|
||||
// Dropping the rule leaves per-occurrence data dangling.
|
||||
if (had('recurrenceOverrides')) data.recurrenceOverrides = null;
|
||||
@@ -619,8 +643,11 @@ export function EventModal({
|
||||
label="Participants"
|
||||
>
|
||||
<ParticipantInput
|
||||
participants={participants}
|
||||
onChange={setParticipants}
|
||||
attendees={attendees}
|
||||
onAdd={(a) => setAttendees((prev) => [...prev, a])}
|
||||
onRemove={(email) =>
|
||||
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ import { X } from 'lucide-react-native';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
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 {
|
||||
participants: Record<string, Participant>;
|
||||
onChange: (next: Record<string, Participant>) => void;
|
||||
attendees: Attendee[];
|
||||
onAdd: (attendee: Attendee) => void;
|
||||
onRemove: (email: string) => void;
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
@@ -27,10 +29,6 @@ function emailRegex(): RegExp {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
}
|
||||
|
||||
function generateParticipantId(): string {
|
||||
return `p-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function flattenContactEmails(): Suggestion[] {
|
||||
const contacts = useContactsStore.getState().contacts;
|
||||
const out: Suggestion[] = [];
|
||||
@@ -44,15 +42,18 @@ function flattenContactEmails(): Suggestion[] {
|
||||
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 styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [draft, setDraft] = React.useState('');
|
||||
const [allSuggestions] = React.useState(() => flattenContactEmails());
|
||||
|
||||
const existingEmails = React.useMemo(() => {
|
||||
return new Set(Object.values(participants).map((p) => p.email?.toLowerCase()));
|
||||
}, [participants]);
|
||||
return new Set(attendees.map((a) => a.email.toLowerCase()));
|
||||
}, [attendees]);
|
||||
|
||||
const filteredSuggestions = React.useMemo(() => {
|
||||
const q = draft.trim().toLowerCase();
|
||||
@@ -75,44 +76,24 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
||||
setDraft('');
|
||||
return;
|
||||
}
|
||||
const id = generateParticipantId();
|
||||
const next: Record<string, Participant> = {
|
||||
...participants,
|
||||
[id]: {
|
||||
email: trimmed,
|
||||
name,
|
||||
sendTo: { imip: `mailto:${trimmed}` },
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
expectReply: true,
|
||||
},
|
||||
};
|
||||
onChange(next);
|
||||
onAdd({ name: name || '', email: trimmed });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const removeParticipant = (id: string) => {
|
||||
const next = { ...participants };
|
||||
delete next[id];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (draft.trim()) addParticipant(draft);
|
||||
};
|
||||
|
||||
const entries = Object.entries(participants);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{entries.length > 0 && (
|
||||
{attendees.length > 0 && (
|
||||
<View style={styles.chips}>
|
||||
{entries.map(([id, p]) => (
|
||||
<View key={id} style={styles.chip}>
|
||||
{attendees.map((a) => (
|
||||
<View key={a.email.toLowerCase()} style={styles.chip}>
|
||||
<Text style={styles.chipText} numberOfLines={1}>
|
||||
{p.name || p.email || 'Unknown'}
|
||||
{a.name || a.email}
|
||||
</Text>
|
||||
<Pressable onPress={() => removeParticipant(id)} hitSlop={6}>
|
||||
<Pressable onPress={() => onRemove(a.email)} hitSlop={6}>
|
||||
<X size={12} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -123,12 +104,13 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Add participant by email"
|
||||
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
onSubmitEditing={handleSubmit}
|
||||
onBlur={handleSubmit}
|
||||
returnKeyType="done"
|
||||
style={styles.input}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { parseCalendarBlob } from '../../api/calendar';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
findCalendarAttachment,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
inferInvitationMethod,
|
||||
type InvitationMethod,
|
||||
} from '../../lib/calendar-invitation';
|
||||
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
|
||||
|
||||
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
||||
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||
@@ -34,7 +34,9 @@ export function CalendarInvitationBanner({ email }: Props) {
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
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]);
|
||||
|
||||
@@ -90,7 +92,7 @@ export function CalendarInvitationBanner({ email }: Props) {
|
||||
const organizer = getOrganizerName(event);
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : 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 writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
|
||||
@@ -104,8 +106,8 @@ export function CalendarInvitationBanner({ email }: Props) {
|
||||
await importEvents([event], writableCalendar.id);
|
||||
// Re-read from the store to get the server-assigned id + participant.
|
||||
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
|
||||
const participant = stored && activeEmail
|
||||
? findParticipantByEmail(stored, [activeEmail])
|
||||
const participant = stored
|
||||
? findParticipantByEmail(stored, currentUserEmails)
|
||||
: me;
|
||||
if (stored && participant) {
|
||||
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 { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
|
||||
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
@@ -126,6 +127,7 @@ export default function CalendarScreen() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const currentUserEmails = useUserCalendarAddresses();
|
||||
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
|
||||
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||
@@ -627,6 +629,7 @@ export default function CalendarScreen() {
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
@@ -651,6 +654,7 @@ export default function CalendarScreen() {
|
||||
event={modalEvent}
|
||||
calendars={eventCalendars}
|
||||
defaultDate={modalDate}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
onClose={() => setModalVisible(false)}
|
||||
|
||||
@@ -375,12 +375,20 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
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');
|
||||
}
|
||||
const storeEvent = get().events.find((e) => e.id === 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({
|
||||
events: get().events.map((e) => {
|
||||
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
||||
|
||||
Reference in New Issue
Block a user