@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.
- package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
- package/apps/shared/calendar/calendarReminders.ts +16 -7
- package/apps/shared/calendar/useCalendarReminders.ts +66 -61
- package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/layout/UserMenu.tsx +29 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
- package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
- package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- package/apps/shared/notifications/preferences.ts +27 -0
- package/apps/shared/notifications/sounds.ts +135 -0
- package/apps/shared/notifications/store.ts +9 -0
- package/apps/www/calendar/index.tsx +64 -32
- package/apps/www/contacts/index.tsx +58 -2
- package/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
- package/dist/apps/shared/calendar/calendarReminders.js +14 -6
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
- package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
- package/dist/apps/shared/components/layout/AppShell.js +4 -3
- package/dist/apps/shared/components/layout/UserMenu.js +9 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
- package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
- package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
- package/dist/apps/shared/notifications/preferences.d.ts +8 -0
- package/dist/apps/shared/notifications/preferences.js +27 -0
- package/dist/apps/shared/notifications/sounds.d.ts +15 -0
- package/dist/apps/shared/notifications/sounds.js +101 -0
- package/dist/apps/shared/notifications/store.js +11 -0
- package/dist/apps/www/calendar/index.js +37 -8
- package/dist/apps/www/contacts/index.js +49 -3
- package/dist/apps/www/tasks/index.js +29 -1
- package/dist/lib/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -0
- package/lib/mail/pushClient.ts +18 -4
- package/lib/tasks/tasksApi.ts +4 -0
- 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(
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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);
|