@rapidmx/web-client 0.34.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/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- 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/www/calendar/index.tsx +33 -31
- 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/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/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/www/calendar/index.js +7 -7
- 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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
/** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
|
|
6
6
|
|
|
7
7
|
import type { PushEvent } from "../../../lib/mail/pushClient.js";
|
|
8
|
+
import { changedItemOf } from "../live/useFolderLiveUpdates.js";
|
|
8
9
|
|
|
9
10
|
/** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
|
|
10
11
|
* which has its own action. */
|
|
@@ -20,9 +21,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
|
|
|
20
21
|
* changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
|
|
21
22
|
*/
|
|
22
23
|
export function changedEventOf(event: PushEvent): { uid: string; deleted: boolean } | undefined {
|
|
23
|
-
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
26
|
-
const data = event.data as { uid?: unknown } | null | undefined;
|
|
27
|
-
return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
|
|
24
|
+
return changedItemOf(event, EVENT_MODEL);
|
|
28
25
|
}
|
|
@@ -12,6 +12,7 @@ import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
|
12
12
|
|
|
13
13
|
export type ContactsView =
|
|
14
14
|
| { type: "all" }
|
|
15
|
+
| { type: "everyone" }
|
|
15
16
|
| { type: "favorites" }
|
|
16
17
|
| { type: "suggested" }
|
|
17
18
|
| { type: "list"; uid: string; name: string }
|
|
@@ -94,7 +95,7 @@ function NavItem({
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (
|
|
98
|
+
* Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
98
99
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
99
100
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
100
101
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
@@ -166,6 +167,9 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
166
167
|
const navContent = (idPrefix: string) => (
|
|
167
168
|
<>
|
|
168
169
|
<div className="flex flex-col gap-0.5">
|
|
170
|
+
{suggestedCount !== undefined && (
|
|
171
|
+
<NavItem label="All contacts" count={contacts.length + suggestedCount} active={active.type === "everyone"} onClick={() => handleSelect({ type: "everyone" })} />
|
|
172
|
+
)}
|
|
169
173
|
<NavItem label="Your contacts" count={contacts.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
|
|
170
174
|
<NavItem label="Favorites" count={favoriteCount} active={active.type === "favorites"} onClick={() => handleSelect({ type: "favorites" })} />
|
|
171
175
|
{suggestedCount !== undefined && (
|
|
@@ -104,8 +104,11 @@ export async function loadUpcomingEvents(folders: readonly Folder[], address: st
|
|
|
104
104
|
.slice(0, CARD_LIST_SIZE);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** A vCard attachment's text. */
|
|
108
|
-
export async function fetchVCardText(attachmentUid: string): Promise<string> {
|
|
107
|
+
/** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
|
|
108
|
+
export async function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string> {
|
|
109
|
+
if (client) {
|
|
110
|
+
return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
|
|
111
|
+
}
|
|
109
112
|
const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
|
|
110
113
|
if (!res.ok) {
|
|
111
114
|
throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
|
|
@@ -130,7 +133,7 @@ export async function loadParticipantCard(participant: Participant, context: Con
|
|
|
130
133
|
const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
|
|
131
134
|
let fallback: ParsedVCardContact | undefined;
|
|
132
135
|
for (const attachment of await vCardAttachments(context, client)) {
|
|
133
|
-
const cards = parseVCards(await fetchVCardText(attachment.uid));
|
|
136
|
+
const cards = parseVCards(await fetchVCardText(attachment.uid, client));
|
|
134
137
|
const match = cardForAddress(cards, participant.address, false);
|
|
135
138
|
if (match) {
|
|
136
139
|
return match;
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
HiOutlineNoSymbol,
|
|
17
17
|
HiOutlineSun,
|
|
18
18
|
} from "react-icons/hi2";
|
|
19
|
-
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
19
|
+
import { ApiRequestError, type ApiClient } from "../../../../lib/util/api.js";
|
|
20
20
|
import {
|
|
21
21
|
Attachment,
|
|
22
22
|
Folder,
|
|
@@ -200,6 +200,23 @@ function downloadFromUrl(url: string, filename: string): void {
|
|
|
200
200
|
a.remove();
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** How long an attachment saved through an explicit client keeps its object URL: the download has started well before this. */
|
|
204
|
+
const DOWNLOAD_URL_LIFETIME_MS = 60_000;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 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
|
|
208
|
+
* `downloadFromUrl()`. The URL stays in `urls` until it is revoked - after `DOWNLOAD_URL_LIFETIME_MS`, or when the pane unmounts.
|
|
209
|
+
*/
|
|
210
|
+
async function downloadThroughClient(client: ApiClient, path: string, filename: string, urls: Set<string>): Promise<void> {
|
|
211
|
+
const url = URL.createObjectURL(await client.fetchBlob(path));
|
|
212
|
+
urls.add(url);
|
|
213
|
+
downloadFromUrl(url, filename);
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
urls.delete(url);
|
|
216
|
+
URL.revokeObjectURL(url);
|
|
217
|
+
}, DOWNLOAD_URL_LIFETIME_MS);
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
/** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
|
|
204
221
|
* opaque download (`application/octet-stream`), never rendered in this origin. */
|
|
205
222
|
function downloadMimeAttachment(attachment: MimeAttachment): void {
|
|
@@ -529,6 +546,17 @@ function MessageDetailContent({
|
|
|
529
546
|
const { mailboxes, trackMessageChange } = useMailShell();
|
|
530
547
|
const { requestUnlock } = useUnlockPrompt();
|
|
531
548
|
const client = useApiClient();
|
|
549
|
+
// Object URLs of attachments saved through `client`, revoked when they have been left to lapse or when this pane unmounts.
|
|
550
|
+
const downloadUrls = useRef(new Set<string>());
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
const urls = downloadUrls.current;
|
|
553
|
+
return () => {
|
|
554
|
+
for (const url of urls) {
|
|
555
|
+
URL.revokeObjectURL(url);
|
|
556
|
+
}
|
|
557
|
+
urls.clear();
|
|
558
|
+
};
|
|
559
|
+
}, []);
|
|
532
560
|
// Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
|
|
533
561
|
// effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
|
|
534
562
|
// state; see keySession.ts's own doc comment).
|
|
@@ -783,11 +811,23 @@ function MessageDetailContent({
|
|
|
783
811
|
return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
|
|
784
812
|
}
|
|
785
813
|
|
|
814
|
+
/** Saves a listed attachment: through the explicit client when there is one (failures are reported, the file is an object URL), else as the
|
|
815
|
+
* cookie link always did. */
|
|
816
|
+
function saveAttachment(attachment: Attachment) {
|
|
817
|
+
if (client) {
|
|
818
|
+
downloadThroughClient(client, `/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, attachment.filename, downloadUrls.current).catch(
|
|
819
|
+
(err) => notifyApiError(err, "Couldn't download this attachment"),
|
|
820
|
+
);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename);
|
|
824
|
+
}
|
|
825
|
+
|
|
786
826
|
/** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
|
|
787
827
|
* pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
|
|
788
828
|
function prefetchReply() {
|
|
789
829
|
prefetchComposeWindow();
|
|
790
|
-
prefetchOriginalMessage(message);
|
|
830
|
+
prefetchOriginalMessage(message, client);
|
|
791
831
|
}
|
|
792
832
|
|
|
793
833
|
/**
|
|
@@ -812,7 +852,7 @@ function MessageDetailContent({
|
|
|
812
852
|
// Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
|
|
813
853
|
const late = (async (): Promise<ComposeLateInput> => {
|
|
814
854
|
const [original, own] = await Promise.all([
|
|
815
|
-
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
|
|
855
|
+
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }, client),
|
|
816
856
|
kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
|
|
817
857
|
]);
|
|
818
858
|
if (kind === "forward") {
|
|
@@ -1141,11 +1181,11 @@ function MessageDetailContent({
|
|
|
1141
1181
|
* sanitized body - in a frame of its own; see `printMessage.ts`. */
|
|
1142
1182
|
async function handlePrint() {
|
|
1143
1183
|
try {
|
|
1144
|
-
const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
|
|
1184
|
+
const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version, undefined, client));
|
|
1145
1185
|
const older = await olderConversationMessages(message, client).catch(() => []);
|
|
1146
1186
|
// A few at a time: a long conversation is hundreds of messages, each of them several requests.
|
|
1147
1187
|
const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) =>
|
|
1148
|
-
printableOlderMessage(other).catch(
|
|
1188
|
+
printableOlderMessage(other, client).catch(
|
|
1149
1189
|
(): PrintableMessage => ({
|
|
1150
1190
|
subject: other.subject || "(no subject)",
|
|
1151
1191
|
headers: printHeaders(other, formatMailAddress(other.from)),
|
|
@@ -1643,9 +1683,18 @@ function MessageDetailContent({
|
|
|
1643
1683
|
<VCardAttachmentChip
|
|
1644
1684
|
label={`${attachment.filename} (${formatBytes(attachment.sizeBytes)})`}
|
|
1645
1685
|
message={message}
|
|
1646
|
-
loadText={() => fetchVCardText(attachment.uid)}
|
|
1647
|
-
onDownload={() =>
|
|
1686
|
+
loadText={() => fetchVCardText(attachment.uid, client)}
|
|
1687
|
+
onDownload={() => saveAttachment(attachment)}
|
|
1648
1688
|
/>
|
|
1689
|
+
) : client ? (
|
|
1690
|
+
// Through the explicit client a link cannot carry the token, so the file is fetched and saved from a button.
|
|
1691
|
+
<button
|
|
1692
|
+
type="button"
|
|
1693
|
+
onClick={() => saveAttachment(attachment)}
|
|
1694
|
+
className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
|
|
1695
|
+
>
|
|
1696
|
+
{attachment.filename} ({formatBytes(attachment.sizeBytes)})
|
|
1697
|
+
</button>
|
|
1649
1698
|
) : (
|
|
1650
1699
|
<a
|
|
1651
1700
|
href={attachmentContentUrl(attachment.uid)}
|
|
@@ -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({
|
|
@@ -25,7 +25,7 @@ import { ApiRequestError } from "../../../lib/util/api.js";
|
|
|
25
25
|
import { useApiClient } from "../../../lib/util/apiClientContext.js";
|
|
26
26
|
import useIsMobile from "../../../lib/util/useIsMobile.js";
|
|
27
27
|
import { CalendarEvent, getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
|
|
28
|
-
import {
|
|
28
|
+
import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
29
29
|
import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
|
|
30
30
|
import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
|
|
31
31
|
import { createFolder } from "../../../lib/mail/mailApi.js";
|
|
@@ -63,6 +63,9 @@ import { notifyApiError } from "../../shared/notifications/apiErrors.js";
|
|
|
63
63
|
import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
|
|
64
64
|
import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
|
|
65
65
|
|
|
66
|
+
/** The push client channel group (`PushClient.setChannels()`) the page's calendars are subscribed under. */
|
|
67
|
+
const CALENDAR_CHANNEL_GROUP = "calendar";
|
|
68
|
+
|
|
66
69
|
/** The furthest instants a `Date` can hold: the List's "range" reaches both. */
|
|
67
70
|
const MAX_DATE_MS = 8.64e15;
|
|
68
71
|
|
|
@@ -302,36 +305,35 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
|
|
|
302
305
|
// fetch changes nothing: the next load shows the event as it is.
|
|
303
306
|
const checkedFolderUidsRef = useRef(checkedFolderUids);
|
|
304
307
|
checkedFolderUidsRef.current = checkedFolderUids;
|
|
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
|
-
|
|
334
|
-
[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
|
+
},
|
|
335
337
|
);
|
|
336
338
|
|
|
337
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
|