@rapidmx/web-client 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  2. package/apps/shared/calendar/calendarReminders.ts +16 -7
  3. package/apps/shared/calendar/useCalendarReminders.ts +66 -61
  4. package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
  5. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  6. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  7. package/apps/shared/components/layout/AppShell.tsx +4 -2
  8. package/apps/shared/components/layout/UserMenu.tsx +29 -1
  9. package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
  10. package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
  11. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  12. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  13. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  14. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  15. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  16. package/apps/shared/notifications/preferences.ts +27 -0
  17. package/apps/shared/notifications/sounds.ts +135 -0
  18. package/apps/shared/notifications/store.ts +9 -0
  19. package/apps/www/calendar/index.tsx +64 -32
  20. package/apps/www/contacts/index.tsx +58 -2
  21. package/apps/www/tasks/index.tsx +42 -0
  22. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  23. package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
  24. package/dist/apps/shared/calendar/calendarReminders.js +14 -6
  25. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
  26. package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
  27. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
  28. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
  29. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  30. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  31. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  32. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  33. package/dist/apps/shared/components/layout/AppShell.js +4 -3
  34. package/dist/apps/shared/components/layout/UserMenu.js +9 -3
  35. package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
  36. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
  37. package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
  38. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  39. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  40. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  41. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  42. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  43. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  44. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  45. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  46. package/dist/apps/shared/notifications/preferences.d.ts +8 -0
  47. package/dist/apps/shared/notifications/preferences.js +27 -0
  48. package/dist/apps/shared/notifications/sounds.d.ts +15 -0
  49. package/dist/apps/shared/notifications/sounds.js +101 -0
  50. package/dist/apps/shared/notifications/store.js +11 -0
  51. package/dist/apps/www/calendar/index.js +37 -8
  52. package/dist/apps/www/contacts/index.js +49 -3
  53. package/dist/apps/www/tasks/index.js +29 -1
  54. package/dist/lib/mail/pushClient.d.ts +10 -3
  55. package/dist/lib/mail/pushClient.js +18 -4
  56. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  57. package/dist/lib/tasks/tasksApi.js +3 -0
  58. package/lib/mail/pushClient.ts +18 -4
  59. package/lib/tasks/tasksApi.ts +4 -0
  60. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { apiUrl } from "../../../../../lib/util/api.js";
5
+ import { type ApiClient, apiUrl } from "../../../../../lib/util/api.js";
6
6
  import { Message, Recipient, attachmentContentUrl, getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
7
7
  import type { QuotedBody } from "../../../../../lib/mail/compose/composeQuoting.js";
8
8
  import { MAX_QUOTED_IMAGE_BYTES, QuotedImageReference, embedQuotedImages } from "../../../../../lib/mail/compose/quotedImages.js";
@@ -44,9 +44,9 @@ const contentCache = new Map<string, { at: number; promise: Promise<string | und
44
44
  /**
45
45
  * The server's sanitized HTML body of a message (`GET /mail/messages/:id/content`), or `undefined` when it has none (a
46
46
  * `text/plain` message is answered with its preview instead) or the request failed or timed out. Never rejects. Remembered
47
- * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time.
47
+ * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time. Through `client` when given, else the cookie request.
48
48
  */
49
- function fetchContentHtml(uid: string): Promise<string | undefined> {
49
+ function fetchContentHtml(uid: string, client?: ApiClient): Promise<string | undefined> {
50
50
  const cached = contentCache.get(uid);
51
51
  if (cached && Date.now() - cached.at < QUOTE_CACHE_MS) {
52
52
  return cached.promise;
@@ -55,6 +55,10 @@ function fetchContentHtml(uid: string): Promise<string | undefined> {
55
55
  const timer = setTimeout(() => controller.abort(), QUOTE_FETCH_TIMEOUT_MS);
56
56
  const promise = (async (): Promise<string | undefined> => {
57
57
  try {
58
+ if (client) {
59
+ const blob = await client.fetchBlob(`/mail/messages/${encodeURIComponent(uid)}/content`, { signal: controller.signal });
60
+ return blob.type.toLowerCase().includes("text/html") ? await blob.text() : undefined;
61
+ }
58
62
  const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), { credentials: "include", signal: controller.signal });
59
63
  return res.ok && (res.headers.get("content-type") ?? "").includes("text/html") ? await res.text() : undefined;
60
64
  } catch {
@@ -92,9 +96,9 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
92
96
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
93
97
  * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
94
98
  */
95
- export function prefetchOriginalMessage(message: Message): void {
99
+ export function prefetchOriginalMessage(message: Message, client?: ApiClient): void {
96
100
  if (!message.encrypted) {
97
- void fetchContentHtml(message.uid);
101
+ void fetchContentHtml(message.uid, client);
98
102
  }
99
103
  }
100
104
 
@@ -116,18 +120,28 @@ function headerRecipients(value: string | undefined, type: "to" | "cc"): Recipie
116
120
 
117
121
  /** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
118
122
  * or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
119
- function serverImageResolver(message: Message): (reference: QuotedImageReference, signal?: AbortSignal) => Promise<string | undefined> {
123
+ function serverImageResolver(
124
+ message: Message,
125
+ client?: ApiClient,
126
+ ): (reference: QuotedImageReference, signal?: AbortSignal) => Promise<string | undefined> {
120
127
  let attachments: Promise<Awaited<ReturnType<typeof listAttachments>>> | undefined;
121
128
  return async (reference, signal) => {
122
129
  try {
123
- attachments ??= listAttachments(message.folderUid, message.uid);
130
+ attachments ??= listAttachments(message.folderUid, message.uid, client);
124
131
  const attachment = findInlineAttachment(await attachments, reference);
125
132
  const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
126
133
  if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
127
134
  return undefined;
128
135
  }
129
- const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
130
- const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
136
+ let bytes: Uint8Array | undefined;
137
+ if (client) {
138
+ // A failed response rejects here, which the catch below turns into no picture - as an `!res.ok` answer always was.
139
+ const blob = await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, { signal });
140
+ bytes = new Uint8Array(await blob.arrayBuffer());
141
+ } else {
142
+ const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
143
+ bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
144
+ }
131
145
  return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
132
146
  } catch {
133
147
  return undefined;
@@ -176,6 +190,7 @@ export async function loadOriginalMessage(
176
190
  message: Message,
177
191
  security: MessageSecurityResult | null,
178
192
  options: { recipients?: boolean } = {},
193
+ client?: ApiClient,
179
194
  ): Promise<OriginalMessage> {
180
195
  const recovered: QuotedBody | undefined =
181
196
  security?.text !== undefined ? { text: security.text } : security?.html !== undefined ? { html: security.html } : undefined;
@@ -193,7 +208,7 @@ export async function loadOriginalMessage(
193
208
  if (!recovered) {
194
209
  // Never rejects; nothing (a plain-text message, a failure, a timeout) leaves the raw message below as the other way
195
210
  // to the body.
196
- const html = await fetchContentHtml(message.uid);
211
+ const html = await fetchContentHtml(message.uid, client);
197
212
  if (html !== undefined) {
198
213
  result.body = { html };
199
214
  } else {
@@ -202,7 +217,7 @@ export async function loadOriginalMessage(
202
217
  }
203
218
  try {
204
219
  if (needsRaw) {
205
- const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid), QUOTE_FETCH_TIMEOUT_MS));
220
+ const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid, client), QUOTE_FETCH_TIMEOUT_MS));
206
221
  if (!recovered && result.body.html === undefined) {
207
222
  const body = extractDisplayBody(entity);
208
223
  result.body = body.html !== undefined ? { html: body.html } : body.text !== undefined ? { text: body.text } : {};
@@ -217,7 +232,7 @@ export async function loadOriginalMessage(
217
232
  // message's own recipients.
218
233
  }
219
234
  if (result.body.html !== undefined) {
220
- const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
235
+ const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message, client);
221
236
  const html = result.body.html;
222
237
  // Bounded like the body's own request: a picture that has not arrived by then is cancelled and left out of the quote, not waited for -
223
238
  // the ones that have arrived stay.
@@ -9,6 +9,7 @@ import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
9
9
  import Alert from "../../../../../lib/components/feedback/Alert.js";
10
10
  import Button from "../../../../../lib/components/buttons/Button.js";
11
11
  import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
12
+ import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
12
13
  import { useResolvedTheme } from "../../../appearance/resolvedTheme.js";
13
14
  import { BodyContent, attachmentSource, cachedBodyContent, fetchBodyContent, makeCidResolver } from "./bodyContent.js";
14
15
  import { prepareBodyHtml } from "./bodyHtml.js";
@@ -78,6 +79,8 @@ export default function MessageBody({ messageUid, messageVersion, title, content
78
79
  });
79
80
  const [attempt, setAttempt] = useState(0);
80
81
  const supplied = content !== undefined;
82
+ // The explicit ApiClient this body is rendered under, if any - undefined for the cookie-based web app.
83
+ const client = useApiClient();
81
84
 
82
85
  useEffect(() => {
83
86
  if (supplied) {
@@ -95,7 +98,7 @@ export default function MessageBody({ messageUid, messageVersion, title, content
95
98
  if (!revalidating) {
96
99
  setRemote({ status: "loading" });
97
100
  }
98
- fetchBodyContent(messageUid, messageVersion, controller.signal).then(
101
+ fetchBodyContent(messageUid, messageVersion, controller.signal, client).then(
99
102
  (loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }),
100
103
  (err) => {
101
104
  if (!controller.signal.aborted && !revalidating) {
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
5
+ import { ApiRequestError, type ApiClient, apiUrl } from "../../../../../lib/util/api.js";
6
6
  import { Attachment, attachmentContentUrl } from "../../../../../lib/mail/mailApi.js";
7
7
  import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
8
8
  import { bytesToBase64, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
@@ -31,28 +31,38 @@ export function cachedBodyContent(uid: string, version: number): BodyContent | u
31
31
  /**
32
32
  * The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
33
33
  * (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
34
- * Successful answers are remembered per uid and version.
34
+ * Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
35
+ * the `jwt` cookie; without one it is the cookie request it always was.
35
36
  */
36
- export async function fetchBodyContent(uid: string, version: number, signal?: AbortSignal): Promise<BodyContent> {
37
+ export async function fetchBodyContent(uid: string, version: number, signal?: AbortSignal, client?: ApiClient): Promise<BodyContent> {
37
38
  const cached = cachedBodyContent(uid, version);
38
39
  if (cached) {
39
40
  return cached;
40
41
  }
41
- // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
42
- const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), {
43
- credentials: "include",
44
- headers: { Accept: ACCEPT },
45
- signal,
46
- });
47
- if (!res.ok) {
48
- const contentType = res.headers.get("content-type") ?? "";
49
- const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
50
- throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
42
+ const path = `/mail/messages/${encodeURIComponent(uid)}/content`;
43
+ let text: string;
44
+ let contentType: string;
45
+ if (client) {
46
+ // `fetchBlob()` rejects with the same `ApiRequestError` a failed response gives, and its blob carries the response's own type.
47
+ const blob = await client.fetchBlob(path, { headers: { Accept: ACCEPT }, signal });
48
+ text = await blob.text();
49
+ contentType = blob.type;
50
+ } else {
51
+ // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
52
+ const res = await fetch(apiUrl(path), {
53
+ credentials: "include",
54
+ headers: { Accept: ACCEPT },
55
+ signal,
56
+ });
57
+ if (!res.ok) {
58
+ const resType = res.headers.get("content-type") ?? "";
59
+ const body = resType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
60
+ throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
61
+ }
62
+ text = await res.text();
63
+ contentType = res.headers.get("content-type") ?? "";
51
64
  }
52
- const text = await res.text();
53
- const content: BodyContent = (res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
54
- ? { kind: "html", html: text }
55
- : { kind: "text", text };
65
+ const content: BodyContent = contentType.toLowerCase().includes("text/html") ? { kind: "html", html: text } : { kind: "text", text };
56
66
  if (cache.size >= CACHE_LIMIT) {
57
67
  cache.delete(cache.keys().next().value as string);
58
68
  }
@@ -40,15 +40,16 @@ export async function olderConversationMessages(message: Message, client?: ApiCl
40
40
 
41
41
  /**
42
42
  * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
43
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
43
+ * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
44
+ * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
44
45
  */
45
- export async function printableOlderMessage(message: Message): Promise<PrintableMessage> {
46
- const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
46
+ export async function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage> {
47
+ const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
47
48
  if (message.encrypted) {
48
49
  const unlocked = getUnlockedKeys(message.mailboxUid);
49
50
  if (unlocked) {
50
51
  const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
51
- const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
52
+ const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid, client), unlocked);
52
53
  const content =
53
54
  security.text !== undefined ? ({ kind: "text", text: security.text } as const) : security.html !== undefined ? ({ kind: "html", html: security.html } as const) : undefined;
54
55
  if (content) {
@@ -70,7 +71,8 @@ export async function printableOlderMessage(message: Message): Promise<Printable
70
71
  return {
71
72
  subject: message.subject || "(no subject)",
72
73
  headers: printHeaders(message, formatMailAddress(message.from)),
73
- content: await fetchBodyContent(message.uid, message.version),
74
+ // Without a client this is exactly the cookie request it always was.
75
+ content: await (client ? fetchBodyContent(message.uid, message.version, undefined, client) : fetchBodyContent(message.uid, message.version)),
74
76
  attachments,
75
77
  };
76
78
  }
@@ -0,0 +1,59 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef } from "react";
6
+ import { getPushClient, type PushEvent } from "../../../lib/mail/pushClient.js";
7
+
8
+ /** An item a push event says was created, changed or deleted: its uid, and whether it is gone. */
9
+ export interface FolderItemChange {
10
+ uid: string;
11
+ deleted: boolean;
12
+ }
13
+
14
+ /**
15
+ * The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
16
+ * `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
17
+ * is whatever every subscriber of the folder may see (a private calendar event's busy block), so a page fetches the item again by its uid.
18
+ */
19
+ export function changedItemOf(event: PushEvent, model: RegExp): FolderItemChange | undefined {
20
+ if (!model.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
21
+ return undefined;
22
+ }
23
+ const data = event.data as { uid?: unknown } | null | undefined;
24
+ return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
25
+ }
26
+
27
+ /**
28
+ * Keeps a page's folders live: while the page is mounted the tab's push connection is subscribed to `folderUids` (under the page's own
29
+ * `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
30
+ * contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
31
+ * tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
32
+ */
33
+ export function useFolderLiveUpdates(
34
+ group: string,
35
+ folderUids: readonly string[],
36
+ parse: (event: PushEvent) => FolderItemChange | undefined,
37
+ onChange: (change: FolderItemChange) => void,
38
+ ): void {
39
+ const key = [...new Set(folderUids)].sort().join(",");
40
+ useEffect(() => {
41
+ const client = getPushClient();
42
+ client.setChannels(key ? key.split(",") : [], group);
43
+ client.start();
44
+ return () => client.setChannels([], group);
45
+ }, [group, key]);
46
+
47
+ const latest = useRef({ parse, onChange });
48
+ latest.current = { parse, onChange };
49
+ useEffect(
50
+ () =>
51
+ getPushClient().onEvent((event) => {
52
+ const change = latest.current.parse(event);
53
+ if (change) {
54
+ latest.current.onChange(change);
55
+ }
56
+ }),
57
+ [],
58
+ );
59
+ }
@@ -64,9 +64,7 @@ async function openFailedDraft(event: SendEvent, client: ApiClient | undefined):
64
64
  });
65
65
  return;
66
66
  }
67
- // loadOriginalMessage() (quotedBody.ts) does not yet accept an explicit ApiClient itself - out of scope here (that module is not one of
68
- // this task's files); it still falls back to the default global session even under an ApiClientContext.Provider.
69
- const original = await loadOriginalMessage(moved, null);
67
+ const original = await loadOriginalMessage(moved, null, {}, client);
70
68
  const attachments = await listAttachments(moved.folderUid, moved.uid, client).catch(() => []);
71
69
  const byType = (type: "to" | "cc" | "bcc") => moved.recipients.filter((recipient) => recipient.type === type).map(formatRecipient).join(", ");
72
70
  openComposeFromOutside({
@@ -34,3 +34,30 @@ export function setNotificationsEnabled(enabled: boolean): void {
34
34
  // Storage blocked or full: the choice lasts until the page is reloaded, no longer.
35
35
  }
36
36
  }
37
+
38
+ /**
39
+ * `localStorage` key of the switch for the sounds that go with notifications (a chime for new mail, a bell for a calendar alarm, a buzz for an
40
+ * error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
41
+ */
42
+ export const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
43
+
44
+ /** Whether notification sounds are on (the default). */
45
+ export function getNotificationSoundsEnabled(): boolean {
46
+ try {
47
+ return localStorage.getItem(NOTIFICATION_SOUNDS_KEY) !== "off";
48
+ } catch {
49
+ return true;
50
+ }
51
+ }
52
+
53
+ export function setNotificationSoundsEnabled(enabled: boolean): void {
54
+ try {
55
+ if (enabled) {
56
+ localStorage.removeItem(NOTIFICATION_SOUNDS_KEY);
57
+ } else {
58
+ localStorage.setItem(NOTIFICATION_SOUNDS_KEY, "off");
59
+ }
60
+ } catch {
61
+ // Storage blocked or full: the choice lasts until the page is reloaded, no longer.
62
+ }
63
+ }
@@ -0,0 +1,135 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { getNotificationSoundsEnabled } from "./preferences.js";
6
+
7
+ /**
8
+ * The sounds that go with the app's notifications: a chime for new mail, a bell for a calendar alarm and a low double buzz for an error. They are
9
+ * made with the Web Audio API as they are played - no audio files to download, cache or ship - so they work offline and cost nothing until used.
10
+ *
11
+ * Browsers only let a page make sound after the person has interacted with it; until then the audio context stays suspended and a sound is silently
12
+ * skipped (it is resumed, and plays from then on, as soon as the page has had a click or a key press). A sound is never an error: whatever
13
+ * goes wrong with the audio is swallowed. The same sound is not played twice within `MIN_GAP_MS`, so a burst of notifications is one sound, not a clatter.
14
+ */
15
+ export type NotificationSound = "mail" | "calendar" | "error";
16
+
17
+ /** The shortest time between two plays of the same sound. */
18
+ export const MIN_GAP_MS = 750;
19
+
20
+ interface Tone {
21
+ /** Pitch in Hz at the start. */
22
+ frequency: number;
23
+ /** Pitch in Hz at the end, when it slides. */
24
+ endFrequency?: number;
25
+ /** Seconds after the sound starts. */
26
+ start: number;
27
+ /** Seconds it takes to die away. */
28
+ duration: number;
29
+ type: OscillatorType;
30
+ /** Peak loudness, 0 to 1. */
31
+ gain: number;
32
+ }
33
+
34
+ /** A soft two-note rising chime. */
35
+ const CHIME: Tone[] = [
36
+ { frequency: 1046.5, start: 0, duration: 0.5, type: "sine", gain: 0.16 },
37
+ { frequency: 1568, start: 0.14, duration: 0.8, type: "sine", gain: 0.14 },
38
+ ];
39
+
40
+ /** A struck bell, twice: a fundamental with the inharmonic partials of a bell, each dying away slowly. */
41
+ const BELL: Tone[] = [0, 0.75].flatMap((start) =>
42
+ [
43
+ { ratio: 1, gain: 0.16, duration: 1.4 },
44
+ { ratio: 2.76, gain: 0.08, duration: 1.0 },
45
+ { ratio: 5.4, gain: 0.04, duration: 0.7 },
46
+ { ratio: 8.93, gain: 0.02, duration: 0.4 },
47
+ ].map<Tone>((partial) => ({ frequency: 880 * partial.ratio, start, duration: partial.duration, type: "sine", gain: partial.gain })),
48
+ );
49
+
50
+ /** Two short falling buzzes - nothing like the other two. */
51
+ const ERROR: Tone[] = [
52
+ { frequency: 300, endFrequency: 190, start: 0, duration: 0.2, type: "sawtooth", gain: 0.09 },
53
+ { frequency: 300, endFrequency: 190, start: 0.26, duration: 0.2, type: "sawtooth", gain: 0.09 },
54
+ ];
55
+
56
+ const SOUNDS: Record<NotificationSound, Tone[]> = { mail: CHIME, calendar: BELL, error: ERROR };
57
+
58
+ let context: AudioContext | undefined;
59
+ const lastPlayed: Partial<Record<NotificationSound, number>> = {};
60
+
61
+ function audioContext(): AudioContext | undefined {
62
+ if (context) {
63
+ return context;
64
+ }
65
+ const Ctor: typeof AudioContext | undefined =
66
+ typeof window === "undefined" ? undefined : (window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext);
67
+ if (!Ctor) {
68
+ return undefined;
69
+ }
70
+ try {
71
+ context = new Ctor();
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ return context;
76
+ }
77
+
78
+ function playTone(audio: AudioContext, tone: Tone): void {
79
+ const begin = audio.currentTime + tone.start;
80
+ const end = begin + tone.duration;
81
+ const oscillator = audio.createOscillator();
82
+ const volume = audio.createGain();
83
+ oscillator.type = tone.type;
84
+ oscillator.frequency.setValueAtTime(tone.frequency, begin);
85
+ if (tone.endFrequency !== undefined) {
86
+ oscillator.frequency.exponentialRampToValueAtTime(tone.endFrequency, end);
87
+ }
88
+ // A short attack so it doesn't click, then an exponential die-away.
89
+ volume.gain.setValueAtTime(0.0001, begin);
90
+ volume.gain.exponentialRampToValueAtTime(tone.gain, begin + 0.01);
91
+ volume.gain.exponentialRampToValueAtTime(0.0001, end);
92
+ oscillator.connect(volume);
93
+ volume.connect(audio.destination);
94
+ oscillator.start(begin);
95
+ oscillator.stop(end + 0.05);
96
+ }
97
+
98
+ /** Plays `sound`, unless the person turned notification sounds off, the same sound just played, or the browser won't make sound yet. Never throws. */
99
+ export function playNotificationSound(sound: NotificationSound, now: number = Date.now()): void {
100
+ if (!getNotificationSoundsEnabled()) {
101
+ return;
102
+ }
103
+ const last = lastPlayed[sound];
104
+ if (last !== undefined && now - last < MIN_GAP_MS) {
105
+ return;
106
+ }
107
+ try {
108
+ const audio = audioContext();
109
+ if (!audio) {
110
+ return;
111
+ }
112
+ lastPlayed[sound] = now;
113
+ const play = (): void => {
114
+ for (const tone of SOUNDS[sound]) {
115
+ playTone(audio, tone);
116
+ }
117
+ };
118
+ if (audio.state === "suspended") {
119
+ // Allowed once the page has been interacted with; until then this is refused and the sound is skipped.
120
+ void audio.resume().then(play, () => undefined);
121
+ } else {
122
+ play();
123
+ }
124
+ } catch {
125
+ // Audio is a nicety: a failure to make a sound never reaches the notification.
126
+ }
127
+ }
128
+
129
+ /** Forgets the audio context and what was played when - for tests. */
130
+ export function resetNotificationSounds(): void {
131
+ context = undefined;
132
+ for (const key of Object.keys(lastPlayed) as NotificationSound[]) {
133
+ delete lastPlayed[key];
134
+ }
135
+ }
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { getNotificationsEnabled } from "./preferences.js";
6
+ import { playNotificationSound } from "./sounds.js";
6
7
 
7
8
  /**
8
9
  * The app's one notification store: every pop-up - new mail, a failed send, an API error, an expired session - goes through
@@ -390,6 +391,14 @@ export function notify(input: NotifyInput): string {
390
391
  emit();
391
392
  return id;
392
393
  }
394
+ // What is shown is heard: a chime for new mail, a bell for a calendar notice, a buzz for an error.
395
+ if (entry.kind === "mail") {
396
+ playNotificationSound("mail");
397
+ } else if (entry.kind === "calendar") {
398
+ playNotificationSound("calendar");
399
+ } else if (entry.kind === "error") {
400
+ playNotificationSound("error");
401
+ }
393
402
  const room = visibleCount() < MAX_VISIBLE;
394
403
  entries.push(entry);
395
404
  if (room) {
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { pageTitle } from "../../shared/navigation/pageTitle.js";
6
+ import { useLocation, useNavigate } from "../../shared/navigation/index.js";
6
7
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
7
8
  import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
8
9
  import { HiOutlineBars3, HiOutlinePlus } from "react-icons/hi2";
@@ -24,11 +25,11 @@ import { ApiRequestError } from "../../../lib/util/api.js";
24
25
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
25
26
  import useIsMobile from "../../../lib/util/useIsMobile.js";
26
27
  import { CalendarEvent, getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
27
- import { getPushClient } from "../../../lib/mail/pushClient.js";
28
+ import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
28
29
  import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
29
30
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
30
31
  import { createFolder } from "../../../lib/mail/mailApi.js";
31
- import { CalendarOccurrence, expandAllOccurrences } from "../../../lib/calendar/recurrence.js";
32
+ import { CalendarOccurrence, expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
32
33
  import { useDayKey } from "../../../lib/calendar/useDayKey.js";
33
34
  import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
34
35
  import Drawer from "../../../lib/components/overlays/Drawer.js";
@@ -62,6 +63,9 @@ import { notifyApiError } from "../../shared/notifications/apiErrors.js";
62
63
  import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
63
64
  import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
64
65
 
66
+ /** The push client channel group (`PushClient.setChannels()`) the page's calendars are subscribed under. */
67
+ const CALENDAR_CHANNEL_GROUP = "calendar";
68
+
65
69
  /** The furthest instants a `Date` can hold: the List's "range" reaches both. */
66
70
  const MAX_DATE_MS = 8.64e15;
67
71
 
@@ -301,36 +305,35 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
301
305
  // fetch changes nothing: the next load shows the event as it is.
302
306
  const checkedFolderUidsRef = useRef(checkedFolderUids);
303
307
  checkedFolderUidsRef.current = checkedFolderUids;
304
- useEffect(
305
- () =>
306
- getPushClient().onEvent((event) => {
307
- const change = changedEventOf(event);
308
- if (!change) {
309
- return;
310
- }
311
- const { uid } = change;
312
- if (change.deleted) {
313
- setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
314
- return;
315
- }
316
- void getCalendarEvent(uid, client).then(
317
- (fresh) => {
318
- if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
319
- setEvents((previous) =>
320
- previous.some((existing) => existing.uid === fresh.uid)
321
- ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
322
- : [...previous, fresh],
323
- );
324
- }
325
- },
326
- (err: unknown) => {
327
- if (err instanceof ApiRequestError && err.status === 404) {
328
- setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
329
- }
330
- },
331
- );
332
- }),
333
- [client],
308
+ // Those events are published on the calendar's own channel, so the page subscribes to every calendar it lists - checked or not, so checking
309
+ // one needs no new subscription - while it is open.
310
+ useFolderLiveUpdates(
311
+ CALENDAR_CHANNEL_GROUP,
312
+ calendarFolders.map((f) => f.uid),
313
+ changedEventOf,
314
+ (change) => {
315
+ const { uid } = change;
316
+ if (change.deleted) {
317
+ setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
318
+ return;
319
+ }
320
+ void getCalendarEvent(uid, client).then(
321
+ (fresh) => {
322
+ if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
323
+ setEvents((previous) =>
324
+ previous.some((existing) => existing.uid === fresh.uid)
325
+ ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
326
+ : [...previous, fresh],
327
+ );
328
+ }
329
+ },
330
+ (err: unknown) => {
331
+ if (err instanceof ApiRequestError && err.status === 404) {
332
+ setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
333
+ }
334
+ },
335
+ );
336
+ },
334
337
  );
335
338
 
336
339
  // Search: `searchText` is the box, `query` what it held once typing paused (or Enter was pressed) and is what is searched for. A search covers every
@@ -538,6 +541,35 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
538
541
  setModal(null);
539
542
  }
540
543
 
544
+ // `?event=<uid>&start=<iso>` opens that event's card on the day it is on - where a calendar alarm's View button leads. `start` names which occurrence
545
+ // of a recurring event. Read from the page's own location, so it also works from the Calendar itself (a shallow navigation), and then taken off the
546
+ // address so a reload doesn't open it again.
547
+ const { search: locationSearch } = useLocation();
548
+ const navigateTo = useNavigate();
549
+ const openedFromAddressRef = useRef("");
550
+ useEffect(() => {
551
+ const params = new URLSearchParams(locationSearch);
552
+ const eventUid = params.get("event");
553
+ const wanted = `${eventUid}|${params.get("start")}`;
554
+ if (!eventUid || openedFromAddressRef.current === wanted) {
555
+ return;
556
+ }
557
+ openedFromAddressRef.current = wanted;
558
+ const requestedStart = new Date(params.get("start") ?? "");
559
+ navigateTo("/calendar", { replace: true });
560
+ getCalendarEvent(eventUid, client)
561
+ .then((event) => {
562
+ const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
563
+ const candidates = expandOccurrences(event, new Date(from.getTime() - 86_400_000), new Date(from.getTime() + 86_400_000));
564
+ const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ?? candidates[0];
565
+ if (match) {
566
+ setViewDate(occurrenceDay(match));
567
+ openEvent(match);
568
+ }
569
+ })
570
+ .catch((err) => notifyApiError(err, "Couldn't open the event"));
571
+ }, [locationSearch]);
572
+
541
573
  // Keyboard shortcuts - the toolbar's own actions. (A dialog open - the event editor - silences them.)
542
574
  useShortcut(SHORTCUTS.calendar.create, () => openNewEvent(), { enabled: !!mailboxUid && !!folderUid });
543
575
  useShortcut(SHORTCUTS.calendar.today, goToday);