@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
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
7
|
import { createPortal } from "react-dom";
|
|
8
8
|
import useIsMobile from "../../../../lib/util/useIsMobile.js";
|
|
9
|
-
import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
|
|
9
|
+
import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineSpeakerWave, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
|
|
10
10
|
import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "../../../../lib/auth/profileApi.js";
|
|
11
11
|
import { listMailboxes } from "../../../../lib/mail/mailApi.js";
|
|
12
12
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
@@ -20,7 +20,7 @@ import { MailConnectionContext } from "../../mail/useMailConnection.js";
|
|
|
20
20
|
import { ownMailboxes } from "../../mail/primaryMailbox.js";
|
|
21
21
|
import ThemeSwitch from "./ThemeSwitch.js";
|
|
22
22
|
import { desktopPermission, requestDesktopPermission, setDesktopOfferDismissed, } from "../../mail/newMailNotifications.js";
|
|
23
|
-
import { getNotificationsEnabled, setNotificationsEnabled } from "../../notifications/preferences.js";
|
|
23
|
+
import { getNotificationSoundsEnabled, getNotificationsEnabled, setNotificationSoundsEnabled, setNotificationsEnabled, } from "../../notifications/preferences.js";
|
|
24
24
|
import { dismissAll } from "../../notifications/store.js";
|
|
25
25
|
function Avatar({ profile, initials, large }) {
|
|
26
26
|
const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
|
|
@@ -53,6 +53,7 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
53
53
|
const menuRef = useRef(null);
|
|
54
54
|
const [detectedAdmin, setDetectedAdmin] = useState(false);
|
|
55
55
|
const [popups, setPopups] = useState(true);
|
|
56
|
+
const [sounds, setSounds] = useState(true);
|
|
56
57
|
const [permission, setPermission] = useState("unsupported");
|
|
57
58
|
const [profile, setProfile] = useState(undefined);
|
|
58
59
|
// Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
|
|
@@ -140,9 +141,14 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
140
141
|
useEffect(() => {
|
|
141
142
|
if (open && showNotificationSettings) {
|
|
142
143
|
setPopups(getNotificationsEnabled());
|
|
144
|
+
setSounds(getNotificationSoundsEnabled());
|
|
143
145
|
setPermission(desktopPermission());
|
|
144
146
|
}
|
|
145
147
|
}, [open, showNotificationSettings]);
|
|
148
|
+
function toggleSounds() {
|
|
149
|
+
setNotificationSoundsEnabled(!sounds);
|
|
150
|
+
setSounds(!sounds);
|
|
151
|
+
}
|
|
146
152
|
function togglePopups() {
|
|
147
153
|
setNotificationsEnabled(!popups);
|
|
148
154
|
setPopups(!popups);
|
|
@@ -206,7 +212,7 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
206
212
|
const accountUrl = accountUrlOf(authServerUrl);
|
|
207
213
|
return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "relative flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), unseenErrors > 0 && (_jsx("span", { "data-testid": "unseen-errors-dot", "aria-hidden": "true", className: "absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" })), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open &&
|
|
208
214
|
anchor &&
|
|
209
|
-
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Settings"] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
|
|
215
|
+
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Settings"] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": sounds, type: "button", onClick: toggleSounds, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineSpeakerWave, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notification sounds"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: sounds ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
|
|
210
216
|
setOpen(false);
|
|
211
217
|
onShowNotifications();
|
|
212
218
|
}, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineClock, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Recent notifications"] }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsxs("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
|
|
@@ -143,6 +143,21 @@ function downloadFromUrl(url, filename) {
|
|
|
143
143
|
a.click();
|
|
144
144
|
a.remove();
|
|
145
145
|
}
|
|
146
|
+
/** How long an attachment saved through an explicit client keeps its object URL: the download has started well before this. */
|
|
147
|
+
const DOWNLOAD_URL_LIFETIME_MS = 60000;
|
|
148
|
+
/**
|
|
149
|
+
* Saves the file at `path` through `client` (its origin and token, which a link's `href` cannot carry): its bytes become an object URL handed to
|
|
150
|
+
* `downloadFromUrl()`. The URL stays in `urls` until it is revoked - after `DOWNLOAD_URL_LIFETIME_MS`, or when the pane unmounts.
|
|
151
|
+
*/
|
|
152
|
+
async function downloadThroughClient(client, path, filename, urls) {
|
|
153
|
+
const url = URL.createObjectURL(await client.fetchBlob(path));
|
|
154
|
+
urls.add(url);
|
|
155
|
+
downloadFromUrl(url, filename);
|
|
156
|
+
setTimeout(() => {
|
|
157
|
+
urls.delete(url);
|
|
158
|
+
URL.revokeObjectURL(url);
|
|
159
|
+
}, DOWNLOAD_URL_LIFETIME_MS);
|
|
160
|
+
}
|
|
146
161
|
/** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
|
|
147
162
|
* opaque download (`application/octet-stream`), never rendered in this origin. */
|
|
148
163
|
function downloadMimeAttachment(attachment) {
|
|
@@ -290,6 +305,17 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
290
305
|
const { mailboxes, trackMessageChange } = useMailShell();
|
|
291
306
|
const { requestUnlock } = useUnlockPrompt();
|
|
292
307
|
const client = useApiClient();
|
|
308
|
+
// Object URLs of attachments saved through `client`, revoked when they have been left to lapse or when this pane unmounts.
|
|
309
|
+
const downloadUrls = useRef(new Set());
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
const urls = downloadUrls.current;
|
|
312
|
+
return () => {
|
|
313
|
+
for (const url of urls) {
|
|
314
|
+
URL.revokeObjectURL(url);
|
|
315
|
+
}
|
|
316
|
+
urls.clear();
|
|
317
|
+
};
|
|
318
|
+
}, []);
|
|
293
319
|
// Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
|
|
294
320
|
// effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
|
|
295
321
|
// state; see keySession.ts's own doc comment).
|
|
@@ -526,11 +552,20 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
526
552
|
const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid, {}, client).catch(() => undefined));
|
|
527
553
|
return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
|
|
528
554
|
}
|
|
555
|
+
/** Saves a listed attachment: through the explicit client when there is one (failures are reported, the file is an object URL), else as the
|
|
556
|
+
* cookie link always did. */
|
|
557
|
+
function saveAttachment(attachment) {
|
|
558
|
+
if (client) {
|
|
559
|
+
downloadThroughClient(client, `/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, attachment.filename, downloadUrls.current).catch((err) => notifyApiError(err, "Couldn't download this attachment"));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename);
|
|
563
|
+
}
|
|
529
564
|
/** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
|
|
530
565
|
* pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
|
|
531
566
|
function prefetchReply() {
|
|
532
567
|
prefetchComposeWindow();
|
|
533
|
-
prefetchOriginalMessage(message);
|
|
568
|
+
prefetchOriginalMessage(message, client);
|
|
534
569
|
}
|
|
535
570
|
/**
|
|
536
571
|
* Opens the compose window at once from what is already in memory - the message's own subject and sender, and the
|
|
@@ -554,7 +589,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
554
589
|
// Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
|
|
555
590
|
const late = (async () => {
|
|
556
591
|
const [original, own] = await Promise.all([
|
|
557
|
-
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
|
|
592
|
+
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }, client),
|
|
558
593
|
kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
|
|
559
594
|
]);
|
|
560
595
|
if (kind === "forward") {
|
|
@@ -842,10 +877,10 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
842
877
|
* sanitized body - in a frame of its own; see `printMessage.ts`. */
|
|
843
878
|
async function handlePrint() {
|
|
844
879
|
try {
|
|
845
|
-
const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
|
|
880
|
+
const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version, undefined, client));
|
|
846
881
|
const older = await olderConversationMessages(message, client).catch(() => []);
|
|
847
882
|
// A few at a time: a long conversation is hundreds of messages, each of them several requests.
|
|
848
|
-
const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) => printableOlderMessage(other).catch(() => ({
|
|
883
|
+
const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) => printableOlderMessage(other, client).catch(() => ({
|
|
849
884
|
subject: other.subject || "(no subject)",
|
|
850
885
|
headers: printHeaders(other, formatMailAddress(other.from)),
|
|
851
886
|
content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
|
|
@@ -955,6 +990,6 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
955
990
|
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
956
991
|
.map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
|
|
957
992
|
? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (_jsx(VCardAttachmentChip, { label: attachment.filename ?? `Unnamed ${attachment.contentType} attachment`, message: message, loadText: async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0)), onDownload: () => downloadMimeAttachment(attachment) })) : (_jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` })) }, `${index}:${attachment.filename ?? ""}`))) }))
|
|
958
|
-
: listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid), onDownload: () =>
|
|
993
|
+
: listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid, client), onDownload: () => saveAttachment(attachment) })) : client ? (_jsxs("button", { type: "button", onClick: () => saveAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
|
|
959
994
|
return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
|
|
960
995
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ApiClient } from "../../../../../lib/util/api.js";
|
|
1
2
|
import { Message, Recipient } from "../../../../../lib/mail/mailApi.js";
|
|
2
3
|
import type { QuotedBody } from "../../../../../lib/mail/compose/composeQuoting.js";
|
|
3
4
|
import type { MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
|
|
@@ -19,7 +20,7 @@ export declare const QUOTE_CACHE_MS = 30000;
|
|
|
19
20
|
* keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
|
|
20
21
|
* Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
|
|
21
22
|
*/
|
|
22
|
-
export declare function prefetchOriginalMessage(message: Message): void;
|
|
23
|
+
export declare function prefetchOriginalMessage(message: Message, client?: ApiClient): void;
|
|
23
24
|
/** Forgets every remembered body (the sign-out and test seam). */
|
|
24
25
|
export declare function clearOriginalMessageCache(): void;
|
|
25
26
|
/**
|
|
@@ -50,4 +51,4 @@ export declare function clearOriginalMessageCache(): void;
|
|
|
50
51
|
*/
|
|
51
52
|
export declare function loadOriginalMessage(message: Message, security: MessageSecurityResult | null, options?: {
|
|
52
53
|
recipients?: boolean;
|
|
53
|
-
}): Promise<OriginalMessage>;
|
|
54
|
+
}, client?: ApiClient): Promise<OriginalMessage>;
|
|
@@ -28,9 +28,9 @@ const contentCache = new Map();
|
|
|
28
28
|
/**
|
|
29
29
|
* The server's sanitized HTML body of a message (`GET /mail/messages/:id/content`), or `undefined` when it has none (a
|
|
30
30
|
* `text/plain` message is answered with its preview instead) or the request failed or timed out. Never rejects. Remembered
|
|
31
|
-
* for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time.
|
|
31
|
+
* for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time. Through `client` when given, else the cookie request.
|
|
32
32
|
*/
|
|
33
|
-
function fetchContentHtml(uid) {
|
|
33
|
+
function fetchContentHtml(uid, client) {
|
|
34
34
|
const cached = contentCache.get(uid);
|
|
35
35
|
if (cached && Date.now() - cached.at < QUOTE_CACHE_MS) {
|
|
36
36
|
return cached.promise;
|
|
@@ -39,6 +39,10 @@ function fetchContentHtml(uid) {
|
|
|
39
39
|
const timer = setTimeout(() => controller.abort(), QUOTE_FETCH_TIMEOUT_MS);
|
|
40
40
|
const promise = (async () => {
|
|
41
41
|
try {
|
|
42
|
+
if (client) {
|
|
43
|
+
const blob = await client.fetchBlob(`/mail/messages/${encodeURIComponent(uid)}/content`, { signal: controller.signal });
|
|
44
|
+
return blob.type.toLowerCase().includes("text/html") ? await blob.text() : undefined;
|
|
45
|
+
}
|
|
42
46
|
const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), { credentials: "include", signal: controller.signal });
|
|
43
47
|
return res.ok && (res.headers.get("content-type") ?? "").includes("text/html") ? await res.text() : undefined;
|
|
44
48
|
}
|
|
@@ -76,9 +80,9 @@ function withTimeout(promise, ms) {
|
|
|
76
80
|
* keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
|
|
77
81
|
* Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
|
|
78
82
|
*/
|
|
79
|
-
export function prefetchOriginalMessage(message) {
|
|
83
|
+
export function prefetchOriginalMessage(message, client) {
|
|
80
84
|
if (!message.encrypted) {
|
|
81
|
-
void fetchContentHtml(message.uid);
|
|
85
|
+
void fetchContentHtml(message.uid, client);
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
/** Forgets every remembered body (the sign-out and test seam). */
|
|
@@ -97,18 +101,26 @@ function headerRecipients(value, type) {
|
|
|
97
101
|
}
|
|
98
102
|
/** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
|
|
99
103
|
* or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
|
|
100
|
-
function serverImageResolver(message) {
|
|
104
|
+
function serverImageResolver(message, client) {
|
|
101
105
|
let attachments;
|
|
102
106
|
return async (reference, signal) => {
|
|
103
107
|
try {
|
|
104
|
-
attachments ?? (attachments = listAttachments(message.folderUid, message.uid));
|
|
108
|
+
attachments ?? (attachments = listAttachments(message.folderUid, message.uid, client));
|
|
105
109
|
const attachment = findInlineAttachment(await attachments, reference);
|
|
106
110
|
const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
|
|
107
111
|
if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
|
|
108
112
|
return undefined;
|
|
109
113
|
}
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
let bytes;
|
|
115
|
+
if (client) {
|
|
116
|
+
// A failed response rejects here, which the catch below turns into no picture - as an `!res.ok` answer always was.
|
|
117
|
+
const blob = await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, { signal });
|
|
118
|
+
bytes = new Uint8Array(await blob.arrayBuffer());
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
|
|
122
|
+
bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
|
|
123
|
+
}
|
|
112
124
|
return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
|
|
113
125
|
}
|
|
114
126
|
catch {
|
|
@@ -152,7 +164,7 @@ function partImageResolver(parts) {
|
|
|
152
164
|
*
|
|
153
165
|
* The quote builders sanitize whatever HTML this returns (`sanitizeQuotedHtml()`), whichever source it came from.
|
|
154
166
|
*/
|
|
155
|
-
export async function loadOriginalMessage(message, security, options = {}) {
|
|
167
|
+
export async function loadOriginalMessage(message, security, options = {}, client) {
|
|
156
168
|
const recovered = security?.text !== undefined ? { text: security.text } : security?.html !== undefined ? { html: security.html } : undefined;
|
|
157
169
|
// Headers inside an unsigned (or badly signed) encrypted message are whatever its encrypter chose to write, so only a
|
|
158
170
|
// verified signature makes them a source of recipients.
|
|
@@ -168,7 +180,7 @@ export async function loadOriginalMessage(message, security, options = {}) {
|
|
|
168
180
|
if (!recovered) {
|
|
169
181
|
// Never rejects; nothing (a plain-text message, a failure, a timeout) leaves the raw message below as the other way
|
|
170
182
|
// to the body.
|
|
171
|
-
const html = await fetchContentHtml(message.uid);
|
|
183
|
+
const html = await fetchContentHtml(message.uid, client);
|
|
172
184
|
if (html !== undefined) {
|
|
173
185
|
result.body = { html };
|
|
174
186
|
}
|
|
@@ -178,7 +190,7 @@ export async function loadOriginalMessage(message, security, options = {}) {
|
|
|
178
190
|
}
|
|
179
191
|
try {
|
|
180
192
|
if (needsRaw) {
|
|
181
|
-
const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid), QUOTE_FETCH_TIMEOUT_MS));
|
|
193
|
+
const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid, client), QUOTE_FETCH_TIMEOUT_MS));
|
|
182
194
|
if (!recovered && result.body.html === undefined) {
|
|
183
195
|
const body = extractDisplayBody(entity);
|
|
184
196
|
result.body = body.html !== undefined ? { html: body.html } : body.text !== undefined ? { text: body.text } : {};
|
|
@@ -194,7 +206,7 @@ export async function loadOriginalMessage(message, security, options = {}) {
|
|
|
194
206
|
// message's own recipients.
|
|
195
207
|
}
|
|
196
208
|
if (result.body.html !== undefined) {
|
|
197
|
-
const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
|
|
209
|
+
const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message, client);
|
|
198
210
|
const html = result.body.html;
|
|
199
211
|
// 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 -
|
|
200
212
|
// the ones that have arrived stay.
|
|
@@ -8,6 +8,7 @@ import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
|
|
|
8
8
|
import Alert from "../../../../../lib/components/feedback/Alert.js";
|
|
9
9
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
10
10
|
import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
|
|
11
|
+
import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
|
|
11
12
|
import { useResolvedTheme } from "../../../appearance/resolvedTheme.js";
|
|
12
13
|
import { attachmentSource, cachedBodyContent, fetchBodyContent, makeCidResolver } from "./bodyContent.js";
|
|
13
14
|
import { prepareBodyHtml } from "./bodyHtml.js";
|
|
@@ -43,6 +44,8 @@ export default function MessageBody({ messageUid, messageVersion, title, content
|
|
|
43
44
|
});
|
|
44
45
|
const [attempt, setAttempt] = useState(0);
|
|
45
46
|
const supplied = content !== undefined;
|
|
47
|
+
// The explicit ApiClient this body is rendered under, if any - undefined for the cookie-based web app.
|
|
48
|
+
const client = useApiClient();
|
|
46
49
|
useEffect(() => {
|
|
47
50
|
if (supplied) {
|
|
48
51
|
return;
|
|
@@ -59,7 +62,7 @@ export default function MessageBody({ messageUid, messageVersion, title, content
|
|
|
59
62
|
if (!revalidating) {
|
|
60
63
|
setRemote({ status: "loading" });
|
|
61
64
|
}
|
|
62
|
-
fetchBodyContent(messageUid, messageVersion, controller.signal).then((loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }), (err) => {
|
|
65
|
+
fetchBodyContent(messageUid, messageVersion, controller.signal, client).then((loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }), (err) => {
|
|
63
66
|
if (!controller.signal.aborted && !revalidating) {
|
|
64
67
|
setRemote({ status: "error", message: err instanceof ApiRequestError ? err.message : "Could not load this message." });
|
|
65
68
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ApiClient } from "../../../../../lib/util/api.js";
|
|
1
2
|
import { Attachment } from "../../../../../lib/mail/mailApi.js";
|
|
2
3
|
import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
|
|
3
4
|
/** What a message's body is: server-sanitized HTML, or plain text (a message with no HTML part, or the server's plain-text fallback). */
|
|
@@ -17,9 +18,10 @@ export declare function cachedBodyContent(uid: string, version: number): BodyCon
|
|
|
17
18
|
/**
|
|
18
19
|
* The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
|
|
19
20
|
* (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
|
|
20
|
-
* Successful answers are remembered per uid and version.
|
|
21
|
+
* Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
|
|
22
|
+
* the `jwt` cookie; without one it is the cookie request it always was.
|
|
21
23
|
*/
|
|
22
|
-
export declare function fetchBodyContent(uid: string, version: number, signal?: AbortSignal): Promise<BodyContent>;
|
|
24
|
+
export declare function fetchBodyContent(uid: string, version: number, signal?: AbortSignal, client?: ApiClient): Promise<BodyContent>;
|
|
23
25
|
/** The largest inline image embedded into a body from a signed or encrypted message. */
|
|
24
26
|
export declare const MAX_INLINE_IMAGE_BYTES = 5000000;
|
|
25
27
|
/** A server attachment record as it may really arrive: the model carries the part's `Content-ID`, though the client type doesn't list it. */
|
|
@@ -23,28 +23,39 @@ export function cachedBodyContent(uid, version) {
|
|
|
23
23
|
/**
|
|
24
24
|
* The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
|
|
25
25
|
* (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
|
|
26
|
-
* Successful answers are remembered per uid and version.
|
|
26
|
+
* Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
|
|
27
|
+
* the `jwt` cookie; without one it is the cookie request it always was.
|
|
27
28
|
*/
|
|
28
|
-
export async function fetchBodyContent(uid, version, signal) {
|
|
29
|
+
export async function fetchBodyContent(uid, version, signal, client) {
|
|
29
30
|
const cached = cachedBodyContent(uid, version);
|
|
30
31
|
if (cached) {
|
|
31
32
|
return cached;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
|
|
42
|
-
throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
|
|
34
|
+
const path = `/mail/messages/${encodeURIComponent(uid)}/content`;
|
|
35
|
+
let text;
|
|
36
|
+
let contentType;
|
|
37
|
+
if (client) {
|
|
38
|
+
// `fetchBlob()` rejects with the same `ApiRequestError` a failed response gives, and its blob carries the response's own type.
|
|
39
|
+
const blob = await client.fetchBlob(path, { headers: { Accept: ACCEPT }, signal });
|
|
40
|
+
text = await blob.text();
|
|
41
|
+
contentType = blob.type;
|
|
43
42
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
else {
|
|
44
|
+
// Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
|
|
45
|
+
const res = await fetch(apiUrl(path), {
|
|
46
|
+
credentials: "include",
|
|
47
|
+
headers: { Accept: ACCEPT },
|
|
48
|
+
signal,
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const resType = res.headers.get("content-type") ?? "";
|
|
52
|
+
const body = resType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
|
|
53
|
+
throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
|
|
54
|
+
}
|
|
55
|
+
text = await res.text();
|
|
56
|
+
contentType = res.headers.get("content-type") ?? "";
|
|
57
|
+
}
|
|
58
|
+
const content = contentType.toLowerCase().includes("text/html") ? { kind: "html", html: text } : { kind: "text", text };
|
|
48
59
|
if (cache.size >= CACHE_LIMIT) {
|
|
49
60
|
cache.delete(cache.keys().next().value);
|
|
50
61
|
}
|
|
@@ -10,6 +10,7 @@ export declare function printHeaders(message: Message, from: string): PrintableM
|
|
|
10
10
|
export declare function olderConversationMessages(message: Message, client?: ApiClient): Promise<Message[]>;
|
|
11
11
|
/**
|
|
12
12
|
* 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
|
|
13
|
-
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
|
|
13
|
+
* 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
|
|
14
|
+
* request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
|
|
14
15
|
*/
|
|
15
|
-
export declare function printableOlderMessage(message: Message): Promise<PrintableMessage>;
|
|
16
|
+
export declare function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage>;
|
|
@@ -34,15 +34,16 @@ export async function olderConversationMessages(message, client) {
|
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* 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
|
|
37
|
-
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
|
|
37
|
+
* 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
|
|
38
|
+
* request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
|
|
38
39
|
*/
|
|
39
|
-
export async function printableOlderMessage(message) {
|
|
40
|
-
const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
|
|
40
|
+
export async function printableOlderMessage(message, client) {
|
|
41
|
+
const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
|
|
41
42
|
if (message.encrypted) {
|
|
42
43
|
const unlocked = getUnlockedKeys(message.mailboxUid);
|
|
43
44
|
if (unlocked) {
|
|
44
45
|
const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
|
|
45
|
-
const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
|
|
46
|
+
const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid, client), unlocked);
|
|
46
47
|
const content = security.text !== undefined ? { kind: "text", text: security.text } : security.html !== undefined ? { kind: "html", html: security.html } : undefined;
|
|
47
48
|
if (content) {
|
|
48
49
|
return {
|
|
@@ -63,7 +64,8 @@ export async function printableOlderMessage(message) {
|
|
|
63
64
|
return {
|
|
64
65
|
subject: message.subject || "(no subject)",
|
|
65
66
|
headers: printHeaders(message, formatMailAddress(message.from)),
|
|
66
|
-
|
|
67
|
+
// Without a client this is exactly the cookie request it always was.
|
|
68
|
+
content: await (client ? fetchBodyContent(message.uid, message.version, undefined, client) : fetchBodyContent(message.uid, message.version)),
|
|
67
69
|
attachments,
|
|
68
70
|
};
|
|
69
71
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type PushEvent } from "../../../lib/mail/pushClient.js";
|
|
2
|
+
/** An item a push event says was created, changed or deleted: its uid, and whether it is gone. */
|
|
3
|
+
export interface FolderItemChange {
|
|
4
|
+
uid: string;
|
|
5
|
+
deleted: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
|
|
9
|
+
* `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
|
|
10
|
+
* 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.
|
|
11
|
+
*/
|
|
12
|
+
export declare function changedItemOf(event: PushEvent, model: RegExp): FolderItemChange | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* 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
|
|
15
|
+
* `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
|
|
16
|
+
* contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
|
|
17
|
+
* tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useFolderLiveUpdates(group: string, folderUids: readonly string[], parse: (event: PushEvent) => FolderItemChange | undefined, onChange: (change: FolderItemChange) => void): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
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 } from "../../../lib/mail/pushClient.js";
|
|
7
|
+
/**
|
|
8
|
+
* The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
|
|
9
|
+
* `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
|
|
10
|
+
* 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.
|
|
11
|
+
*/
|
|
12
|
+
export function changedItemOf(event, model) {
|
|
13
|
+
if (!model.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const data = event.data;
|
|
17
|
+
return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 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
|
|
21
|
+
* `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
|
|
22
|
+
* contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
|
|
23
|
+
* tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
|
|
24
|
+
*/
|
|
25
|
+
export function useFolderLiveUpdates(group, folderUids, parse, onChange) {
|
|
26
|
+
const key = [...new Set(folderUids)].sort().join(",");
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const client = getPushClient();
|
|
29
|
+
client.setChannels(key ? key.split(",") : [], group);
|
|
30
|
+
client.start();
|
|
31
|
+
return () => client.setChannels([], group);
|
|
32
|
+
}, [group, key]);
|
|
33
|
+
const latest = useRef({ parse, onChange });
|
|
34
|
+
latest.current = { parse, onChange };
|
|
35
|
+
useEffect(() => getPushClient().onEvent((event) => {
|
|
36
|
+
const change = latest.current.parse(event);
|
|
37
|
+
if (change) {
|
|
38
|
+
latest.current.onChange(change);
|
|
39
|
+
}
|
|
40
|
+
}), []);
|
|
41
|
+
}
|
|
@@ -57,9 +57,7 @@ async function openFailedDraft(event, client) {
|
|
|
57
57
|
});
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
// this task's files); it still falls back to the default global session even under an ApiClientContext.Provider.
|
|
62
|
-
const original = await loadOriginalMessage(moved, null);
|
|
60
|
+
const original = await loadOriginalMessage(moved, null, {}, client);
|
|
63
61
|
const attachments = await listAttachments(moved.folderUid, moved.uid, client).catch(() => []);
|
|
64
62
|
const byType = (type) => moved.recipients.filter((recipient) => recipient.type === type).map(formatRecipient).join(", ");
|
|
65
63
|
openComposeFromOutside({
|
|
@@ -11,3 +11,11 @@ export declare const NOTIFICATIONS_ENABLED_KEY = "rapidmx-new-mail-popups";
|
|
|
11
11
|
/** Whether pop-up notifications are on (the default). */
|
|
12
12
|
export declare function getNotificationsEnabled(): boolean;
|
|
13
13
|
export declare function setNotificationsEnabled(enabled: boolean): void;
|
|
14
|
+
/**
|
|
15
|
+
* `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
|
|
16
|
+
* error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
|
|
17
|
+
*/
|
|
18
|
+
export declare const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
|
|
19
|
+
/** Whether notification sounds are on (the default). */
|
|
20
|
+
export declare function getNotificationSoundsEnabled(): boolean;
|
|
21
|
+
export declare function setNotificationSoundsEnabled(enabled: boolean): void;
|
|
@@ -34,3 +34,30 @@ export function setNotificationsEnabled(enabled) {
|
|
|
34
34
|
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* `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
|
|
39
|
+
* error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
|
|
40
|
+
*/
|
|
41
|
+
export const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
|
|
42
|
+
/** Whether notification sounds are on (the default). */
|
|
43
|
+
export function getNotificationSoundsEnabled() {
|
|
44
|
+
try {
|
|
45
|
+
return localStorage.getItem(NOTIFICATION_SOUNDS_KEY) !== "off";
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function setNotificationSoundsEnabled(enabled) {
|
|
52
|
+
try {
|
|
53
|
+
if (enabled) {
|
|
54
|
+
localStorage.removeItem(NOTIFICATION_SOUNDS_KEY);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
localStorage.setItem(NOTIFICATION_SOUNDS_KEY, "off");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 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
|
|
3
|
+
* 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.
|
|
4
|
+
*
|
|
5
|
+
* 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
|
|
6
|
+
* 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
|
|
7
|
+
* 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.
|
|
8
|
+
*/
|
|
9
|
+
export type NotificationSound = "mail" | "calendar" | "error";
|
|
10
|
+
/** The shortest time between two plays of the same sound. */
|
|
11
|
+
export declare const MIN_GAP_MS = 750;
|
|
12
|
+
/** Plays `sound`, unless the person turned notification sounds off, the same sound just played, or the browser won't make sound yet. Never throws. */
|
|
13
|
+
export declare function playNotificationSound(sound: NotificationSound, now?: number): void;
|
|
14
|
+
/** Forgets the audio context and what was played when - for tests. */
|
|
15
|
+
export declare function resetNotificationSounds(): void;
|