Merge pull request #33 from LINUXexpert-org/calendar-vocabulary-into-main

Bring the stranded calendar work into main
This commit is contained in:
LINUXexpert.org
2026-08-25 09:08:18 -07:00
committed by GitHub
11 changed files with 187 additions and 42 deletions
+1
View File
@@ -207,6 +207,7 @@ the registry path is **awaiting live re-verification**.
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24). - **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
- **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path is confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The mock enforces the same rules either way (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens. - **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path is confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The mock enforces the same rules either way (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. **Not yet exercised against the live server** — verified end to end against the mock only. - **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. **Not yet exercised against the live server** — verified end to end against the mock only.
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation. - The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
+25 -4
View File
@@ -151,8 +151,8 @@ const events: Obj[] = [];
const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; }; const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; };
const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`; const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }], showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", email: USER, sendTo: { imip: `mailto:${USER}` }, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", email: "[email protected]", sendTo: { imip: "mailto:[email protected]" }, roles: { attendee: true }, participationStatus: "needs-action", expectReply: true } }, replyTo: { imip: `mailto:${USER}` } }); events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null }); events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" }); events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
} }
@@ -323,6 +323,12 @@ function genericGet(list: Obj[]) {
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] }; return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
}; };
} }
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
class SetError extends Error {
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
}
function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) { function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
return (a: Obj) => { return (a: Obj) => {
const created: Obj = {}; const created: Obj = {};
@@ -332,7 +338,13 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const id = `${prefix}${randomUUID().slice(0, 6)}`; const id = `${prefix}${randomUUID().slice(0, 6)}`;
const o = { ...(obj as Obj), id }; const o = { ...(obj as Obj), id };
try {
onCreate?.(o); onCreate?.(o);
} catch (err) {
if (!(err instanceof SetError)) throw err;
notCreated[cid] = err.toJSON();
continue;
}
list.push(o); list.push(o);
created[cid] = { id }; created[cid] = { id };
} }
@@ -581,8 +593,17 @@ const handlers: Record<string, Handler> = {
"Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })), "Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })),
"CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }), "CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }),
"CalendarEvent/get": genericGet(events), "CalendarEvent/get": genericGet(events),
"CalendarEvent/set": genericSet(events, "ev", (o) => Object.assign(o, { uid: o.uid ?? randomUUID() })), // Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", email: "[email protected]", sendTo: { imip: "mailto:[email protected]" }, roles: { owner: true } }, me: { name: "Demo User", email: USER, sendTo: { imip: `mailto:${USER}` }, roles: { attendee: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, // participants addressed the RFC 8984 way. The mock did neither, which is how
// #26 and #30 reached a live server unnoticed — so it now does both.
"CalendarEvent/set": genericSet(events, "ev", (o) => {
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
const parts = o.participants as Record<string, Obj> | undefined;
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
return Object.assign(o, { uid: o.uid ?? randomUUID() });
}),
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"ParticipantIdentity/get": genericGet(participantIdentities), "ParticipantIdentity/get": genericGet(participantIdentities),
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
"Principal/get": genericGet(principals), "Principal/get": genericGet(principals),
+6
View File
@@ -615,6 +615,8 @@ export interface JSCalendarParticipant {
email?: string; email?: string;
description?: string; description?: string;
sendTo?: Record<string, string>; sendTo?: Record<string, string>;
/** Where Stalwart 0.16 keeps the address, in place of `sendTo` / `email`. */
calendarAddress?: string;
kind?: "individual" | "group" | "location" | "resource"; kind?: "individual" | "group" | "location" | "resource";
roles: Record<string, boolean>; roles: Record<string, boolean>;
locationId?: string; locationId?: string;
@@ -679,6 +681,8 @@ export interface JSCalendarEvent {
recurrenceId?: LocalDate; recurrenceId?: LocalDate;
recurrenceIdTimeZone?: string; recurrenceIdTimeZone?: string;
recurrenceRules?: JSCalendarRecurrenceRule[]; recurrenceRules?: JSCalendarRecurrenceRule[];
/** Stalwart 0.16 stores a single rule under this name instead of the array above. */
recurrenceRule?: JSCalendarRecurrenceRule;
excludedRecurrenceRules?: JSCalendarRecurrenceRule[]; excludedRecurrenceRules?: JSCalendarRecurrenceRule[];
recurrenceOverrides?: Record<LocalDate, Record<string, unknown> | null>; recurrenceOverrides?: Record<LocalDate, Record<string, unknown> | null>;
excluded?: boolean; excluded?: boolean;
@@ -686,6 +690,8 @@ export interface JSCalendarEvent {
freeBusyStatus?: "free" | "busy"; freeBusyStatus?: "free" | "busy";
privacy?: "public" | "private" | "secret"; privacy?: "public" | "private" | "secret";
replyTo?: Record<string, string>; replyTo?: Record<string, string>;
/** Where Stalwart 0.16 keeps the organizer, in place of `replyTo`. */
organizerCalendarAddress?: string;
sentBy?: string; sentBy?: string;
participants?: Record<string, JSCalendarParticipant>; participants?: Record<string, JSCalendarParticipant>;
requestStatus?: string; requestStatus?: string;
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { participantAddresses, participantEmail, isAttendee, eventRule, makeParticipant } from "@/store/calendar";
import type { CalendarEvent, JSCalendarParticipant } from "@/jmap/types";
/**
* Stalwart 0.16.19 and RFC 8984 disagree about where a participant's address
* lives. Sent the RFC's way, Stalwart keeps the event and drops the participant
* map without a word — guests vanished and no invitation was ever sent (#26).
* Shapes below are what a live 0.16.19 returned.
*/
const p = (o: Partial<JSCalendarParticipant>): JSCalendarParticipant => ({ roles: {}, ...o });
const ev = (o: Partial<CalendarEvent>): CalendarEvent => ({ id: "e1", "@type": "Event", uid: "u1", calendarIds: { c1: true }, start: "2030-01-01T10:00:00", ...o } as CalendarEvent);
describe("participant addresses", () => {
it("reads Stalwart's calendarAddress", () => {
expect(participantEmail(p({ calendarAddress: "mailto:[email protected]" }))).toBe("[email protected]");
});
it("still reads the RFC 8984 spellings, for events written by other clients", () => {
expect(participantEmail(p({ sendTo: { imip: "mailto:[email protected]" } }))).toBe("[email protected]");
expect(participantEmail(p({ email: "[email protected]" }))).toBe("[email protected]");
expect(participantAddresses(p({ calendarAddress: "mailto:[email protected]", email: "[email protected]" }))).toEqual(["mailto:[email protected]", "mailto:[email protected]"]);
});
it("has no address to offer when the participant carries none", () => {
expect(participantEmail(p({ name: "Nameless" }))).toBe("");
});
it("counts a participant as attending under either role name", () => {
expect(isAttendee(p({ roles: { attendee: true } }))).toBe(true);
expect(isAttendee(p({ roles: { required: true } }))).toBe(true); // what Stalwart writes for REQ-PARTICIPANT
expect(isAttendee(p({ roles: { optional: true } }))).toBe(true);
expect(isAttendee(p({ roles: { owner: true } }))).toBe(false);
});
});
describe("makeParticipant", () => {
it("addresses a guest the way Stalwart stores them", () => {
const guest = makeParticipant("[email protected]", "Guest", "attendee");
expect(guest.calendarAddress).toBe("mailto:[email protected]");
expect(guest.sendTo).toBeUndefined();
expect(guest.roles).toEqual({ attendee: true, required: true });
expect(guest.participationStatus).toBe("needs-action");
expect(guest.expectReply).toBe(true);
});
it("marks the organizer as owner and keeps a status already given", () => {
const me = makeParticipant("[email protected]", "John Coffey", "owner");
expect(me.roles).toEqual({ owner: true, attendee: true });
expect(me.participationStatus).toBe("accepted");
expect(me.expectReply).toBe(false);
expect(makeParticipant("[email protected]", null, "attendee", "declined").participationStatus).toBe("declined");
});
});
describe("eventRule", () => {
it("reads Stalwart's singular rule and the RFC's array", () => {
expect(eventRule(ev({ recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly" } }))?.frequency).toBe("weekly");
expect(eventRule(ev({ recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily" }] }))?.frequency).toBe("daily");
expect(eventRule(ev({}))).toBeUndefined();
});
});
+11 -5
View File
@@ -15,13 +15,19 @@ describe("isRecurring", () => {
it("does not call a one-off event a series just because it has a baseEventId", () => { it("does not call a one-off event a series just because it has a baseEventId", () => {
expect(isRecurring(ev({ baseEventId: "ev1" }))).toBe(false); expect(isRecurring(ev({ baseEventId: "ev1" }))).toBe(false);
expect(isRecurring(ev({}))).toBe(false); expect(isRecurring(ev({}))).toBe(false);
// The shape a live 0.16.19 returns for a one-off: an instance id of its own,
// and a base that is a different id. Neither makes it a series.
expect(isRecurring(ev({ id: "eaaaaai", baseEventId: "i" }))).toBe(false);
}); });
it("still recognises an occurrence whose base is another event", () => { it("recognises a series by its rule, under either name", () => {
expect(isRecurring(ev({ id: "ev1_2", baseEventId: "ev1" }))).toBe(true);
});
it("recognises a series by its recurrence rules", () => {
expect(isRecurring(ev({ recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "weekly" }] }))).toBe(true); expect(isRecurring(ev({ recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "weekly" }] }))).toBe(true);
expect(isRecurring(ev({ baseEventId: "ev1", recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily" }] }))).toBe(true);
expect(isRecurring(ev({ excludedRecurrenceRules: [{ "@type": "RecurrenceRule", frequency: "monthly" }] }))).toBe(true); expect(isRecurring(ev({ excludedRecurrenceRules: [{ "@type": "RecurrenceRule", frequency: "monthly" }] }))).toBe(true);
// Stalwart 0.16 keeps a single rule under the singular name.
expect(isRecurring(ev({ recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", count: 3 } }))).toBe(true);
});
it("recognises an occurrence, which arrives with no rule of its own", () => {
// A live 0.16.19 expands a weekly series into instances like this: an id
// per occurrence, a recurrenceId, and no rule attached.
expect(isRecurring(ev({ id: "iaaaaas", recurrenceId: "2030-03-11T10:00:00" }))).toBe(true);
}); });
}); });
+63 -11
View File
@@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client"; import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types"; import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates"; import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
import { settings } from "./settings"; import { settings } from "./settings";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -57,7 +57,7 @@ const EVENT_PROPS = [
"id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees", "id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
"uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime", "uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime",
"locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone", "locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone",
"recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress",
"sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status", "sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status",
]; ];
@@ -300,15 +300,22 @@ export const useCalendar = create<CalendarState>((set, get) => ({
/** /**
* Whether an event is part of a series. * Whether an event is part of a series.
* *
* Not the same question as "does it have a baseEventId": `CalendarEvent/query` * Three things had to be checked against a live 0.16.19 to get this right, none
* runs with `expandRecurrences`, and Stalwart sets `baseEventId` on every event * of which the mock reproduces:
* it returns that way — a one-off event included, pointing at itself. Recurrence *
* rules are what make a series, so those are the question; a base that is some * - `baseEventId` says nothing. `CalendarEvent/query` runs with
* *other* event means this is one occurrence of one, whether or not the expanded * `expandRecurrences`, and a one-off comes back as id `eaaaaai` over base
* instance carried the rules along with it. * `i` — an instance id of its own, and a base that is a different id.
* - The rules say nothing on an instance. An occurrence of a weekly series
* arrives with no rule attached at all; only the master carries one.
* - Stalwart names that rule `recurrenceRule`, singular, not the RFC 8984
* `recurrenceRules` array.
*
* What an occurrence does carry is a `recurrenceId`, and a one-off never has
* one. Master or occurrence, that is what makes this a series.
*/ */
export function isRecurring(ev: CalendarEvent): boolean { export function isRecurring(ev: CalendarEvent): boolean {
return Boolean(ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length || (ev.baseEventId && ev.baseEventId !== ev.id)); return Boolean(ev.recurrenceRule || ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length || ev.recurrenceId);
} }
export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null { export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null {
@@ -331,6 +338,52 @@ export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): E
return { key: e.id, event: e, start, end, allDay, calendar: calId ? calendars[calId] : undefined }; return { key: e.id, event: e, start, end, allDay, calendar: calId ? calendars[calId] : undefined };
} }
/**
* Every address a participant answers to, as lowercase `mailto:` URIs.
*
* Stalwart 0.16 keeps one address under `calendarAddress`; RFC 8984 spreads it
* over `sendTo` and `email`. Reading has to accept all three — a mailbox may
* hold events written by either, and by other clients besides.
*/
export function participantAddresses(p: JSCalendarParticipant): string[] {
return [p.calendarAddress ?? "", ...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""]
.filter(Boolean)
.map((a) => a.toLowerCase());
}
/** The address to show or write to, without the `mailto:`. */
export function participantEmail(p: JSCalendarParticipant): string {
return (participantAddresses(p)[0] ?? "").replace(/^mailto:/i, "");
}
/** Whether this participant is attending, under any of the role names in use. */
export function isAttendee(p: JSCalendarParticipant): boolean {
return Boolean(p.roles?.attendee || p.roles?.required || p.roles?.optional || p.roles?.chair);
}
/** The event's recurrence rule, under either spelling. */
export function eventRule(ev: CalendarEvent): JSCalendarRecurrenceRule | undefined {
return ev.recurrenceRule ?? ev.recurrenceRules?.[0];
}
/**
* Builds a participant the way Stalwart 0.16 stores them: the address under
* `calendarAddress`. Sent under RFC 8984's `sendTo`/`email` instead, the server
* keeps the event and drops the whole participant map without saying so — which
* is how invitations came to vanish (#26).
*/
export function makeParticipant(email: string, name: string | null | undefined, role: "owner" | "attendee", status?: string): JSCalendarParticipant {
return {
"@type": "Participant",
name: name || undefined,
calendarAddress: `mailto:${email}`,
kind: "individual",
roles: role === "owner" ? { owner: true, attendee: true } : { attendee: true, required: true },
participationStatus: (status as JSCalendarParticipant["participationStatus"]) ?? (role === "owner" ? "accepted" : "needs-action"),
expectReply: role !== "owner",
};
}
export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIdentity[]): string[] { export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIdentity[]): string[] {
const mine = new Set<string>(); const mine = new Set<string>();
for (const i of identities) { for (const i of identities) {
@@ -341,8 +394,7 @@ export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIden
if (session?.username?.includes("@")) mine.add(`mailto:${session.username.toLowerCase()}`); if (session?.username?.includes("@")) mine.add(`mailto:${session.username.toLowerCase()}`);
const keys: string[] = []; const keys: string[] = [];
for (const [k, p] of Object.entries(ev.participants ?? {})) { for (const [k, p] of Object.entries(ev.participants ?? {})) {
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase()); if (participantAddresses(p).some((a) => mine.has(a))) keys.push(k);
if (addrs.some((a) => mine.has(a))) keys.push(k);
} }
return keys; return keys;
} }
@@ -81,7 +81,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
const duplicate = async () => { const duplicate = async () => {
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>; const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
try { try {
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false); await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
toast.success("Event duplicated"); toast.success("Event duplicated");
} catch (err) { } catch (err) {
toast.error((err as Error).message); toast.error((err as Error).message);
+2 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react";
import { useCalendar, type EventInstance } from "@/store/calendar"; import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates"; import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
import { formatMonthYear, formatTime } from "@/lib/format"; import { formatMonthYear, formatTime } from "@/lib/format";
@@ -184,8 +184,7 @@ function statusClass(i: EventInstance): string {
const ids = mine.flatMap((m) => [m.calendarAddress.toLowerCase(), ...Object.values(m.sendTo ?? {}).map((x) => x.toLowerCase())]); const ids = mine.flatMap((m) => [m.calendarAddress.toLowerCase(), ...Object.values(m.sendTo ?? {}).map((x) => x.toLowerCase())]);
let my: string | undefined; let my: string | undefined;
for (const p of Object.values(ev.participants ?? {})) { for (const p of Object.values(ev.participants ?? {})) {
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase()); if (participantAddresses(p).some((a) => ids.includes(a))) my = p.participationStatus;
if (addrs.some((a) => ids.includes(a))) my = p.participationStatus;
} }
if (ev.status === "cancelled") return "cancelled"; if (ev.status === "cancelled") return "cancelled";
if (my === "declined") return "declined"; if (my === "declined") return "declined";
+11 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Plus, Trash2, Users } from "lucide-react"; import { Plus, Trash2, Users } from "lucide-react";
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types"; import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
import { useCalendar, myParticipantKeys, isRecurring } from "@/store/calendar"; import { useCalendar, myParticipantKeys, isRecurring, eventRule, makeParticipant, participantEmail } from "@/store/calendar";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
@@ -67,8 +67,8 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
const [color, setColor] = useState<string | null>(ev?.color ?? null); const [color, setColor] = useState<string | null>(ev?.color ?? null);
const categories = useSettings((s) => s.settings.eventCategories); const categories = useSettings((s) => s.settings.eventCategories);
const [category, setCategory] = useState<string>(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? ""); const [category, setCategory] = useState<string>(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? "");
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev?.recurrenceRules?.[0]); const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev ? eventRule(ev) : undefined);
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev?.recurrenceRules?.[0])); const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev ? eventRule(ev) : undefined));
const [alerts, setAlerts] = useState<number[]>(() => { const [alerts, setAlerts] = useState<number[]>(() => {
const a = Object.values(ev?.alerts ?? {}).map((x) => ("offset" in x.trigger ? -parseDuration(x.trigger.offset) / 60 : 0)).filter((n) => n >= 0); const a = Object.values(ev?.alerts ?? {}).map((x) => ("offset" in x.trigger ? -parseDuration(x.trigger.offset) / 60 : 0)).filter((n) => n >= 0);
if (ev) return a; if (ev) return a;
@@ -78,7 +78,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
const [attendees, setAttendees] = useState<EmailAddress[]>(() => const [attendees, setAttendees] = useState<EmailAddress[]>(() =>
Object.entries(ev?.participants ?? {}) Object.entries(ev?.participants ?? {})
.filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee)) .filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee))
.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" })) .map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) }))
.filter((a) => a.email), .filter((a) => a.email),
); );
const [sendInvites, setSendInvites] = useState(true); const [sendInvites, setSendInvites] = useState(true);
@@ -142,11 +142,11 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS); if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS);
const participants: Record<string, JSCalendarParticipant> = {}; const participants: Record<string, JSCalendarParticipant> = {};
if (attendees.length && myAddress) { if (attendees.length && myAddress) {
participants.me = { "@type": "Participant", name: identity?.name || undefined, email: myPlainEmail, sendTo: { imip: myAddress }, kind: "individual", roles: { owner: true, attendee: true }, participationStatus: "accepted", expectReply: false }; participants.me = makeParticipant(myPlainEmail, identity?.name, "owner");
for (const a of attendees) { for (const a of attendees) {
// preserve existing status if the attendee was already there // preserve existing status if the attendee was already there
const existing = Object.values(ev?.participants ?? {}).find((p) => (p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, ""))?.toLowerCase() === a.email.toLowerCase()); const existing = Object.values(ev?.participants ?? {}).find((p) => participantEmail(p).toLowerCase() === a.email.toLowerCase());
participants[newKey("p")] = { "@type": "Participant", name: a.name ?? undefined, email: a.email, sendTo: { imip: `mailto:${a.email}` }, kind: "individual", roles: { attendee: true }, participationStatus: existing?.participationStatus ?? "needs-action", expectReply: true }; participants[newKey("p")] = makeParticipant(a.email, a.name, "attendee", existing?.participationStatus);
} }
} }
const alertObj: Record<string, JSCalendarAlert> = {}; const alertObj: Record<string, JSCalendarAlert> = {};
@@ -161,10 +161,12 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
locations: location.trim() ? { [newKey("l")]: { "@type": "Location", name: location.trim() } } : undefined, locations: location.trim() ? { [newKey("l")]: { "@type": "Location", name: location.trim() } } : undefined,
virtualLocations: vurl.trim() ? { [newKey("v")]: { "@type": "VirtualLocation", uri: vurl.trim(), name: "Online meeting" } } : undefined, virtualLocations: vurl.trim() ? { [newKey("v")]: { "@type": "VirtualLocation", uri: vurl.trim(), name: "Online meeting" } } : undefined,
participants: Object.keys(participants).length ? participants : undefined, participants: Object.keys(participants).length ? participants : undefined,
replyTo: Object.keys(participants).length && myAddress ? { imip: myAddress } : undefined, // Stalwart 0.16 names the organizer here; RFC 8984's replyTo is ignored.
organizerCalendarAddress: Object.keys(participants).length && myAddress ? myAddress : undefined,
alerts: Object.keys(alertObj).length ? alertObj : undefined, alerts: Object.keys(alertObj).length ? alertObj : undefined,
useDefaultAlerts: false, useDefaultAlerts: false,
recurrenceRules: rule ? [rule] : undefined, // Singular, and no array: Stalwart 0.16 rejects `recurrenceRules` outright (#30).
recurrenceRule: rule ?? undefined,
status, status,
privacy, privacy,
freeBusyStatus: freeBusy, freeBusyStatus: freeBusy,
+4 -4
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react"; import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
import { useCalendar, myParticipantKeys, isRecurring, type EventInstance } from "@/store/calendar"; import { useCalendar, myParticipantKeys, isRecurring, eventRule, participantEmail, type EventInstance } from "@/store/calendar";
import { Popover, type Anchor } from "@/ui/popover"; import { Popover, type Anchor } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog"; import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
@@ -66,7 +66,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
</div> </div>
<h3>{ev.title || "(untitled)"}</h3> <h3>{ev.title || "(untitled)"}</h3>
<div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div> <div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div>
{ev.recurrenceRules?.[0] && <div className="ev-line"><Repeat size={15} /><span>{describeRule(ev.recurrenceRules[0])}</span></div>} {eventRule(ev) && <div className="ev-line"><Repeat size={15} /><span>{describeRule(eventRule(ev)!)}</span></div>}
{location?.name && <div className="ev-line"><MapPin size={15} /><span>{location.name}</span></div>} {location?.name && <div className="ev-line"><MapPin size={15} /><span>{location.name}</span></div>}
{vloc?.uri && <div className="ev-line"><Link2 size={15} /><a href={vloc.uri} target="_blank" rel="noreferrer" className="truncate">{vloc.name || vloc.uri}</a></div>} {vloc?.uri && <div className="ev-line"><Link2 size={15} /><a href={vloc.uri} target="_blank" rel="noreferrer" className="truncate">{vloc.name || vloc.uri}</a></div>}
{ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>} {ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>}
@@ -75,12 +75,12 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
<div className="ev-line"><CalIcon size={15} /><span>{inst.calendar?.name ?? "Calendar"}{ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}{ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}{ev.freeBusyStatus === "free" ? " · shown as free" : ""}</span></div> <div className="ev-line"><CalIcon size={15} /><span>{inst.calendar?.name ?? "Calendar"}{ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}{ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}{ev.freeBusyStatus === "free" ? " · shown as free" : ""}</span></div>
{participants.length > 0 && ( {participants.length > 0 && (
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}> <div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
<div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div> <div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}> <div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
{participants.map(([k, p]) => ( {participants.map(([k, p]) => (
<div key={k} className="participant-row"> <div key={k} className="participant-row">
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} /> <span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
<span className="truncate">{p.name || p.email || Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "")}</span> <span className="truncate">{p.name || participantEmail(p)}</span>
{p.roles?.owner && <span className="hint">organizer</span>} {p.roles?.owner && <span className="hint">organizer</span>}
{p.roles?.optional && <span className="hint">optional</span>} {p.roles?.optional && <span className="hint">optional</span>}
</div> </div>
+4 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Calendar, Check, HelpCircle, MapPin, X } from "lucide-react"; import { Calendar, Check, HelpCircle, MapPin, X } from "lucide-react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types"; import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types";
import { useCalendar, toInstance, myParticipantKeys } from "@/store/calendar"; import { useCalendar, toInstance, myParticipantKeys, isAttendee, participantEmail } from "@/store/calendar";
import { formatTimeRange } from "@/lib/dates"; import { formatTimeRange } from "@/lib/dates";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
@@ -41,7 +41,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
const organizer = Object.values(ev.participants ?? {}).find((p) => p.roles?.owner); const organizer = Object.values(ev.participants ?? {}).find((p) => p.roles?.owner);
const location = Object.values(ev.locations ?? {})[0]?.name; const location = Object.values(ev.locations ?? {})[0]?.name;
const myStatus = existing ? (myParticipantKeys(existing, cal.identities).map((k) => existing.participants?.[k]?.participationStatus)[0] ?? null) : null; const myStatus = existing ? (myParticipantKeys(existing, cal.identities).map((k) => existing.participants?.[k]?.participationStatus)[0] ?? null) : null;
const attendees = Object.values(ev.participants ?? {}).filter((p) => p.roles?.attendee); const attendees = Object.values(ev.participants ?? {}).filter(isAttendee);
const respond = async (status: "accepted" | "tentative" | "declined") => { const respond = async (status: "accepted" | "tentative" | "declined") => {
setBusy(status); setBusy(status);
@@ -90,11 +90,11 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
<h4>{ev.title || "(untitled event)"}</h4> <h4>{ev.title || "(untitled event)"}</h4>
{inst && <div className="small">{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}</div>} {inst && <div className="small">{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}</div>}
{location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>} {location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>}
{organizer && <div className="small muted">Organizer: {organizer.name || organizer.email || Object.values(organizer.sendTo ?? {})[0]?.replace("mailto:", "")}</div>} {organizer && <div className="small muted">Organizer: {organizer.name || participantEmail(organizer)}</div>}
{attendees.length > 0 && <div className="small muted">{attendees.length} attendee{attendees.length === 1 ? "" : "s"}</div>} {attendees.length > 0 && <div className="small muted">{attendees.length} attendee{attendees.length === 1 ? "" : "s"}</div>}
{method === "REPLY" && ( {method === "REPLY" && (
<div className="small" style={{ marginTop: 4 }}> <div className="small" style={{ marginTop: 4 }}>
{attendees.map((a) => <div key={a.email ?? a.name}>{a.name || a.email}: <b>{a.participationStatus ?? "unknown"}</b></div>)} {attendees.map((a) => <div key={participantEmail(a) || a.name}>{a.name || participantEmail(a)}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
</div> </div>
)} )}
</div> </div>