@rapidmx/web-client 0.6.0 → 0.7.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/components/admin/settings/BrandingForm.tsx +398 -385
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
- package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
- package/apps/shared/components/layout/AppShell.tsx +288 -288
- package/apps/shared/components/mail/ConversationList.tsx +225 -33
- package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
- package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
- package/apps/shared/components/mail/MenuButton.tsx +384 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
- package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
- package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
- package/apps/shared/components/mail/compose/recipients.ts +104 -0
- package/apps/shared/components/mail/labelMenu.tsx +278 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
- package/apps/shared/components/mail/listPreferences.ts +228 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
- package/apps/shared/styles/app.css +28 -5
- package/apps/www/index.tsx +1854 -1314
- package/apps/www/messages/[uid].tsx +5 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
- package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
- package/dist/apps/shared/components/mail/ConversationList.js +84 -10
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
- package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
- package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
- package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
- package/dist/apps/shared/components/mail/MenuButton.js +211 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
- package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
- package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
- package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
- package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
- package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
- package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
- package/dist/apps/shared/components/mail/labelMenu.js +134 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
- package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
- package/dist/apps/shared/components/mail/listPreferences.js +186 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
- package/dist/apps/shared/styles/app.css +28 -5
- package/dist/apps/www/index.js +506 -112
- package/dist/apps/www/messages/[uid].js +2 -3
- package/package.json +2 -2
- package/apps/www/settings/focused-inbox/index.tsx +0 -150
- package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
- package/dist/apps/www/settings/focused-inbox/index.js +0 -71
|
@@ -3,27 +3,45 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
HiOutlineArchiveBox,
|
|
8
|
+
HiOutlineArrowUturnLeft,
|
|
9
|
+
HiOutlineArrowUturnRight,
|
|
10
|
+
HiOutlineCheck,
|
|
11
|
+
HiOutlineExclamationTriangle,
|
|
12
|
+
HiOutlineFolderArrowDown,
|
|
13
|
+
HiOutlineLockClosed,
|
|
14
|
+
} from "react-icons/hi2";
|
|
8
15
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
16
|
import {
|
|
10
17
|
Attachment,
|
|
18
|
+
Folder,
|
|
11
19
|
Message,
|
|
12
|
-
MessageClassification,
|
|
13
20
|
ReceiptType,
|
|
21
|
+
Recipient,
|
|
14
22
|
approveReceipt,
|
|
15
23
|
archiveMessage,
|
|
16
24
|
attachmentContentUrl,
|
|
17
25
|
cancelScheduledSend,
|
|
18
|
-
classifyMessage,
|
|
19
26
|
declineReceipt,
|
|
27
|
+
getMailbox,
|
|
20
28
|
getMessage,
|
|
21
29
|
getMessageRawContent,
|
|
30
|
+
moveMessage,
|
|
22
31
|
recallMessage,
|
|
23
32
|
setMessageLabels,
|
|
24
33
|
} from "@rapidmx/react-shared/mail/mailApi.js";
|
|
25
34
|
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
26
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
buildForwardQuote,
|
|
37
|
+
buildReplyQuote,
|
|
38
|
+
buildReplyRecipients,
|
|
39
|
+
buildReplyThreading,
|
|
40
|
+
forwardSubject,
|
|
41
|
+
replySubject,
|
|
42
|
+
} from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
43
|
+
import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
|
|
44
|
+
import MoveToFolderDialog from "./MoveToFolderDialog.js";
|
|
27
45
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
28
46
|
import {
|
|
29
47
|
MessageSecurityResult,
|
|
@@ -40,11 +58,14 @@ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.
|
|
|
40
58
|
import KeyChangeReview from "../contacts/KeyChangeReview.js";
|
|
41
59
|
import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
|
|
42
60
|
import { useCompose } from "./compose/ComposeContext.js";
|
|
61
|
+
import { loadOriginalMessage } from "./compose/quotedBody.js";
|
|
62
|
+
import { formatRecipient } from "./compose/recipients.js";
|
|
43
63
|
import { useMailShell } from "./layout/MailShell.js";
|
|
44
64
|
import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
|
|
45
65
|
import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
|
|
46
66
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
47
67
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
68
|
+
import LabelMenuButton from "./labelMenu.js";
|
|
48
69
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
49
70
|
|
|
50
71
|
/** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
|
|
@@ -155,6 +176,44 @@ function downloadMimeAttachment(attachment: MimeAttachment): void {
|
|
|
155
176
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
156
177
|
}
|
|
157
178
|
|
|
179
|
+
/**
|
|
180
|
+
* One of the reading pane's own actions - Reply, Reply All, Forward, Archive, Move to Other - as an icon
|
|
181
|
+
* button. The action's name is its `aria-label` *and* its tooltip, so it has the same accessible name a
|
|
182
|
+
* labelled button had, and is never drawn beside the icon at any width: the row is icon-only everywhere,
|
|
183
|
+
* the way Outlook's own reading-pane command bar is, so it reads the same in the ~396px pane beside the
|
|
184
|
+
* message list and in a maximised window rather than changing shape at a breakpoint.
|
|
185
|
+
*
|
|
186
|
+
* A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
|
|
187
|
+
* text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
|
|
188
|
+
*/
|
|
189
|
+
function IconAction({
|
|
190
|
+
icon,
|
|
191
|
+
label,
|
|
192
|
+
onClick,
|
|
193
|
+
disabled,
|
|
194
|
+
busy,
|
|
195
|
+
}: {
|
|
196
|
+
icon: React.ReactNode;
|
|
197
|
+
label: string;
|
|
198
|
+
onClick: () => void;
|
|
199
|
+
disabled?: boolean;
|
|
200
|
+
busy?: boolean;
|
|
201
|
+
}) {
|
|
202
|
+
return (
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
aria-label={label}
|
|
206
|
+
title={label}
|
|
207
|
+
aria-busy={busy || undefined}
|
|
208
|
+
disabled={disabled}
|
|
209
|
+
onClick={onClick}
|
|
210
|
+
className="inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent"
|
|
211
|
+
>
|
|
212
|
+
{icon}
|
|
213
|
+
</button>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
158
217
|
function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
|
|
159
218
|
const { label, className } = SECURITY_INDICATOR[security.state];
|
|
160
219
|
if (security.state !== "verified_at_first_open") {
|
|
@@ -202,58 +261,10 @@ const ENCRYPTED_LOAD_ERROR = "Couldn't load this message's encrypted content.";
|
|
|
202
261
|
const BODY_CSP_META =
|
|
203
262
|
"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'\">";
|
|
204
263
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const RESOURCE_URI_ATTRIBUTES = new Set(["src", "srcset", "background", "poster", "lowsrc", "dynsrc", "xlink:href", "action", "formaction"]);
|
|
208
|
-
|
|
209
|
-
/** Decodes CSS escapes first (so an escaped `u\72l(` can't hide from the checks below), then drops every
|
|
210
|
-
* `@import` and neutralizes every `url()`/`image-set()` reference that isn't a `data:`/`cid:` URI. */
|
|
211
|
-
export function stripRemoteCssUrls(css: string): string {
|
|
212
|
-
return css
|
|
213
|
-
.replace(/\\([0-9a-f]{1,6})\s?/gi, (_match, hex: string) => String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)))
|
|
214
|
-
.replace(/\\(.)/g, "$1")
|
|
215
|
-
.replace(/@import[^;]*;?/gi, "")
|
|
216
|
-
.replace(/(?:-webkit-)?image-set\((?:[^()]|\([^()]*\))*\)/gi, (match) =>
|
|
217
|
-
[...match.matchAll(/(["'])(.*?)\1/g)].every(([, , uri]) => EMBEDDED_URI.test(uri)) ? match : "none",
|
|
218
|
-
)
|
|
219
|
-
.replace(/url\(\s*(["']?)(.*?)\1\s*\)/gi, (match, _quote: string, uri: string) => (EMBEDDED_URI.test(uri) ? match : "none"));
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
let bodyPurifier: ReturnType<typeof DOMPurify> | undefined;
|
|
223
|
-
|
|
224
|
-
/** A dedicated DOMPurify instance (hooks registered here never leak into any other DOMPurify caller)
|
|
225
|
-
* that additionally strips every remote resource reference - see `stripRemoteCssUrls()`. */
|
|
226
|
-
function getBodyPurifier(): ReturnType<typeof DOMPurify> {
|
|
227
|
-
if (!bodyPurifier) {
|
|
228
|
-
bodyPurifier = DOMPurify(window);
|
|
229
|
-
bodyPurifier.addHook("uponSanitizeElement", (node, data) => {
|
|
230
|
-
if (data.tagName === "style") {
|
|
231
|
-
// An element's `textContent` is always a string (only documents/doctypes yield null).
|
|
232
|
-
node.textContent = stripRemoteCssUrls(node.textContent as string);
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
bodyPurifier.addHook("uponSanitizeAttribute", (node, data) => {
|
|
236
|
-
const name = data.attrName.toLowerCase();
|
|
237
|
-
if (name === "style") {
|
|
238
|
-
data.attrValue = stripRemoteCssUrls(data.attrValue);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
const isNavigationLink = name === "href" && ["a", "area"].includes(node.nodeName.toLowerCase());
|
|
242
|
-
if (RESOURCE_URI_ATTRIBUTES.has(name) || (name === "href" && !isNavigationLink)) {
|
|
243
|
-
const candidates = name === "srcset" ? data.attrValue.split(/,\s+/) : [data.attrValue];
|
|
244
|
-
if (!candidates.every((candidate) => EMBEDDED_URI.test(candidate))) {
|
|
245
|
-
data.keepAttr = false;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
return bodyPurifier;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Sanitizes a client-rendered body for `srcDoc`, with the CSP meta as its very first element. */
|
|
264
|
+
/** Sanitizes a client-rendered body for `srcDoc` (DOMPurify plus every remote resource reference stripped - see
|
|
265
|
+
* react-shared's `messageBodySanitizer.ts`), with the CSP meta as its very first element. */
|
|
254
266
|
export function buildSecureSrcDoc(html: string): string {
|
|
255
|
-
|
|
256
|
-
return BODY_CSP_META + sanitized;
|
|
267
|
+
return BODY_CSP_META + sanitizeMessageBodyHtml(html);
|
|
257
268
|
}
|
|
258
269
|
|
|
259
270
|
export interface MessageDetailPaneProps {
|
|
@@ -282,18 +293,21 @@ export interface MessageDetailPaneProps {
|
|
|
282
293
|
/** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
|
|
283
294
|
* successfully canceling a scheduled send. */
|
|
284
295
|
onScheduledSendCanceled?: (updated: Message) => void;
|
|
285
|
-
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
*
|
|
292
|
-
|
|
296
|
+
/** Every folder of this message's own mailbox, for the Move to prompt's destination list. Absent or
|
|
297
|
+
* empty simply hides the control — there is nowhere to move to. Each caller already resolves this to
|
|
298
|
+
* work out `isSentItems`/`isOutbox`/`draftsFolderUid`, so nothing new is fetched for it. */
|
|
299
|
+
folders?: Folder[];
|
|
300
|
+
/** Called with the server's updated copy (now in the chosen folder) after a successful Move to. A move
|
|
301
|
+
* takes the message out of whichever folder is being listed, so callers remove it from their list
|
|
302
|
+
* rather than patching it — unlike `onClassified`, which this replaces. */
|
|
303
|
+
onMoved?: (updated: Message) => void;
|
|
304
|
+
/** A folder created from the Move to prompt, so the caller's own list and the folder sidebar pick it up
|
|
305
|
+
* without a page load. */
|
|
306
|
+
onFolderCreated?: (folder: Folder) => void;
|
|
293
307
|
/** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
|
|
294
|
-
* mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox
|
|
295
|
-
* `
|
|
296
|
-
*
|
|
308
|
+
* mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`):
|
|
309
|
+
* `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are only ever
|
|
310
|
+
* `true` on a real delivered copy, so the banner below is self-gating. */
|
|
297
311
|
onReceiptHandled?: (updated: Message) => void;
|
|
298
312
|
/** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
|
|
299
313
|
* successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
|
|
@@ -309,6 +323,13 @@ export interface MessageDetailPaneProps {
|
|
|
309
323
|
* a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
|
|
310
324
|
* changing labels never moves a message between folders. */
|
|
311
325
|
onLabelsChanged?: (updated: Message) => void;
|
|
326
|
+
/** Handed a label created from the Labels menu, for the caller to add to `labels`. Without it the menu
|
|
327
|
+
* offers no "New label" row, only the link to where labels are managed. */
|
|
328
|
+
onLabelCreated?: (label: Label) => void;
|
|
329
|
+
/** Rendered as one message of a thread (`ConversationThreadPane`) rather than as the pane itself: the
|
|
330
|
+
* subject becomes a heading under the thread's own, since a document has one `h1` and the thread's is
|
|
331
|
+
* the conversation. Everything else - the badges, the actions, the body - is identical. */
|
|
332
|
+
inThread?: boolean;
|
|
312
333
|
}
|
|
313
334
|
|
|
314
335
|
function formatBytes(bytes: number): string {
|
|
@@ -320,9 +341,9 @@ function formatBytes(bytes: number): string {
|
|
|
320
341
|
/**
|
|
321
342
|
* A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
|
|
322
343
|
* Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
|
|
323
|
-
* the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
|
|
324
|
-
* a message row)
|
|
325
|
-
*
|
|
344
|
+
* and the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
|
|
345
|
+
* a message row) — see each call site for how `message`/`attachments`/`isSentItems` are sourced. The
|
|
346
|
+
* desktop pane shows whichever message the list opened, including one opened from a conversation row.
|
|
326
347
|
*/
|
|
327
348
|
export default function MessageDetailPane(props: MessageDetailPaneProps) {
|
|
328
349
|
if (!props.message) {
|
|
@@ -351,12 +372,15 @@ function MessageDetailContent({
|
|
|
351
372
|
isOutbox,
|
|
352
373
|
draftsFolderUid,
|
|
353
374
|
onScheduledSendCanceled,
|
|
354
|
-
|
|
355
|
-
|
|
375
|
+
folders,
|
|
376
|
+
onMoved,
|
|
377
|
+
onFolderCreated,
|
|
356
378
|
onReceiptHandled,
|
|
357
379
|
onArchived,
|
|
358
380
|
labels,
|
|
359
381
|
onLabelsChanged,
|
|
382
|
+
onLabelCreated,
|
|
383
|
+
inThread,
|
|
360
384
|
}: MessageDetailPaneProps & { message: Message }) {
|
|
361
385
|
// A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
|
|
362
386
|
// `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
|
|
@@ -371,16 +395,13 @@ function MessageDetailContent({
|
|
|
371
395
|
const [canceling, setCanceling] = useState(false);
|
|
372
396
|
const [archiving, setArchiving] = useState(false);
|
|
373
397
|
const [archiveError, setArchiveError] = useState<string | null>(null);
|
|
374
|
-
const [
|
|
375
|
-
const [togglingLabelUid, setTogglingLabelUid] = useState<string | null>(null);
|
|
398
|
+
const [savingLabels, setSavingLabels] = useState(false);
|
|
376
399
|
const [labelsError, setLabelsError] = useState<string | null>(null);
|
|
377
400
|
const [error, setError] = useState<string | null>(null);
|
|
378
401
|
// Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
|
|
379
402
|
// pane rather than inside a confirmation modal — the two flows never need to share one message.
|
|
380
403
|
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
381
|
-
const [
|
|
382
|
-
const [classifyError, setClassifyError] = useState<string | null>(null);
|
|
383
|
-
const [alwaysForSender, setAlwaysForSender] = useState(false);
|
|
404
|
+
const [movePrompt, setMovePrompt] = useState(false);
|
|
384
405
|
// Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
|
|
385
406
|
// `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
|
|
386
407
|
// wouldn't distinguish which row's buttons should show a loading state.
|
|
@@ -613,40 +634,65 @@ function MessageDetailContent({
|
|
|
613
634
|
setUnlockRefresh((n) => n + 1);
|
|
614
635
|
}
|
|
615
636
|
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
openCompose({
|
|
621
|
-
mailboxUid: message.mailboxUid,
|
|
622
|
-
to: message.from.address,
|
|
623
|
-
subject: replySubject(message.subject),
|
|
624
|
-
quotedHtml: buildReplyQuote(message),
|
|
625
|
-
signatureContext: "reply_forward",
|
|
626
|
-
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
627
|
-
});
|
|
628
|
-
}
|
|
637
|
+
// Reply, Reply All and Forward first load the full body to quote (see `loadQuotedBody()`), so the buttons are
|
|
638
|
+
// disabled meanwhile - a second click would open a second window. An encrypted original keeps its reply
|
|
639
|
+
// encrypted, since the quote may carry its decrypted content.
|
|
640
|
+
const [preparingCompose, setPreparingCompose] = useState(false);
|
|
629
641
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
cc: cc.join(", "),
|
|
636
|
-
subject: replySubject(message.subject),
|
|
637
|
-
quotedHtml: buildReplyQuote(message),
|
|
638
|
-
signatureContext: "reply_forward",
|
|
639
|
-
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
640
|
-
});
|
|
642
|
+
/** The replying mailbox's own addresses (primary and aliases), which a reply never goes to - from the mail shell's
|
|
643
|
+
* mailbox list, else fetched; none when neither is available. */
|
|
644
|
+
async function ownAddresses(): Promise<string[]> {
|
|
645
|
+
const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid).catch(() => undefined));
|
|
646
|
+
return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
|
|
641
647
|
}
|
|
642
648
|
|
|
643
|
-
function
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
649
|
+
async function handleReplyOrForward(kind: "reply" | "replyAll" | "forward") {
|
|
650
|
+
setPreparingCompose(true);
|
|
651
|
+
try {
|
|
652
|
+
const [original, own] = await Promise.all([
|
|
653
|
+
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
|
|
654
|
+
kind === "forward" ? [] : ownAddresses(),
|
|
655
|
+
]);
|
|
656
|
+
const encrypt = !!message.encrypted;
|
|
657
|
+
// What makes the new message part of this one's thread rather than a conversation of its own:
|
|
658
|
+
// the server composes the MIME from the recipients, subject and HTML alone, so nothing else
|
|
659
|
+
// recovers what is being replied to. A forward carries it for the same reason - it continues
|
|
660
|
+
// the thread it came from, which is where its recipient will file the reply to it.
|
|
661
|
+
const threading = buildReplyThreading(message);
|
|
662
|
+
if (kind === "forward") {
|
|
663
|
+
openCompose({
|
|
664
|
+
mailboxUid: message.mailboxUid,
|
|
665
|
+
subject: forwardSubject(message.subject),
|
|
666
|
+
quotedHtml: buildForwardQuote(message, original.body),
|
|
667
|
+
signatureContext: "reply_forward",
|
|
668
|
+
encrypt,
|
|
669
|
+
threading,
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
// The message's own recipients are only the envelope recipient of a delivered message, so Reply All
|
|
674
|
+
// answers whoever its headers actually name, with anything the record holds that they don't.
|
|
675
|
+
const known = new Set((original.recipients ?? []).map((r) => r.address.trim().toLowerCase()));
|
|
676
|
+
const recipients = buildReplyRecipients(
|
|
677
|
+
{ ...message, recipients: [...(original.recipients ?? []), ...message.recipients.filter((r) => !known.has(r.address.trim().toLowerCase()))] },
|
|
678
|
+
own,
|
|
679
|
+
kind === "replyAll",
|
|
680
|
+
);
|
|
681
|
+
const format = (list: Recipient[]) => list.map((r) => formatRecipient(r)).join(", ");
|
|
682
|
+
openCompose({
|
|
683
|
+
mailboxUid: message.mailboxUid,
|
|
684
|
+
to: format(recipients.to),
|
|
685
|
+
cc: recipients.cc.length > 0 ? format(recipients.cc) : undefined,
|
|
686
|
+
subject: replySubject(message.subject),
|
|
687
|
+
quotedHtml: buildReplyQuote(message, original.body),
|
|
688
|
+
signatureContext: "reply_forward",
|
|
689
|
+
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
690
|
+
encrypt,
|
|
691
|
+
threading,
|
|
692
|
+
});
|
|
693
|
+
} finally {
|
|
694
|
+
setPreparingCompose(false);
|
|
695
|
+
}
|
|
650
696
|
}
|
|
651
697
|
|
|
652
698
|
async function handleRecall() {
|
|
@@ -709,50 +755,41 @@ function MessageDetailContent({
|
|
|
709
755
|
}
|
|
710
756
|
}
|
|
711
757
|
|
|
712
|
-
// Only ever invoked from
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
758
|
+
// Only ever invoked from the Labels menu below, which itself only renders once `message` is loaded.
|
|
759
|
+
// Several labels are ticked in the menu and saved together here, in one `setMessageLabels()` call:
|
|
760
|
+
// that call replaces the whole `labelUids` list rather than patching one entry, so a menu that saved
|
|
761
|
+
// per tick would send a request (and burn an optimistic-lock `version`) for every single tick.
|
|
716
762
|
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
// the same stale snapshot.
|
|
722
|
-
async function handleToggleLabel(labelUid: string) {
|
|
723
|
-
setTogglingLabelUid(labelUid);
|
|
763
|
+
// The list is computed from the newest copy known (`latestLabelsMessageRef`, never a stale render
|
|
764
|
+
// closure), so a save that follows another still carries the `version` the server last handed back.
|
|
765
|
+
async function handleApplyLabels(labelUids: string[]) {
|
|
766
|
+
setSavingLabels(true);
|
|
724
767
|
setLabelsError(null);
|
|
725
768
|
try {
|
|
726
|
-
// Read through the ref (not this render's closure) so a toggle fired from a not-yet-re-rendered
|
|
727
|
-
// handler still sees the previous toggle's server response.
|
|
728
769
|
const base = message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current;
|
|
729
|
-
const
|
|
730
|
-
const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
|
|
731
|
-
const updated = await setMessageLabels(base, next);
|
|
770
|
+
const updated = await setMessageLabels(base, labelUids);
|
|
732
771
|
latestLabelsMessageRef.current = updated;
|
|
733
772
|
setLabelsMessage(updated);
|
|
734
773
|
onLabelsChanged?.(updated);
|
|
735
774
|
} catch (err) {
|
|
736
775
|
setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
|
|
737
776
|
} finally {
|
|
738
|
-
|
|
777
|
+
setSavingLabels(false);
|
|
739
778
|
}
|
|
740
779
|
}
|
|
741
780
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
setClassifying(false);
|
|
755
|
-
}
|
|
781
|
+
/**
|
|
782
|
+
* Moves this message into the folder picked in the Move to prompt. Deliberately *not* caught here:
|
|
783
|
+
* `MoveToFolderDialog` shows a failure beside the destination that would retry it, and closes only
|
|
784
|
+
* once the move has actually landed.
|
|
785
|
+
*
|
|
786
|
+
* The move is awaited on its own line rather than written as `onMoved?.(await moveMessage(...))` -
|
|
787
|
+
* an optional call whose callee is absent never evaluates its arguments at all, so a caller that
|
|
788
|
+
* passes no `onMoved` would have had nothing moved while the prompt closed as if it had.
|
|
789
|
+
*/
|
|
790
|
+
async function handleMove(folderUid: string) {
|
|
791
|
+
const updated = await moveMessage(message, folderUid);
|
|
792
|
+
onMoved?.(updated);
|
|
756
793
|
}
|
|
757
794
|
|
|
758
795
|
// Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
|
|
@@ -813,8 +850,31 @@ function MessageDetailContent({
|
|
|
813
850
|
? senderKeyState!.conflict
|
|
814
851
|
: undefined;
|
|
815
852
|
|
|
853
|
+
const shownSubject = (protectedSubject ?? message.subject) || "(no subject)";
|
|
854
|
+
/**
|
|
855
|
+
* How tall the message body is - the one thing on this pane worth every pixel it can have.
|
|
856
|
+
*
|
|
857
|
+
* The same rule in both places: the pane is a full-height flex column and the body is its one growing
|
|
858
|
+
* child, so it fills whatever the header leaves (`min-h-0` on both, or a tall body would stretch the
|
|
859
|
+
* column past the pane instead of scrolling inside it). Inside a thread that height comes from the
|
|
860
|
+
* message's own row, which `ConversationThreadPane` gives `min-h-full` of its scrolling list - so the
|
|
861
|
+
* height is still the window's, resolved down the flex chain rather than written here as a `vh`
|
|
862
|
+
* number, and it follows a resize with no breakpoint to cross.
|
|
863
|
+
*
|
|
864
|
+
* Why not size it to its content: that would need the document *inside* the frame to measure and
|
|
865
|
+
* report itself, and these frames are `sandbox=""` - scripts off - because they render mail from
|
|
866
|
+
* strangers. So a message longer than the pane keeps its own scrollbar, and everything shorter shows
|
|
867
|
+
* whole with the frame reaching the bottom of the pane either way.
|
|
868
|
+
*/
|
|
869
|
+
const bodyClassName = "flex-1 min-h-0 w-full";
|
|
870
|
+
|
|
816
871
|
return (
|
|
817
|
-
|
|
872
|
+
// `h-full` outside a thread only: the standalone message route renders this pane straight into a
|
|
873
|
+
// *block* (`MailShell`'s own `<main>`), where nothing stretches it and a percentage of that block's
|
|
874
|
+
// own resolved height is the height. Inside a thread the parent is a flex row whose own height came
|
|
875
|
+
// out of the flex algorithm - Chrome won't resolve a percentage against that, and an explicit
|
|
876
|
+
// height would also opt the pane out of the stretching that does size it correctly there.
|
|
877
|
+
<div className={`flex-1 min-w-0 min-h-0 flex flex-col${inThread ? "" : " h-full"}`}>
|
|
818
878
|
<div className="border-b border-border p-4">
|
|
819
879
|
{backHref && (
|
|
820
880
|
<a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
|
|
@@ -823,7 +883,12 @@ function MessageDetailContent({
|
|
|
823
883
|
)}
|
|
824
884
|
<div className="flex items-start justify-between gap-3">
|
|
825
885
|
<div className="flex items-center gap-2 min-w-0">
|
|
826
|
-
|
|
886
|
+
{/* One `h1` per document: inside a thread the conversation's own subject is it. */}
|
|
887
|
+
{inThread ? (
|
|
888
|
+
<h3 className="text-sm font-semibold truncate">{shownSubject}</h3>
|
|
889
|
+
) : (
|
|
890
|
+
<h1 className="text-lg font-bold tracking-tight truncate">{shownSubject}</h1>
|
|
891
|
+
)}
|
|
827
892
|
{security && <SecurityIndicator security={security} />}
|
|
828
893
|
</div>
|
|
829
894
|
{sendInProgress && (
|
|
@@ -1029,32 +1094,66 @@ function MessageDetailContent({
|
|
|
1029
1094
|
<p className="text-sm text-text-muted">
|
|
1030
1095
|
To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
|
|
1031
1096
|
</p>
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
Reply
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1097
|
+
{/* Wraps: in a thread the pane can be as narrow as the reading pane gets (the list takes
|
|
1098
|
+
384px of it), and these are six controls. */}
|
|
1099
|
+
<div className="flex flex-wrap gap-1 mt-3">
|
|
1100
|
+
<IconAction
|
|
1101
|
+
icon={<HiOutlineArrowUturnLeft size={16} aria-hidden="true" />}
|
|
1102
|
+
label="Reply"
|
|
1103
|
+
disabled={preparingCompose}
|
|
1104
|
+
onClick={() => void handleReplyOrForward("reply")}
|
|
1105
|
+
/>
|
|
1106
|
+
<IconAction
|
|
1107
|
+
icon={
|
|
1108
|
+
// The conventional reply-all glyph: the reply arrow, doubled. `hi2` has no
|
|
1109
|
+
// reply-all icon of its own, and nothing else in it means "answer everyone".
|
|
1110
|
+
<span className="inline-flex items-center" aria-hidden="true">
|
|
1111
|
+
<HiOutlineArrowUturnLeft size={16} />
|
|
1112
|
+
<HiOutlineArrowUturnLeft size={16} className="-ml-2.5" />
|
|
1113
|
+
</span>
|
|
1114
|
+
}
|
|
1115
|
+
label="Reply All"
|
|
1116
|
+
disabled={preparingCompose}
|
|
1117
|
+
onClick={() => void handleReplyOrForward("replyAll")}
|
|
1118
|
+
/>
|
|
1119
|
+
<IconAction
|
|
1120
|
+
icon={<HiOutlineArrowUturnRight size={16} aria-hidden="true" />}
|
|
1121
|
+
label="Forward"
|
|
1122
|
+
disabled={preparingCompose}
|
|
1123
|
+
onClick={() => void handleReplyOrForward("forward")}
|
|
1124
|
+
/>
|
|
1042
1125
|
{!inOutbox && message.folderUid !== draftsFolderUid && (
|
|
1043
|
-
<
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
loading={archiving}
|
|
1126
|
+
<IconAction
|
|
1127
|
+
icon={<HiOutlineArchiveBox size={16} aria-hidden="true" />}
|
|
1128
|
+
label="Archive"
|
|
1129
|
+
busy={archiving}
|
|
1048
1130
|
disabled={archiving}
|
|
1049
1131
|
onClick={handleArchive}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1132
|
+
/>
|
|
1133
|
+
)}
|
|
1134
|
+
{folders && folders.length > 0 && (
|
|
1135
|
+
<IconAction
|
|
1136
|
+
icon={<HiOutlineFolderArrowDown size={16} aria-hidden="true" />}
|
|
1137
|
+
label="Move to"
|
|
1138
|
+
onClick={() => setMovePrompt(true)}
|
|
1139
|
+
/>
|
|
1053
1140
|
)}
|
|
1054
1141
|
{labels && labels.length > 0 && (
|
|
1055
|
-
<
|
|
1056
|
-
Labels
|
|
1057
|
-
|
|
1142
|
+
<LabelMenuButton
|
|
1143
|
+
aria-label="Labels"
|
|
1144
|
+
label="Labels"
|
|
1145
|
+
className="border border-border py-1.5"
|
|
1146
|
+
labels={labels}
|
|
1147
|
+
mailboxUid={message.mailboxUid}
|
|
1148
|
+
onLabelCreated={onLabelCreated}
|
|
1149
|
+
applied={currentLabelsMessage.labelUids ?? []}
|
|
1150
|
+
onCommit={(labelUids) => void handleApplyLabels(labelUids)}
|
|
1151
|
+
busy={savingLabels}
|
|
1152
|
+
note="Ticked labels are applied to this message and unticked ones removed."
|
|
1153
|
+
emptyNote="This mailbox has no labels yet."
|
|
1154
|
+
commit={{ label: "Apply" }}
|
|
1155
|
+
clear={{ label: "Remove all labels" }}
|
|
1156
|
+
/>
|
|
1058
1157
|
)}
|
|
1059
1158
|
</div>
|
|
1060
1159
|
{archiveError && (
|
|
@@ -1062,6 +1161,11 @@ function MessageDetailContent({
|
|
|
1062
1161
|
<Alert>{archiveError}</Alert>
|
|
1063
1162
|
</div>
|
|
1064
1163
|
)}
|
|
1164
|
+
{labelsError && (
|
|
1165
|
+
<div className="mt-2">
|
|
1166
|
+
<Alert>{labelsError}</Alert>
|
|
1167
|
+
</div>
|
|
1168
|
+
)}
|
|
1065
1169
|
{labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (
|
|
1066
1170
|
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
|
1067
1171
|
{currentLabelsMessage
|
|
@@ -1078,34 +1182,8 @@ function MessageDetailContent({
|
|
|
1078
1182
|
))}
|
|
1079
1183
|
</div>
|
|
1080
1184
|
)}
|
|
1081
|
-
{
|
|
1082
|
-
|
|
1083
|
-
<Button
|
|
1084
|
-
type="button"
|
|
1085
|
-
variant="text"
|
|
1086
|
-
loading={classifying}
|
|
1087
|
-
disabled={classifying}
|
|
1088
|
-
onClick={() =>
|
|
1089
|
-
handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
|
|
1090
|
-
}
|
|
1091
|
-
>
|
|
1092
|
-
{message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
|
|
1093
|
-
</Button>
|
|
1094
|
-
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
|
1095
|
-
<input
|
|
1096
|
-
type="checkbox"
|
|
1097
|
-
checked={alwaysForSender}
|
|
1098
|
-
onChange={(e) => setAlwaysForSender(e.target.checked)}
|
|
1099
|
-
/>
|
|
1100
|
-
Always for this sender
|
|
1101
|
-
</label>
|
|
1102
|
-
</div>
|
|
1103
|
-
)}
|
|
1104
|
-
{classifyError && (
|
|
1105
|
-
<div className="mt-2">
|
|
1106
|
-
<Alert>{classifyError}</Alert>
|
|
1107
|
-
</div>
|
|
1108
|
-
)}
|
|
1185
|
+
{/* A failed reclassification is reported inside the confirmation dialog it was started
|
|
1186
|
+
from, next to the Move button that would retry it - not out here behind it. */}
|
|
1109
1187
|
{(["delivery", "read"] as const)
|
|
1110
1188
|
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
1111
1189
|
.map((type) => (
|
|
@@ -1198,7 +1276,7 @@ function MessageDetailContent({
|
|
|
1198
1276
|
// A recovered text/plain body renders as text (React escapes it) - never as markup.
|
|
1199
1277
|
<pre
|
|
1200
1278
|
aria-label={message.subject || "Message content"}
|
|
1201
|
-
className=
|
|
1279
|
+
className={`${bodyClassName} overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words`}
|
|
1202
1280
|
>
|
|
1203
1281
|
{security.text}
|
|
1204
1282
|
</pre>
|
|
@@ -1212,7 +1290,7 @@ function MessageDetailContent({
|
|
|
1212
1290
|
title={message.subject || "Message content"}
|
|
1213
1291
|
srcDoc={buildSecureSrcDoc(security.html)}
|
|
1214
1292
|
sandbox=""
|
|
1215
|
-
className=
|
|
1293
|
+
className={`${bodyClassName} border-0`}
|
|
1216
1294
|
/>
|
|
1217
1295
|
) : (
|
|
1218
1296
|
<iframe
|
|
@@ -1220,10 +1298,19 @@ function MessageDetailContent({
|
|
|
1220
1298
|
title={message.subject || "Message content"}
|
|
1221
1299
|
src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
|
|
1222
1300
|
sandbox=""
|
|
1223
|
-
className=
|
|
1301
|
+
className={`${bodyClassName} border-0`}
|
|
1224
1302
|
/>
|
|
1225
1303
|
)}
|
|
1226
1304
|
|
|
1305
|
+
<MoveToFolderDialog
|
|
1306
|
+
open={movePrompt}
|
|
1307
|
+
onClose={() => setMovePrompt(false)}
|
|
1308
|
+
mailboxUid={message.mailboxUid}
|
|
1309
|
+
folders={folders ?? []}
|
|
1310
|
+
currentFolderUid={message.folderUid}
|
|
1311
|
+
onMove={handleMove}
|
|
1312
|
+
onFolderCreated={onFolderCreated}
|
|
1313
|
+
/>
|
|
1227
1314
|
<Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
|
|
1228
1315
|
<p className="text-sm text-text-muted mb-4">
|
|
1229
1316
|
This asks every original recipient's mail system to delete their copy, but only if it's
|
|
@@ -1291,28 +1378,6 @@ function MessageDetailContent({
|
|
|
1291
1378
|
</div>
|
|
1292
1379
|
</div>
|
|
1293
1380
|
</Modal>
|
|
1294
|
-
<Modal open={labelsOpen} onClose={() => setLabelsOpen(false)} title="Labels">
|
|
1295
|
-
{labelsError && <Alert>{labelsError}</Alert>}
|
|
1296
|
-
<ul className="flex flex-col gap-1">
|
|
1297
|
-
{(labels ?? []).map((l) => {
|
|
1298
|
-
const checked = currentLabelsMessage.labelUids?.includes(l.uid) ?? false;
|
|
1299
|
-
return (
|
|
1300
|
-
<li key={l.uid}>
|
|
1301
|
-
<label className="flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt">
|
|
1302
|
-
<input
|
|
1303
|
-
type="checkbox"
|
|
1304
|
-
checked={checked}
|
|
1305
|
-
disabled={togglingLabelUid !== null}
|
|
1306
|
-
onChange={() => handleToggleLabel(l.uid)}
|
|
1307
|
-
/>
|
|
1308
|
-
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
|
|
1309
|
-
{l.name}
|
|
1310
|
-
</label>
|
|
1311
|
-
</li>
|
|
1312
|
-
);
|
|
1313
|
-
})}
|
|
1314
|
-
</ul>
|
|
1315
|
-
</Modal>
|
|
1316
1381
|
</div>
|
|
1317
1382
|
);
|
|
1318
1383
|
}
|