@rapidmx/web-client 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/apps/shared/components/admin/setup/SetupWizard.tsx +1 -1
  2. package/apps/shared/components/calendar/TimeGridView.tsx +3 -1
  3. package/apps/shared/components/mail/ConversationList.tsx +18 -5
  4. package/apps/shared/components/mail/ConversationThreadPane.tsx +7 -2
  5. package/apps/shared/components/mail/MessageDetailPane.tsx +37 -18
  6. package/apps/shared/components/mail/invite/InviteRowChip.tsx +8 -12
  7. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +2 -2
  8. package/apps/shared/components/mail/reading/conversationPrint.ts +76 -0
  9. package/apps/shared/components/mail/reading/printMessage.ts +44 -7
  10. package/apps/shared/keyboard/keymap.ts +1 -0
  11. package/apps/shared/mail/decryptedMessages.ts +116 -0
  12. package/apps/www/index.tsx +11 -0
  13. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +4 -4
  14. package/dist/apps/shared/components/admin/setup/SetupWizard.js +1 -1
  15. package/dist/apps/shared/components/calendar/TimeGridView.js +1 -1
  16. package/dist/apps/shared/components/mail/ConversationList.js +17 -4
  17. package/dist/apps/shared/components/mail/ConversationThreadPane.js +6 -2
  18. package/dist/apps/shared/components/mail/MessageDetailPane.js +31 -18
  19. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +2 -2
  20. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +7 -9
  21. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +1 -1
  22. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +2 -2
  23. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +15 -0
  24. package/dist/apps/shared/components/mail/reading/conversationPrint.js +69 -0
  25. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +7 -1
  26. package/dist/apps/shared/components/mail/reading/printMessage.js +33 -6
  27. package/dist/apps/shared/keyboard/keymap.d.ts +6 -0
  28. package/dist/apps/shared/keyboard/keymap.js +1 -0
  29. package/dist/apps/shared/mail/decryptedMessages.d.ts +27 -0
  30. package/dist/apps/shared/mail/decryptedMessages.js +97 -0
  31. package/dist/apps/www/index.js +10 -0
  32. package/package.json +1 -1
@@ -0,0 +1,97 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useSyncExternalStore } from "react";
6
+ import { getUnlockedKeys, subscribeKeySession } from "../../../lib/crypto/keySession.js";
7
+ import { getMessageRawContent } from "../../../lib/mail/mailApi.js";
8
+ const PREVIEW_MAX_LENGTH = 160;
9
+ let entries = {};
10
+ /** Bumped whenever a mailbox's keys are unlocked, so a view can retry decrypting what it couldn't before. */
11
+ let unlockEpoch = 0;
12
+ const listeners = new Set();
13
+ const inFlight = new Set();
14
+ function emit() {
15
+ for (const listener of [...listeners]) {
16
+ listener();
17
+ }
18
+ }
19
+ subscribeKeySession(({ mailboxUid, state }) => {
20
+ if (state === "locked") {
21
+ const kept = Object.fromEntries(Object.entries(entries).filter(([, entry]) => entry.mailboxUid !== mailboxUid));
22
+ if (Object.keys(kept).length !== Object.keys(entries).length) {
23
+ entries = kept;
24
+ }
25
+ }
26
+ else {
27
+ unlockEpoch++;
28
+ }
29
+ emit();
30
+ });
31
+ function subscribe(listener) {
32
+ listeners.add(listener);
33
+ return () => {
34
+ listeners.delete(listener);
35
+ };
36
+ }
37
+ /** Plain text of decrypted HTML for a one-line preview. Only ever feeds text display, never markup. */
38
+ export function previewOf(html) {
39
+ return html
40
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
41
+ .replace(/<[^>]+>/g, " ")
42
+ .replace(/&nbsp;/gi, " ")
43
+ .replace(/&amp;/gi, "&")
44
+ .replace(/&lt;/gi, "<")
45
+ .replace(/&gt;/gi, ">")
46
+ .replace(/&quot;/gi, '"')
47
+ .replace(/&#0*39;/gi, "'")
48
+ .replace(/\s+/g, " ")
49
+ .trim()
50
+ .slice(0, PREVIEW_MAX_LENGTH);
51
+ }
52
+ /** Remembers what was recovered of one message. Ignored when there is nothing new to show. */
53
+ export function rememberDecrypted(uid, mailboxUid, found) {
54
+ if (!found.subject && !found.preview) {
55
+ return;
56
+ }
57
+ const current = entries[uid];
58
+ if (current && current.subject === found.subject && current.preview === found.preview) {
59
+ return;
60
+ }
61
+ entries = { ...entries, [uid]: { mailboxUid, subject: found.subject, preview: found.preview } };
62
+ emit();
63
+ }
64
+ /** Every message decrypted so far, by uid; a new object whenever any changes. */
65
+ export function useDecryptedMessages() {
66
+ return useSyncExternalStore(subscribe, () => entries);
67
+ }
68
+ /** A number that changes whenever a mailbox is unlocked - a dependency for effects that decrypt what they can. */
69
+ export function useUnlockEpoch() {
70
+ return useSyncExternalStore(subscribe, () => unlockEpoch);
71
+ }
72
+ /**
73
+ * Decrypts one message to recover its subject and preview, when its mailbox's keys are unlocked and it isn't already known. Best effort: a
74
+ * message that can't be fetched or opened is simply left as it was. Never prompts.
75
+ */
76
+ export async function decryptForDisplay(uid, mailboxUid) {
77
+ const unlocked = getUnlockedKeys(mailboxUid);
78
+ if (!unlocked || entries[uid] || inFlight.has(uid)) {
79
+ return;
80
+ }
81
+ inFlight.add(uid);
82
+ try {
83
+ const rawMime = await getMessageRawContent(uid);
84
+ const { evaluateMessageSecurity } = await import("../../../lib/crypto/messageSecurity.js");
85
+ const security = await evaluateMessageSecurity(rawMime, unlocked);
86
+ // A lock while this ran: the result must not go back on screen.
87
+ if (getUnlockedKeys(mailboxUid) === unlocked) {
88
+ rememberDecrypted(uid, mailboxUid, { subject: security.subject, preview: security.html ? previewOf(security.html) : security.text?.slice(0, PREVIEW_MAX_LENGTH) });
89
+ }
90
+ }
91
+ catch {
92
+ // Left as the placeholder.
93
+ }
94
+ finally {
95
+ inFlight.delete(uid);
96
+ }
97
+ }
@@ -32,6 +32,7 @@ import { setReadStateMany } from "../shared/mail/messageReadState.js";
32
32
  import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
33
33
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
34
34
  import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
35
+ import { rememberDecrypted } from "../shared/mail/decryptedMessages.js";
35
36
  import ConversationList, { conversationRowKey } from "../shared/components/mail/ConversationList.js";
36
37
  import SwipeRow from "../shared/components/mail/SwipeRow.js";
37
38
  import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
@@ -101,6 +102,7 @@ async function decryptEncryptedRows(messages, unlocked) {
101
102
  return null;
102
103
  }
103
104
  const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
105
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: security.subject, preview });
104
106
  return [message.uid, { subject: security.subject, preview }];
105
107
  }
106
108
  catch {
@@ -2076,11 +2078,19 @@ function InboxContent({ userUid }) {
2076
2078
  }
2077
2079
  await actOnKeyboardTargets(targets.filter((message) => !inDeletedItems(message)), (chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"), true);
2078
2080
  }
2081
+ /** Shift+Delete, from the keyboard: permanently deletes the targets wherever they are, after the same confirmation as Delete in Deleted Items. */
2082
+ async function permanentDeleteFromKeyboard() {
2083
+ const targets = await keyboardTargets();
2084
+ if (targets.length > 0 && (await purgeChosen(targets)) && threadPane && !selectMode) {
2085
+ closeOpenThread();
2086
+ }
2087
+ }
2079
2088
  useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
2080
2089
  useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
2081
2090
  useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
2082
2091
  useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
2083
2092
  useShortcut(SHORTCUTS.mail.delete, () => void deleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
2093
+ useShortcut(SHORTCUTS.mail.permanentDelete, () => void permanentDeleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
2084
2094
  useShortcut(SHORTCUTS.mail.markRead, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }), false, isUnread), { enabled: keyboardActions && keyboardTargetExists });
2085
2095
  useShortcut(SHORTCUTS.mail.markUnread, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }), false, (message) => !isUnread(message)), { enabled: keyboardActions && keyboardTargetExists });
2086
2096
  useShortcut(SHORTCUTS.mail.flag, () =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",