mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: gate event editing on calendar rights and keep read-only calendars out of the pickers (#762)
This commit is contained in:
@@ -95,22 +95,22 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
|||||||
|
|
||||||
### Invitations / scheduling
|
### Invitations / scheduling
|
||||||
|
|
||||||
- [ ] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity`
|
- [x] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity` — fixed in 5dc4070
|
||||||
- What WEB does: `buildParticipantMap` emits an owner-only organizer participant with `calendarAddress`, attendees with `calendarAddress` + `scheduleAgent: 'server'`, and the modal sets `organizerCalendarAddress: mailto:<user>` — without it Stalwart emits no ORGANIZER and silently skips iTIP (ref `lib/calendar-participants.ts:164-214`, `components/calendar/event-modal.tsx:573-586`, changelog 1.7.0 "Send calendar invites by setting organizerCalendarAddress", 1.7.2 #500 "Use calendarAddress, drop retired sendTo", 1.8.1 #731).
|
- What WEB does: `buildParticipantMap` emits an owner-only organizer participant with `calendarAddress`, attendees with `calendarAddress` + `scheduleAgent: 'server'`, and the modal sets `organizerCalendarAddress: mailto:<user>` — without it Stalwart emits no ORGANIZER and silently skips iTIP (ref `lib/calendar-participants.ts:164-214`, `components/calendar/event-modal.tsx:573-586`, changelog 1.7.0 "Send calendar invites by setting organizerCalendarAddress", 1.7.2 #500 "Use calendarAddress, drop retired sendTo", 1.8.1 #731).
|
||||||
- What RN does: `ParticipantInput.addParticipant` writes `{ email, sendTo: { imip }, roles: { attendee } }` (`src/components/calendar/ParticipantInput.tsx:71-92`), no organizer participant and no `organizerCalendarAddress` anywhere in `EventModal.handleSave` (`:262-281`). `sendSchedulingMessages: true` is passed (`src/stores/calendar-store.ts:293-297`) but Stalwart has nothing to schedule with. On Stalwart the attendee's `sendTo` is stored as an inert JSPROP.
|
- What RN does: `ParticipantInput.addParticipant` writes `{ email, sendTo: { imip }, roles: { attendee } }` (`src/components/calendar/ParticipantInput.tsx:71-92`), no organizer participant and no `organizerCalendarAddress` anywhere in `EventModal.handleSave` (`:262-281`). `sendSchedulingMessages: true` is passed (`src/stores/calendar-store.ts:293-297`) but Stalwart has nothing to schedule with. On Stalwart the attendee's `sendTo` is stored as an inert JSPROP.
|
||||||
- Fix hint: port `buildParticipantMap` + `collectUserCalendarAddresses`; on save with attendees set `participants = buildParticipantMap({name, email: activeAccount.email}, attendees)` and `organizerCalendarAddress = mailto:<email>`; on edit keep existing participant ids and never re-add the organizer (#731).
|
- Fix hint: port `buildParticipantMap` + `collectUserCalendarAddresses`; on save with attendees set `participants = buildParticipantMap({name, email: activeAccount.email}, attendees)` and `organizerCalendarAddress = mailto:<email>`; on edit keep existing participant ids and never re-add the organizer (#731).
|
||||||
|
|
||||||
- [ ] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing`
|
- [x] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing` — fixed in 5dc4070
|
||||||
- What WEB does: `currentUserEmails` = identities + account aliases via `collectUserCalendarAddresses`, used for `getUserParticipantId`, organizer detection and editability (ref `lib/calendar-participants.ts:71-112`, changelog 1.9.0 "including alias organizers").
|
- What WEB does: `currentUserEmails` = identities + account aliases via `collectUserCalendarAddresses`, used for `getUserParticipantId`, organizer detection and editability (ref `lib/calendar-participants.ts:71-112`, changelog 1.9.0 "including alias organizers").
|
||||||
- What RN does: `findParticipantByEmail(event, [activeEmail])` with the single login address (`src/components/calendar/EventDetailSheet.tsx:115, 173`, `src/components/email/CalendarInvitationBanner.tsx:98`). Invitations addressed to an alias show no RSVP buttons.
|
- What RN does: `findParticipantByEmail(event, [activeEmail])` with the single login address (`src/components/calendar/EventDetailSheet.tsx:115, 173`, `src/components/email/CalendarInvitationBanner.tsx:98`). Invitations addressed to an alias show no RSVP buttons.
|
||||||
- Fix hint: RN already loads identities for the composer; collect their emails (+ aliases from `x:Account/get` if available) and pass the array.
|
- Fix hint: RN already loads identities for the composer; collect their emails (+ aliases from `x:Account/get` if available) and pass the array.
|
||||||
|
|
||||||
- [ ] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial`
|
- [x] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial` — fixed in 5dc4070
|
||||||
- What WEB does: only when the event lacks `organizerCalendarAddress` it sets it from `replyTo.imip`; never sends `replyTo` (retired) (ref `stores/calendar-store.ts:782-789`).
|
- What WEB does: only when the event lacks `organizerCalendarAddress` it sets it from `replyTo.imip`; never sends `replyTo` (retired) (ref `stores/calendar-store.ts:782-789`).
|
||||||
- What RN does: `rsvpEvent` sets `patch.replyTo = replyTo` whenever `buildReplyTo` returns something (`src/api/calendar.ts:344`, `src/lib/calendar-invitation.ts:74-83`). Harmless on Stalwart (ignored) but the repair path for imported invites lacking an organizer is missing, so those REPLYs are not routed.
|
- What RN does: `rsvpEvent` sets `patch.replyTo = replyTo` whenever `buildReplyTo` returns something (`src/api/calendar.ts:344`, `src/lib/calendar-invitation.ts:74-83`). Harmless on Stalwart (ignored) but the repair path for imported invites lacking an organizer is missing, so those REPLYs are not routed.
|
||||||
- Fix hint: mirror WEB: `if (replyTo?.imip && !storeEvent.organizerCalendarAddress) patch.organizerCalendarAddress = replyTo.imip`.
|
- Fix hint: mirror WEB: `if (replyTo?.imip && !storeEvent.organizerCalendarAddress) patch.organizerCalendarAddress = replyTo.imip`.
|
||||||
|
|
||||||
- [ ] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug`
|
- [x] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug` — fixed in 5dc4070
|
||||||
- What WEB does: participant ids are opaque and RFC 6901-escaped; only empty ids are rejected (ref `stores/calendar-store.ts:763-770`).
|
- What WEB does: participant ids are opaque and RFC 6901-escaped; only empty ids are rejected (ref `stores/calendar-store.ts:763-770`).
|
||||||
- What RN does: `if (!participantId || participantId.includes('..')) throw` (`src/stores/calendar-store.ts:332-334`); a server-generated id like `a..b` cannot RSVP.
|
- What RN does: `if (!participantId || participantId.includes('..')) throw` (`src/stores/calendar-store.ts:332-334`); a server-generated id like `a..b` cannot RSVP.
|
||||||
- Fix hint: drop the `..` check.
|
- Fix hint: drop the `..` check.
|
||||||
@@ -214,7 +214,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
|||||||
- What RN does: `event.description` rendered with `<MapPin/>` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read.
|
- What RN does: `event.description` rendered with `<MapPin/>` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read.
|
||||||
- Fix hint: show `Object.values(event.locations)[0]?.name` with MapPin; drop or move the description.
|
- Fix hint: show `Object.values(event.locations)[0]?.name` with MapPin; drop or move the description.
|
||||||
|
|
||||||
- [ ] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug`
|
- [x] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug` — fixed in 5dc4070
|
||||||
- What WEB does: multi-day all-day events show the inclusive end date (changelog 1.6.x #318, `getEventDisplayEndDate`).
|
- What WEB does: multi-day all-day events show the inclusive end date (changelog 1.6.x #318, `getEventDisplayEndDate`).
|
||||||
- What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`).
|
- What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`).
|
||||||
- Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting.
|
- Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting.
|
||||||
|
|||||||
@@ -532,8 +532,6 @@ export interface CalendarRights {
|
|||||||
mayRSVP?: boolean;
|
mayRSVP?: boolean;
|
||||||
mayShare?: boolean;
|
mayShare?: boolean;
|
||||||
mayDelete?: boolean;
|
mayDelete?: boolean;
|
||||||
// Legacy short flag still read by the sidebar drawer.
|
|
||||||
mayWrite?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Calendar {
|
export interface Calendar {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import { useAnimDuration } from '../../theme/dynamic';
|
import { useAnimDuration } from '../../theme/dynamic';
|
||||||
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
|
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
|
||||||
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||||
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
|
|
||||||
interface CalendarSidebarDrawerProps {
|
interface CalendarSidebarDrawerProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -90,12 +91,8 @@ export function CalendarSidebarDrawer({
|
|||||||
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
|
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
|
||||||
|
|
||||||
const sharedCalendars = calendars.filter((cal) => cal.isShared);
|
const sharedCalendars = calendars.filter((cal) => cal.isShared);
|
||||||
const myCalendars = calendars.filter(
|
const myCalendars = calendars.filter((cal) => !cal.isShared && isWritableCalendar(cal));
|
||||||
(cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false),
|
const subscribed = calendars.filter((cal) => !cal.isShared && !isWritableCalendar(cal));
|
||||||
);
|
|
||||||
const subscribed = calendars.filter(
|
|
||||||
(cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const sectionProps = {
|
const sectionProps = {
|
||||||
hiddenSet,
|
hiddenSet,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
} from '../../lib/calendar-participants';
|
} from '../../lib/calendar-participants';
|
||||||
|
import { getEventEditability } from '../../lib/calendar-editability';
|
||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
|
|
||||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||||
@@ -56,6 +57,8 @@ interface EventDetailSheetProps {
|
|||||||
timeFormat?: TimeFormat;
|
timeFormat?: TimeFormat;
|
||||||
// Login address + identities + aliases; finds "me" among the participants.
|
// Login address + identities + aliases; finds "me" among the participants.
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
|
// Client-side iCal subscriptions are always read-only.
|
||||||
|
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEdit?: (event: CalendarEvent) => void;
|
onEdit?: (event: CalendarEvent) => void;
|
||||||
onDelete?: (event: CalendarEvent) => void;
|
onDelete?: (event: CalendarEvent) => void;
|
||||||
@@ -112,6 +115,7 @@ export function EventDetailSheet({
|
|||||||
calendars,
|
calendars,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
|
isSubscriptionCalendar,
|
||||||
onClose,
|
onClose,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -180,7 +184,18 @@ export function EventDetailSheet({
|
|||||||
// aliases) and the caller wired an onRsvp handler.
|
// aliases) and the caller wired an onRsvp handler.
|
||||||
const myParticipantId = getUserParticipantId(event, currentUserEmails);
|
const myParticipantId = getUserParticipantId(event, currentUserEmails);
|
||||||
const userIsOrganizer = isOrganizer(event, currentUserEmails);
|
const userIsOrganizer = isOrganizer(event, currentUserEmails);
|
||||||
const canRsvp = Boolean(onRsvp && myParticipantId && !userIsOrganizer);
|
// Gate affordances on calendar rights first, then identity: read-only /
|
||||||
|
// subscription calendars never offer Edit/Delete, RSVP-only events offer
|
||||||
|
// just the reply buttons.
|
||||||
|
const editability = getEventEditability(event, {
|
||||||
|
calendarsById: new Map(calendars.map((cal) => [cal.id, cal])),
|
||||||
|
userCalendarAddresses: currentUserEmails,
|
||||||
|
isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false),
|
||||||
|
});
|
||||||
|
const canEdit = editability === 'editable';
|
||||||
|
const canRsvp = Boolean(
|
||||||
|
onRsvp && myParticipantId && !userIsOrganizer && editability !== 'read-only',
|
||||||
|
);
|
||||||
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
|
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
|
||||||
|
|
||||||
const doRsvp = async (status: RsvpStatus) => {
|
const doRsvp = async (status: RsvpStatus) => {
|
||||||
@@ -339,30 +354,32 @@ export function EventDetailSheet({
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
{(canEdit || onDuplicate) && (
|
||||||
{onEdit && (
|
<View style={styles.actions}>
|
||||||
<ActionButton
|
{onEdit && canEdit && (
|
||||||
icon={<Pencil size={18} color={c.text} />}
|
<ActionButton
|
||||||
label="Edit"
|
icon={<Pencil size={18} color={c.text} />}
|
||||||
onPress={() => onEdit(event)}
|
label="Edit"
|
||||||
/>
|
onPress={() => onEdit(event)}
|
||||||
)}
|
/>
|
||||||
{onDuplicate && (
|
)}
|
||||||
<ActionButton
|
{onDuplicate && (
|
||||||
icon={<Copy size={18} color={c.text} />}
|
<ActionButton
|
||||||
label="Duplicate"
|
icon={<Copy size={18} color={c.text} />}
|
||||||
onPress={() => onDuplicate(event)}
|
label="Duplicate"
|
||||||
/>
|
onPress={() => onDuplicate(event)}
|
||||||
)}
|
/>
|
||||||
{onDelete && (
|
)}
|
||||||
<ActionButton
|
{onDelete && canEdit && (
|
||||||
icon={<Trash2 size={18} color={c.error} />}
|
<ActionButton
|
||||||
label="Delete"
|
icon={<Trash2 size={18} color={c.error} />}
|
||||||
onPress={() => onDelete(event)}
|
label="Delete"
|
||||||
destructive
|
onPress={() => onDelete(event)}
|
||||||
/>
|
destructive
|
||||||
)}
|
/>
|
||||||
</View>
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
getPrimaryCalendarId,
|
getPrimaryCalendarId,
|
||||||
} from '../../lib/calendar-utils';
|
} from '../../lib/calendar-utils';
|
||||||
import { getEffectiveTimeZone } from '../../lib/calendar-timezone';
|
import { getEffectiveTimeZone } from '../../lib/calendar-timezone';
|
||||||
|
import { canCreateEventsIn } from '../../lib/calendar-editability';
|
||||||
import {
|
import {
|
||||||
alertsToReminders,
|
alertsToReminders,
|
||||||
remindersToAlerts,
|
remindersToAlerts,
|
||||||
@@ -82,6 +83,8 @@ interface EventModalProps {
|
|||||||
// Login address + identities + aliases; the first one is the organizer
|
// Login address + identities + aliases; the first one is the organizer
|
||||||
// address of invitations sent from this editor.
|
// address of invitations sent from this editor.
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
|
// Client-side iCal subscriptions are read-only targets (#762).
|
||||||
|
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||||
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;
|
||||||
@@ -162,6 +165,7 @@ export function EventModal({
|
|||||||
defaultDate,
|
defaultDate,
|
||||||
defaultCalendarId,
|
defaultCalendarId,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
|
isSubscriptionCalendar,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -177,9 +181,11 @@ export function EventModal({
|
|||||||
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
|
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
|
||||||
// New events land in the explicitly requested calendar, else the account's
|
// New events land in the explicitly requested calendar, else the account's
|
||||||
// default calendar, else the first one.
|
// default calendar, else the first one.
|
||||||
|
// Never default new events into a subscription / read-only calendar (#762).
|
||||||
const fallbackCalendarId =
|
const fallbackCalendarId =
|
||||||
defaultCalendarId
|
defaultCalendarId
|
||||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|
|| calendars.find((cal) => cal.isDefault && !cal.isShared && canCreateEventsIn(cal, isSubscriptionCalendar))?.id
|
||||||
|
|| calendars.find((cal) => canCreateEventsIn(cal, isSubscriptionCalendar))?.id
|
||||||
|| calendars[0]?.id
|
|| calendars[0]?.id
|
||||||
|| '';
|
|| '';
|
||||||
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
|
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
|
||||||
@@ -227,12 +233,7 @@ export function EventModal({
|
|||||||
setAllDay(false);
|
setAllDay(false);
|
||||||
setStart(d);
|
setStart(d);
|
||||||
setEnd(addHours(d, 1));
|
setEnd(addHours(d, 1));
|
||||||
setCalendarId(
|
setCalendarId(fallbackCalendarId);
|
||||||
defaultCalendarId
|
|
||||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|
|
||||||
|| calendars[0]?.id
|
|
||||||
|| '',
|
|
||||||
);
|
|
||||||
setAttendees([]);
|
setAttendees([]);
|
||||||
setRecurrence('none');
|
setRecurrence('none');
|
||||||
setCustomRule(null);
|
setCustomRule(null);
|
||||||
@@ -241,7 +242,7 @@ export function EventModal({
|
|||||||
setLocation('');
|
setLocation('');
|
||||||
setVideoUrl('');
|
setVideoUrl('');
|
||||||
}
|
}
|
||||||
}, [visible, event, defaultDate, defaultCalendarId, calendars, currentUserEmails]);
|
}, [visible, event, defaultDate, fallbackCalendarId, calendars, currentUserEmails]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
@@ -349,6 +350,19 @@ export function EventModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedCalendar = calendars.find((c) => c.id === calendarId);
|
const selectedCalendar = calendars.find((c) => c.id === calendarId);
|
||||||
|
// Calendars offered as create/move targets: writable, non-subscription, in
|
||||||
|
// the same account as the event being edited (a cross-account move would
|
||||||
|
// need delete + recreate), plus the event's current calendar so an
|
||||||
|
// in-place edit never blanks the picker (#762).
|
||||||
|
const selectableCalendars = React.useMemo(() => {
|
||||||
|
const eventAccount = event ? (event.accountId ?? null) : null;
|
||||||
|
return calendars.filter((c) => {
|
||||||
|
if (c.id === calendarId) return true;
|
||||||
|
if (!canCreateEventsIn(c, isSubscriptionCalendar)) return false;
|
||||||
|
if (event && (c.accountId ?? null) !== eventAccount) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [calendars, calendarId, event, isSubscriptionCalendar]);
|
||||||
const recurrenceLabel =
|
const recurrenceLabel =
|
||||||
recurrence === 'custom'
|
recurrence === 'custom'
|
||||||
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
|
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
|
||||||
@@ -430,7 +444,7 @@ export function EventModal({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
{calendarOpen && (
|
{calendarOpen && (
|
||||||
<View style={styles.popover}>
|
<View style={styles.popover}>
|
||||||
{calendars.map((c) => (
|
{selectableCalendars.map((c) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={c.id}
|
key={c.id}
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Button from '../Button';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||||
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
|
|
||||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||||
@@ -49,7 +50,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const writable = React.useMemo(
|
const writable = React.useMemo(
|
||||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
() => calendars.filter((cal) => isWritableCalendar(cal)),
|
||||||
[calendars],
|
[calendars],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { Calendar, CalendarEvent } from '../../api/types';
|
|||||||
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 { getCalendarColor } from '../../lib/calendar-utils';
|
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||||
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
|
|
||||||
interface TasksSheetProps {
|
interface TasksSheetProps {
|
||||||
@@ -82,7 +83,7 @@ export function TasksSheet({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const writableCalendars = React.useMemo(
|
const writableCalendars = React.useMemo(
|
||||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
() => calendars.filter((cal) => isWritableCalendar(cal)),
|
||||||
[calendars],
|
[calendars],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
type InvitationMethod,
|
type InvitationMethod,
|
||||||
} from '../../lib/calendar-invitation';
|
} from '../../lib/calendar-invitation';
|
||||||
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
|
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
|
||||||
|
import { canCreateEventsIn } from '../../lib/calendar-editability';
|
||||||
|
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
||||||
|
|
||||||
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
||||||
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||||
@@ -32,6 +34,7 @@ export function CalendarInvitationBanner({ email }: Props) {
|
|||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||||
const calendars = useCalendarStore((s) => s.calendars);
|
const calendars = useCalendarStore((s) => s.calendars);
|
||||||
|
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
||||||
// Login address + identities + aliases, so invitations addressed to an
|
// Login address + identities + aliases, so invitations addressed to an
|
||||||
@@ -95,7 +98,15 @@ export function CalendarInvitationBanner({ email }: Props) {
|
|||||||
const me = findParticipantByEmail(event, currentUserEmails);
|
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);
|
// Import into the account's default calendar; never into a shared calendar,
|
||||||
|
// an iCal subscription (the next feed sync would delete the event) or a
|
||||||
|
// read-only one. Fall back to the first calendar events may be created in.
|
||||||
|
const isSubscriptionCalendar = (id: string) =>
|
||||||
|
subscriptions.some((s) => s.calendarId === id);
|
||||||
|
const candidates = calendars.filter(
|
||||||
|
(cal) => !cal.isShared && canCreateEventsIn(cal, isSubscriptionCalendar),
|
||||||
|
);
|
||||||
|
const writableCalendar = candidates.find((cal) => cal.isDefault) ?? candidates[0];
|
||||||
|
|
||||||
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
|
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
|
||||||
if (busy || !writableCalendar) return;
|
if (busy || !writableCalendar) return;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
|
import {
|
||||||
|
canCreateEventsIn,
|
||||||
|
eventHasNoOwner,
|
||||||
|
getEventEditability,
|
||||||
|
isWritableCalendar,
|
||||||
|
} from '../calendar-editability';
|
||||||
|
|
||||||
|
const base: CalendarEvent = {
|
||||||
|
id: 'ev',
|
||||||
|
uid: 'u',
|
||||||
|
title: 'T',
|
||||||
|
start: '2026-03-01T09:00:00',
|
||||||
|
calendarIds: { 'cal-1': true },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ctx(rights: Calendar['myRights'] | undefined, subs: string[] = []) {
|
||||||
|
return {
|
||||||
|
calendarsById: new Map([['cal-1', { myRights: rights }]]),
|
||||||
|
userCalendarAddresses: ['me@example.com', 'alias@example.com'],
|
||||||
|
isSubscriptionCalendar: (id: string) => subs.includes(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isWritableCalendar / canCreateEventsIn', () => {
|
||||||
|
it('treats missing rights as writable and never reads mayWrite', () => {
|
||||||
|
expect(isWritableCalendar({})).toBe(true);
|
||||||
|
expect(isWritableCalendar({ myRights: { mayWriteOwn: true } })).toBe(true);
|
||||||
|
expect(isWritableCalendar({ myRights: { mayReadItems: true } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes iCal subscriptions even when the calendar is writable (#762)', () => {
|
||||||
|
const cal = { id: 'cal-1', myRights: { mayWriteAll: true } };
|
||||||
|
expect(canCreateEventsIn(cal)).toBe(true);
|
||||||
|
expect(canCreateEventsIn(cal, (id) => id === 'cal-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEventEditability', () => {
|
||||||
|
it('is read-only on a subscription calendar regardless of rights', () => {
|
||||||
|
expect(getEventEditability(base, ctx({ mayWriteAll: true }, ['cal-1']))).toBe('read-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is editable with mayWriteAll', () => {
|
||||||
|
expect(getEventEditability(base, ctx({ mayWriteAll: true }))).toBe('editable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is editable with mayWriteOwn only for own/ownerless events (alias organizer counts)', () => {
|
||||||
|
expect(getEventEditability(base, ctx({ mayWriteOwn: true }))).toBe('editable');
|
||||||
|
const foreign = { ...base, organizerCalendarAddress: 'mailto:other@example.com' };
|
||||||
|
expect(getEventEditability(foreign, ctx({ mayWriteOwn: true }))).toBe('read-only');
|
||||||
|
const mine = { ...base, organizerCalendarAddress: 'mailto:alias@example.com' };
|
||||||
|
expect(getEventEditability(mine, ctx({ mayWriteOwn: true }))).toBe('editable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is rsvp-only for a participant with mayRSVP but no write right', () => {
|
||||||
|
const invited = {
|
||||||
|
...base,
|
||||||
|
organizerCalendarAddress: 'mailto:other@example.com',
|
||||||
|
participants: { p1: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true } } },
|
||||||
|
};
|
||||||
|
expect(getEventEditability(invited, ctx({ mayRSVP: true }))).toBe('rsvp-only');
|
||||||
|
expect(getEventEditability(invited, ctx({ mayReadItems: true }))).toBe('read-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back on ownership when rights are not loaded', () => {
|
||||||
|
expect(getEventEditability(base, ctx(undefined))).toBe('editable');
|
||||||
|
expect(getEventEditability({ ...base, isShared: true }, ctx(undefined))).toBe('read-only');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eventHasNoOwner', () => {
|
||||||
|
it('detects plain events', () => {
|
||||||
|
expect(eventHasNoOwner(base)).toBe(true);
|
||||||
|
expect(eventHasNoOwner({ ...base, replyTo: { imip: 'mailto:x@y' } })).toBe(false);
|
||||||
|
expect(eventHasNoOwner({ ...base, participants: { a: { roles: { owner: true } } } })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,6 @@ export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
|
|||||||
mayReadItems: true,
|
mayReadItems: true,
|
||||||
mayWriteAll: false,
|
mayWriteAll: false,
|
||||||
mayWriteOwn: false,
|
mayWriteOwn: false,
|
||||||
mayWrite: false,
|
|
||||||
mayDelete: false,
|
mayDelete: false,
|
||||||
mayRSVP: false,
|
mayRSVP: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { CalendarEvent, Calendar, CalendarRights } from '../api/types';
|
||||||
|
import { isOrganizer, getUserParticipantId } from './calendar-participants';
|
||||||
|
|
||||||
|
// Port of the webmail's lib/calendar-editability.ts.
|
||||||
|
|
||||||
|
export type EventEditability = 'editable' | 'rsvp-only' | 'read-only';
|
||||||
|
|
||||||
|
export interface EditabilityContext {
|
||||||
|
/** Viewer's calendars by id; only their `myRights` is read. */
|
||||||
|
calendarsById: ReadonlyMap<string, Pick<Calendar, 'myRights'>>;
|
||||||
|
/** Identities + aliases; only refines owner/participant detection, never the gate. */
|
||||||
|
userCalendarAddresses: string[];
|
||||||
|
/** Client-side external iCal (webcal/ICS) subscriptions - always read-only. */
|
||||||
|
isSubscriptionCalendar: (calendarId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the viewer may write to this calendar at all. Stalwart never sends
|
||||||
|
* a `mayWrite` flag; the RFC-style rights are `mayWriteAll` / `mayWriteOwn`.
|
||||||
|
* Missing rights (older server, own calendars) count as writable.
|
||||||
|
*/
|
||||||
|
export function isWritableCalendar(calendar: Pick<Calendar, 'myRights'>): boolean {
|
||||||
|
const r = calendar.myRights;
|
||||||
|
return !r || !!r.mayWriteAll || !!r.mayWriteOwn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether new events may be created in / moved into this calendar: writable and
|
||||||
|
* not a (read-only) iCal subscription. Bulwark subscriptions are real Stalwart
|
||||||
|
* calendars with writable myRights, so the flag must be checked too (issue #762).
|
||||||
|
*/
|
||||||
|
export function canCreateEventsIn(
|
||||||
|
calendar: Pick<Calendar, 'id' | 'myRights'>,
|
||||||
|
isSubscriptionCalendar?: (calendarId: string) => boolean,
|
||||||
|
): boolean {
|
||||||
|
if (isSubscriptionCalendar?.(calendar.id)) return false;
|
||||||
|
return isWritableCalendar(calendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ownerless (plain, non-scheduled) events are editable under `mayWriteOwn`. */
|
||||||
|
export function eventHasNoOwner(event: CalendarEvent): boolean {
|
||||||
|
if (event.organizerCalendarAddress) return false;
|
||||||
|
if (event.replyTo && Object.keys(event.replyTo).length > 0) return false;
|
||||||
|
if (event.participants) {
|
||||||
|
for (const p of Object.values(event.participants)) {
|
||||||
|
if (p.roles?.owner) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editability gated on the calendar's `myRights` FIRST, then identity - so
|
||||||
|
* read-only/subscription calendars stay read-only and an alias organizer can't
|
||||||
|
* over-open events the user can't write. (Stalwart: organizer identity does not
|
||||||
|
* confer calendar write; the ACL does.)
|
||||||
|
*/
|
||||||
|
export function getEventEditability(
|
||||||
|
event: CalendarEvent,
|
||||||
|
ctx: EditabilityContext,
|
||||||
|
): EventEditability {
|
||||||
|
const calIds = Object.keys(event.calendarIds ?? {});
|
||||||
|
|
||||||
|
// Subscriptions have no write path and myRights can't describe them.
|
||||||
|
if (calIds.some((id) => ctx.isSubscriptionCalendar(id))) return 'read-only';
|
||||||
|
|
||||||
|
const rights = calIds
|
||||||
|
.map((id) => ctx.calendarsById.get(id)?.myRights)
|
||||||
|
.filter((r): r is CalendarRights => Boolean(r));
|
||||||
|
|
||||||
|
// Rights not loaded (own calendars on servers that omit them): the user's
|
||||||
|
// own account is writable; a shared calendar without rights is not.
|
||||||
|
if (rights.length === 0) return event.isShared ? 'read-only' : 'editable';
|
||||||
|
|
||||||
|
// Every calendar must grant the right (a read-only member blocks edit).
|
||||||
|
const may = (k: keyof CalendarRights) => rights.every((r) => r[k]);
|
||||||
|
const isOwner =
|
||||||
|
isOrganizer(event, ctx.userCalendarAddresses) || eventHasNoOwner(event);
|
||||||
|
|
||||||
|
if (may('mayWriteAll')) return 'editable';
|
||||||
|
if (may('mayWriteOwn') && isOwner) return 'editable';
|
||||||
|
|
||||||
|
const isParticipant =
|
||||||
|
getUserParticipantId(event, ctx.userCalendarAddresses) != null;
|
||||||
|
if (may('mayRSVP') && isParticipant) return 'rsvp-only';
|
||||||
|
|
||||||
|
return 'read-only';
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID }
|
|||||||
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 { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
|
||||||
|
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
|
||||||
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';
|
||||||
@@ -324,9 +325,20 @@ export default function CalendarScreen() {
|
|||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Client-side iCal subscriptions mirror a remote feed into a local
|
||||||
|
// calendar; edits there would be wiped by the next sync, so they're
|
||||||
|
// read-only targets everywhere (#762).
|
||||||
|
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||||
|
const isSubscriptionCalendar = React.useCallback(
|
||||||
|
(calendarId: string) => subscriptions.some((s) => s.calendarId === calendarId),
|
||||||
|
[subscriptions],
|
||||||
|
);
|
||||||
|
|
||||||
const isReadOnlyEvent = React.useCallback(
|
const isReadOnlyEvent = React.useCallback(
|
||||||
(event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID],
|
(event: CalendarEvent) =>
|
||||||
[],
|
!!event.calendarIds?.[BIRTHDAY_CALENDAR_ID] ||
|
||||||
|
Object.keys(event.calendarIds ?? {}).some(isSubscriptionCalendar),
|
||||||
|
[isSubscriptionCalendar],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Editing a series member opens the editor directly; the this/future/all
|
// Editing a series member opens the editor directly; the this/future/all
|
||||||
@@ -630,6 +642,7 @@ export default function CalendarScreen() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
timeFormat={calendarTimeFormat}
|
timeFormat={calendarTimeFormat}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
|
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||||
onClose={() => setDetailEvent(null)}
|
onClose={() => setDetailEvent(null)}
|
||||||
onEdit={handleEditFromDetail}
|
onEdit={handleEditFromDetail}
|
||||||
onDelete={handleDeleteFromDetail}
|
onDelete={handleDeleteFromDetail}
|
||||||
@@ -655,6 +668,7 @@ export default function CalendarScreen() {
|
|||||||
calendars={eventCalendars}
|
calendars={eventCalendars}
|
||||||
defaultDate={modalDate}
|
defaultDate={modalDate}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
|
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDelete={handleDeleteFromModal}
|
onDelete={handleDeleteFromModal}
|
||||||
onClose={() => setModalVisible(false)}
|
onClose={() => setModalVisible(false)}
|
||||||
|
|||||||
Reference in New Issue
Block a user