@rapidmx/web-client 0.9.0 → 0.10.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/README.md +192 -88
- package/apps/shared/auth/accountUrl.ts +9 -0
- package/apps/shared/auth/adminAccess.ts +99 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
- package/apps/shared/components/layout/AppShell.tsx +411 -288
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
- package/apps/shared/components/layout/UserMenu.tsx +303 -185
- package/apps/shared/components/mail/ConversationList.tsx +269 -262
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
- package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
- package/apps/shared/components/mail/compose/composePerf.ts +46 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
- package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
- package/apps/shared/components/mail/unreadStyle.tsx +75 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
- package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
- package/apps/shared/keyboard/dispatch.ts +124 -0
- package/apps/shared/keyboard/format.ts +89 -0
- package/apps/shared/keyboard/keymap.ts +114 -0
- package/apps/shared/keyboard/match.ts +44 -0
- package/apps/shared/keyboard/parse.ts +136 -0
- package/apps/shared/keyboard/platform.ts +34 -0
- package/apps/shared/keyboard/registry.ts +65 -0
- package/apps/shared/keyboard/targets.ts +79 -0
- package/apps/shared/keyboard/useShortcut.ts +50 -0
- package/apps/shared/keyboard/useShortcutProps.ts +17 -0
- package/apps/shared/mail/folderCounts.ts +302 -0
- package/apps/shared/mail/listSnapshots.ts +87 -0
- package/apps/shared/mail/messageReadState.ts +85 -0
- package/apps/shared/mail/newMailNotifications.ts +183 -0
- package/apps/shared/mail/useMailConnection.ts +150 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
- package/apps/shared/mail/useMarkMessageRead.ts +47 -0
- package/apps/shared/mail/useNewMailNotifications.ts +163 -0
- package/apps/shared/mail/useUnreadTitle.ts +42 -0
- package/apps/shared/navigation/AppRouter.tsx +300 -0
- package/apps/shared/navigation/appHrefs.ts +23 -0
- package/apps/shared/navigation/frameContext.tsx +35 -0
- package/apps/shared/navigation/idle.ts +45 -0
- package/apps/shared/navigation/routerContext.tsx +83 -0
- package/apps/shared/navigation/routes.ts +72 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
- package/apps/shared/styles/app.css +28 -10
- package/apps/www/_routedPage.tsx +24 -0
- package/apps/www/_routes.ts +35 -0
- package/apps/www/calendar/index.tsx +55 -19
- package/apps/www/contacts/[uid].tsx +112 -107
- package/apps/www/contacts/index.tsx +584 -567
- package/apps/www/index.tsx +2280 -1917
- package/apps/www/messages/[uid].tsx +106 -101
- package/apps/www/settings/auto-reply/index.tsx +4 -1
- package/apps/www/settings/encryption/index.tsx +1252 -1249
- package/apps/www/settings/filters/[uid].tsx +4 -1
- package/apps/www/settings/filters/index.tsx +102 -99
- package/apps/www/settings/filters/new/index.tsx +138 -133
- package/apps/www/settings/labels/index.tsx +204 -201
- package/apps/www/settings/privacy/index.tsx +4 -1
- package/apps/www/settings/read-receipts/index.tsx +4 -1
- package/apps/www/settings/sharing/index.tsx +277 -274
- package/apps/www/settings/signatures/[uid].tsx +170 -167
- package/apps/www/settings/signatures/index.tsx +88 -85
- package/apps/www/settings/signatures/new/index.tsx +134 -129
- package/apps/www/tasks/index.tsx +18 -2
- package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
- package/dist/apps/shared/auth/accountUrl.js +8 -0
- package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
- package/dist/apps/shared/auth/adminAccess.js +89 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
- package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
- package/dist/apps/shared/components/layout/AppShell.js +73 -14
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
- package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
- package/dist/apps/shared/components/layout/UserMenu.js +52 -5
- package/dist/apps/shared/components/mail/ConversationList.js +4 -11
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
- package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
- package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
- package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
- package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
- package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
- package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
- package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
- package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
- package/dist/apps/shared/keyboard/dispatch.js +109 -0
- package/dist/apps/shared/keyboard/format.d.ts +12 -0
- package/dist/apps/shared/keyboard/format.js +74 -0
- package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
- package/dist/apps/shared/keyboard/keymap.js +84 -0
- package/dist/apps/shared/keyboard/match.d.ts +20 -0
- package/dist/apps/shared/keyboard/match.js +31 -0
- package/dist/apps/shared/keyboard/parse.d.ts +31 -0
- package/dist/apps/shared/keyboard/parse.js +107 -0
- package/dist/apps/shared/keyboard/platform.d.ts +15 -0
- package/dist/apps/shared/keyboard/platform.js +22 -0
- package/dist/apps/shared/keyboard/registry.d.ts +39 -0
- package/dist/apps/shared/keyboard/registry.js +33 -0
- package/dist/apps/shared/keyboard/targets.d.ts +14 -0
- package/dist/apps/shared/keyboard/targets.js +67 -0
- package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
- package/dist/apps/shared/keyboard/useShortcut.js +35 -0
- package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
- package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
- package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
- package/dist/apps/shared/mail/folderCounts.js +212 -0
- package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
- package/dist/apps/shared/mail/listSnapshots.js +43 -0
- package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
- package/dist/apps/shared/mail/messageReadState.js +63 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
- package/dist/apps/shared/mail/newMailNotifications.js +138 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
- package/dist/apps/shared/mail/useMailConnection.js +96 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
- package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
- package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
- package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
- package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
- package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
- package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
- package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
- package/dist/apps/shared/navigation/AppRouter.js +242 -0
- package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
- package/dist/apps/shared/navigation/appHrefs.js +20 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
- package/dist/apps/shared/navigation/frameContext.js +24 -0
- package/dist/apps/shared/navigation/idle.d.ts +14 -0
- package/dist/apps/shared/navigation/idle.js +43 -0
- package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
- package/dist/apps/shared/navigation/routerContext.js +56 -0
- package/dist/apps/shared/navigation/routes.d.ts +32 -0
- package/dist/apps/shared/navigation/routes.js +37 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
- package/dist/apps/shared/styles/app.css +28 -10
- package/dist/apps/www/_routedPage.d.ts +12 -0
- package/dist/apps/www/_routedPage.js +19 -0
- package/dist/apps/www/_routes.d.ts +11 -0
- package/dist/apps/www/_routes.js +29 -0
- package/dist/apps/www/calendar/index.d.ts +2 -2
- package/dist/apps/www/calendar/index.js +39 -8
- package/dist/apps/www/contacts/[uid].d.ts +3 -9
- package/dist/apps/www/contacts/[uid].js +6 -2
- package/dist/apps/www/contacts/index.d.ts +2 -2
- package/dist/apps/www/contacts/index.js +17 -4
- package/dist/apps/www/index.d.ts +2 -2
- package/dist/apps/www/index.js +301 -35
- package/dist/apps/www/messages/[uid].d.ts +3 -7
- package/dist/apps/www/messages/[uid].js +7 -4
- package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
- package/dist/apps/www/settings/auto-reply/index.js +3 -1
- package/dist/apps/www/settings/encryption/index.d.ts +2 -2
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/index.js +3 -1
- package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/new/index.js +6 -2
- package/dist/apps/www/settings/labels/index.d.ts +2 -2
- package/dist/apps/www/settings/labels/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.d.ts +2 -2
- package/dist/apps/www/settings/privacy/index.js +3 -1
- package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
- package/dist/apps/www/settings/read-receipts/index.js +3 -1
- package/dist/apps/www/settings/sharing/index.d.ts +2 -2
- package/dist/apps/www/settings/sharing/index.js +3 -1
- package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/index.js +3 -1
- package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/new/index.js +6 -2
- package/dist/apps/www/tasks/index.d.ts +2 -2
- package/dist/apps/www/tasks/index.js +15 -3
- package/package.json +2 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
2
|
+
/**
|
|
3
|
+
* What decides whether a new message is announced, what the announcement says, and the two settings behind it (both per
|
|
4
|
+
* browser, in `localStorage`): whether new-mail pop-ups are on at all, and what the user told the "turn on desktop
|
|
5
|
+
* notifications" offer.
|
|
6
|
+
*
|
|
7
|
+
* The announcement is built only from what the push event carries - the whole `Message`, including `bodyPreview` - and only
|
|
8
|
+
* ever as text: React escapes it on screen and the Notifications API takes plain strings, so nothing here is HTML.
|
|
9
|
+
*/
|
|
10
|
+
/** `localStorage` key of the pop-ups switch. Absent means on; `"off"` means the user turned them off. */
|
|
11
|
+
export const NEW_MAIL_POPUPS_KEY = "rapidmx-new-mail-popups";
|
|
12
|
+
/** `localStorage` key of the answer to the desktop-notifications offer: `"later"` (not now) or `"asked"` (they were asked). */
|
|
13
|
+
export const DESKTOP_OFFER_KEY = "rapidmx-desktop-notifications-offer";
|
|
14
|
+
/** How much of the body an announcement quotes. */
|
|
15
|
+
export const PREVIEW_MAX_LENGTH = 140;
|
|
16
|
+
/** A message received longer ago than this is not "new": mail imported or migrated in bulk arrives as `create` events too. */
|
|
17
|
+
export const MAX_NEW_AGE_MS = 6 * 60 * 60 * 1000;
|
|
18
|
+
/** The subject the server stores for an encrypted message (the real one is inside the ciphertext). */
|
|
19
|
+
const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
|
|
20
|
+
function readStorage(key) {
|
|
21
|
+
try {
|
|
22
|
+
return localStorage.getItem(key);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function writeStorage(key, value) {
|
|
29
|
+
try {
|
|
30
|
+
if (value === null) {
|
|
31
|
+
localStorage.removeItem(key);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
localStorage.setItem(key, value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Whether new-mail pop-ups are on (the default). */
|
|
42
|
+
export function getNewMailPopupsEnabled() {
|
|
43
|
+
return readStorage(NEW_MAIL_POPUPS_KEY) !== "off";
|
|
44
|
+
}
|
|
45
|
+
export function setNewMailPopupsEnabled(enabled) {
|
|
46
|
+
writeStorage(NEW_MAIL_POPUPS_KEY, enabled ? null : "off");
|
|
47
|
+
}
|
|
48
|
+
/** Whether the "turn on desktop notifications" offer has been put away with "Not now" (or answered). */
|
|
49
|
+
export function getDesktopOfferDismissed() {
|
|
50
|
+
return readStorage(DESKTOP_OFFER_KEY) !== null;
|
|
51
|
+
}
|
|
52
|
+
export function setDesktopOfferDismissed(dismissed) {
|
|
53
|
+
writeStorage(DESKTOP_OFFER_KEY, dismissed ? "later" : null);
|
|
54
|
+
}
|
|
55
|
+
export function desktopPermission() {
|
|
56
|
+
return typeof Notification === "undefined" ? "unsupported" : Notification.permission;
|
|
57
|
+
}
|
|
58
|
+
/** Asks the browser for permission - call it from a click. Resolves the answer; never rejects. */
|
|
59
|
+
export async function requestDesktopPermission() {
|
|
60
|
+
if (typeof Notification === "undefined") {
|
|
61
|
+
return "unsupported";
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return await Notification.requestPermission();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return desktopPermission();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Control characters, and the invisible or bidirectional-override characters that can make text read as something else, as code point ranges. */
|
|
71
|
+
const UNWANTED_RANGES = [
|
|
72
|
+
[0x0000, 0x001f],
|
|
73
|
+
[0x007f, 0x009f],
|
|
74
|
+
[0x200b, 0x200f],
|
|
75
|
+
[0x2028, 0x202e],
|
|
76
|
+
[0x2060, 0x2064],
|
|
77
|
+
[0x2066, 0x2069],
|
|
78
|
+
[0xfeff, 0xfeff],
|
|
79
|
+
];
|
|
80
|
+
const hex = (code) => "\\u" + code.toString(16).padStart(4, "0");
|
|
81
|
+
const UNWANTED = new RegExp("[" + UNWANTED_RANGES.map(([from, to]) => hex(from) + "-" + hex(to)).join("") + "]+", "g");
|
|
82
|
+
/** `text` with those characters replaced by spaces, whitespace folded, and cut to `max` characters (with an ellipsis). */
|
|
83
|
+
export function cleanPreview(text, max = PREVIEW_MAX_LENGTH) {
|
|
84
|
+
const cleaned = text.replace(UNWANTED, " ").replace(/\s+/g, " ").trim();
|
|
85
|
+
return cleaned.length > max ? cleaned.slice(0, max - 1).trimEnd() + String.fromCharCode(0x2026) : cleaned;
|
|
86
|
+
}
|
|
87
|
+
/** The text of an announcement of `message`. */
|
|
88
|
+
export function noticeFor(message) {
|
|
89
|
+
const { name, address } = splitMailAddress(message.from);
|
|
90
|
+
const encrypted = message.encrypted === true;
|
|
91
|
+
const rawSubject = cleanPreview(message.subject ?? "", 200);
|
|
92
|
+
const subject = !rawSubject || rawSubject === ENCRYPTED_SUBJECT_PLACEHOLDER ? (encrypted ? "(encrypted subject)" : "(no subject)") : rawSubject;
|
|
93
|
+
return {
|
|
94
|
+
uid: message.uid,
|
|
95
|
+
mailboxUid: message.mailboxUid,
|
|
96
|
+
folderUid: message.folderUid,
|
|
97
|
+
// `splitMailAddress()` has already taken control and bidirectional-override characters out of both.
|
|
98
|
+
senderName: name ?? "",
|
|
99
|
+
senderAddress: address,
|
|
100
|
+
subject,
|
|
101
|
+
preview: encrypted ? "Encrypted message" : cleanPreview(message.bodyPreview ?? ""),
|
|
102
|
+
href: `/messages/${encodeURIComponent(message.uid)}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Every address that is this user's own, lowercased - a message from one of them is not new mail worth announcing. */
|
|
106
|
+
export function ownAddressesOf(mailboxes) {
|
|
107
|
+
const own = new Set();
|
|
108
|
+
for (const mailbox of mailboxes) {
|
|
109
|
+
for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
|
|
110
|
+
if (address) {
|
|
111
|
+
own.add(address.trim().toLowerCase());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return own;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Whether the arrival of `message` is worth announcing: unread mail in an Inbox (not Drafts, Sent Items, Outbox, Deleted Items,
|
|
119
|
+
* Junk Email or quarantine, which are other folders), that Focused Inbox did not put under Other, that its own user did not
|
|
120
|
+
* send, and that is not old (a bulk import).
|
|
121
|
+
*/
|
|
122
|
+
export function shouldAnnounce(message, context) {
|
|
123
|
+
if (context.folders.find((folder) => folder.uid === message.folderUid)?.type !== "inbox") {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (message.flags.read === true || message.inferenceClassification === "other") {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (context.ownAddresses.has((message.from?.address ?? "").trim().toLowerCase())) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const received = Date.parse(message.receivedDate);
|
|
133
|
+
return Number.isNaN(received) || (context.now ?? Date.now()) - received < MAX_NEW_AGE_MS;
|
|
134
|
+
}
|
|
135
|
+
/** `Name <address>`, or the address alone - the desktop notification's title. */
|
|
136
|
+
export function noticeSender(notice) {
|
|
137
|
+
return formatMailAddress({ address: notice.senderAddress, displayName: notice.senderName || undefined });
|
|
138
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
3
|
+
import { LiveUpdates } from "./useMailLiveUpdates.js";
|
|
4
|
+
import type { FolderCounts } from "./folderCounts.js";
|
|
5
|
+
import { NewMailNotifications } from "./useNewMailNotifications.js";
|
|
6
|
+
/** Page size of the one `listMailboxes()` call. */
|
|
7
|
+
export declare const MAILBOX_LIST_LIMIT = 100;
|
|
8
|
+
/** Well-known folders sort first in the sidebar, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
|
|
9
|
+
export declare const FOLDER_ORDER: string[];
|
|
10
|
+
/**
|
|
11
|
+
* A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see `CalendarShell`/`ContactsShell`/`TasksShell`), not
|
|
12
|
+
* Mail - `listFolders()` returns every well-known folder for the mailbox regardless of which app owns it, so Mail's own folder tree filters down
|
|
13
|
+
* to just the mail ones itself, or those other apps' folders leak into its sidebar.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MAIL_FOLDER_TYPES: Set<string>;
|
|
16
|
+
export type MailConnectionStatus = "checking" | "error" | "ready";
|
|
17
|
+
/** Everything the mail apps share about the user's mailboxes, and the one live connection that keeps it current. */
|
|
18
|
+
export interface MailConnection {
|
|
19
|
+
/** Where the one `listMailboxes()` call is. */
|
|
20
|
+
status: MailConnectionStatus;
|
|
21
|
+
/** Why `status` is `"error"`. */
|
|
22
|
+
error: string | null;
|
|
23
|
+
mailboxes: Mailbox[];
|
|
24
|
+
/** Every accessible mailbox's own mail folders, one entry per mailbox - see `MailboxFolders`. */
|
|
25
|
+
mailboxFolders: MailboxFolders[];
|
|
26
|
+
/** Their folder lists are still being fetched. */
|
|
27
|
+
foldersLoading: boolean;
|
|
28
|
+
/** Files a folder that was just created (by this client or another) under its mailbox, so the sidebar and every folder picker show it. */
|
|
29
|
+
onFolderCreated: (folder: Folder) => void;
|
|
30
|
+
/** Bumped whenever new mail may have arrived - see `useMailLiveUpdates()`. */
|
|
31
|
+
live: LiveUpdates;
|
|
32
|
+
/** The folder badges' numbers and how to change them. */
|
|
33
|
+
folderCounts: FolderCounts;
|
|
34
|
+
/** The new-mail pop-ups and desktop notifications, and what feeds them. */
|
|
35
|
+
notifications: NewMailNotifications;
|
|
36
|
+
}
|
|
37
|
+
/** What the persistent app frame (`AppChrome`) offers to the Mail shell so that it doesn't open a second connection. `null` outside a frame. */
|
|
38
|
+
export declare const MailConnectionContext: import("react").Context<MailConnection | null>;
|
|
39
|
+
export interface UseMailConnectionOptions {
|
|
40
|
+
/** Nothing is fetched, and no socket opened, without a signed-in user. */
|
|
41
|
+
userUid?: string;
|
|
42
|
+
/** Runs only while true. The frame turns it on; a Mail shell rendered outside a frame turns it on for itself; and the one that isn't
|
|
43
|
+
* the owner leaves it off - so exactly one connection exists, whichever of them the page is rendered by. */
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
/** Opens a message from a desktop notification's click (the router's `useNavigate()`); defaults to a plain navigation. */
|
|
46
|
+
open?: (href: string) => void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The user's mailboxes and their mail folders, plus the live connection that keeps them - and the badges, the list, the new-mail
|
|
50
|
+
* pop-ups - current. It is everything `MailShell` used to do on mount, gathered so that it can live in the persistent app frame instead: the
|
|
51
|
+
* push socket (the server allows ten per user), the safety-net poll, the folder counters and the pop-ups then belong to the signed-in session
|
|
52
|
+
* rather than to whichever page happens to be showing, and keep working in Calendar, Contacts, Tasks and Settings. `MailShell` reads the same
|
|
53
|
+
* state from `MailConnectionContext` when there is a frame, and runs this hook itself when there isn't (a page rendered outside the router).
|
|
54
|
+
*/
|
|
55
|
+
export declare function useMailConnection({ userUid, enabled, open }: UseMailConnectionOptions): MailConnection;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { createContext, useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import { useMailLiveUpdates } from "./useMailLiveUpdates.js";
|
|
9
|
+
import { useNewMailNotifications } from "./useNewMailNotifications.js";
|
|
10
|
+
/** Page size of the one `listMailboxes()` call. */
|
|
11
|
+
export const MAILBOX_LIST_LIMIT = 100;
|
|
12
|
+
/** Well-known folders sort first in the sidebar, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
|
|
13
|
+
export const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "archive", "deleted_items"];
|
|
14
|
+
/**
|
|
15
|
+
* A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see `CalendarShell`/`ContactsShell`/`TasksShell`), not
|
|
16
|
+
* Mail - `listFolders()` returns every well-known folder for the mailbox regardless of which app owns it, so Mail's own folder tree filters down
|
|
17
|
+
* to just the mail ones itself, or those other apps' folders leak into its sidebar.
|
|
18
|
+
*/
|
|
19
|
+
export const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
|
|
20
|
+
/** What the persistent app frame (`AppChrome`) offers to the Mail shell so that it doesn't open a second connection. `null` outside a frame. */
|
|
21
|
+
export const MailConnectionContext = createContext(null);
|
|
22
|
+
/**
|
|
23
|
+
* The user's mailboxes and their mail folders, plus the live connection that keeps them - and the badges, the list, the new-mail
|
|
24
|
+
* pop-ups - current. It is everything `MailShell` used to do on mount, gathered so that it can live in the persistent app frame instead: the
|
|
25
|
+
* push socket (the server allows ten per user), the safety-net poll, the folder counters and the pop-ups then belong to the signed-in session
|
|
26
|
+
* rather than to whichever page happens to be showing, and keep working in Calendar, Contacts, Tasks and Settings. `MailShell` reads the same
|
|
27
|
+
* state from `MailConnectionContext` when there is a frame, and runs this hook itself when there isn't (a page rendered outside the router).
|
|
28
|
+
*/
|
|
29
|
+
export function useMailConnection({ userUid, enabled, open }) {
|
|
30
|
+
const [status, setStatus] = useState("checking");
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const [mailboxes, setMailboxes] = useState([]);
|
|
33
|
+
const [mailboxFolders, setMailboxFolders] = useState([]);
|
|
34
|
+
const [foldersLoading, setFoldersLoading] = useState(true);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!userUid || !enabled) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
listMailboxes({ limit: MAILBOX_LIST_LIMIT })
|
|
40
|
+
.then((result) => {
|
|
41
|
+
setMailboxes(result);
|
|
42
|
+
setStatus("ready");
|
|
43
|
+
})
|
|
44
|
+
.catch((err) => {
|
|
45
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
46
|
+
setStatus("error");
|
|
47
|
+
});
|
|
48
|
+
}, [userUid, enabled]);
|
|
49
|
+
// Fans out one listFolders() call per accessible mailbox in parallel - each call catches its own failure into a MailboxFolders.error
|
|
50
|
+
// rather than letting Promise.all reject, so one mailbox's fetch failure renders that section's own inline Alert instead of blanking out
|
|
51
|
+
// every other mailbox's folder tree.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (mailboxes.length === 0) {
|
|
54
|
+
// Deliberately leaves foldersLoading as-is: this also runs once before listMailboxes() has resolved, and clearing it here would
|
|
55
|
+
// briefly render an empty sidebar the moment mailboxes arrive, before their folders do. A genuinely mailbox-less caller gets
|
|
56
|
+
// MailboxProvisioning.
|
|
57
|
+
setMailboxFolders([]);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
setFoldersLoading(true);
|
|
61
|
+
void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
|
|
62
|
+
.then((result) => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
|
|
63
|
+
.catch((err) => ({
|
|
64
|
+
mailbox,
|
|
65
|
+
folders: [],
|
|
66
|
+
error: err instanceof ApiRequestError ? err.message : "Could not load folders.",
|
|
67
|
+
}))))
|
|
68
|
+
.then(setMailboxFolders)
|
|
69
|
+
.finally(() => setFoldersLoading(false));
|
|
70
|
+
}, [mailboxes]);
|
|
71
|
+
/** Files a newly created folder under its own mailbox, leaving every other mailbox's list untouched - and ignoring a type the
|
|
72
|
+
* sidebar doesn't list at all, the same filter the fetch above applies. */
|
|
73
|
+
const onFolderCreated = useCallback((folder) => {
|
|
74
|
+
if (!MAIL_FOLDER_TYPES.has(folder.type)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setMailboxFolders((prev) => prev.map((entry) =>
|
|
78
|
+
// Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
|
|
79
|
+
entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
|
|
80
|
+
? { ...entry, folders: [...entry.folders, folder] }
|
|
81
|
+
: entry));
|
|
82
|
+
}, []);
|
|
83
|
+
// New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders` rather than
|
|
84
|
+
// written into it, so a refreshed unread count never looks like a change of folders to the list on screen (which reloads, and forgets its
|
|
85
|
+
// selection, whenever the folders do). A pop-up (and, in the background, a desktop notification) for each new message the connection
|
|
86
|
+
// announces.
|
|
87
|
+
const notifications = useNewMailNotifications({ mailboxes, mailboxFolders, open });
|
|
88
|
+
const { live, folderCounts } = useMailLiveUpdates({
|
|
89
|
+
userUid: enabled ? userUid : undefined,
|
|
90
|
+
mailboxes,
|
|
91
|
+
mailboxFolders,
|
|
92
|
+
onFolderCreated,
|
|
93
|
+
onMessageCreated: notifications.announce,
|
|
94
|
+
});
|
|
95
|
+
return { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, live, folderCounts, notifications };
|
|
96
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
1
|
+
import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
2
|
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
3
|
+
import { FolderCounts } from "./folderCounts.js";
|
|
3
4
|
/** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
|
|
4
5
|
* that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
|
|
5
6
|
export declare const LIVE_POLL_INTERVAL_MS = 45000;
|
|
@@ -28,21 +29,27 @@ export interface UseMailLiveUpdatesOptions {
|
|
|
28
29
|
mailboxFolders: MailboxFolders[];
|
|
29
30
|
/** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
|
|
30
31
|
onFolderCreated: (folder: Folder) => void;
|
|
32
|
+
/** Called with each new message the push connection announces (a `create` event) - once per message, never for a list
|
|
33
|
+
* refetch, the initial load or a reconnect (nothing published while the socket was down is replayed). The caller decides
|
|
34
|
+
* whether it is worth telling the user about. */
|
|
35
|
+
onMessageCreated?: (message: Message) => void;
|
|
31
36
|
}
|
|
32
37
|
export interface MailLiveUpdates {
|
|
33
38
|
live: LiveUpdates;
|
|
34
|
-
/** The
|
|
35
|
-
*
|
|
36
|
-
|
|
39
|
+
/** The folder badges' numbers and how to change them - see `useFolderCounts()`. Kept in an overlay of their own, **not** in
|
|
40
|
+
* `mailboxFolders`, because the list effect reloads - and forgets the selection - whenever `mailboxFolders` changes. */
|
|
41
|
+
folderCounts: FolderCounts;
|
|
37
42
|
}
|
|
38
43
|
/**
|
|
39
44
|
* Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
|
|
40
45
|
* folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
|
|
41
46
|
* `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
|
|
42
47
|
* tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
|
|
43
|
-
* (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's
|
|
48
|
+
* (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's counts are re-read. A new message
|
|
49
|
+
* also bumps its folder's badge at once and is announced to `onMessageCreated`, and the server's `Folder` update events (the
|
|
50
|
+
* folder's real counts) are applied to the badges - see `useFolderCounts()`.
|
|
44
51
|
*
|
|
45
52
|
* Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
|
|
46
53
|
* Renders nothing and does nothing where there is no window (server-side rendering).
|
|
47
54
|
*/
|
|
48
|
-
export declare function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates;
|
|
55
|
+
export declare function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onMessageCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates;
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
-
import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
6
|
import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
8
7
|
import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
|
|
8
|
+
import { useFolderCounts } from "./folderCounts.js";
|
|
9
9
|
/** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
|
|
10
10
|
* that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
|
|
11
11
|
export const LIVE_POLL_INTERVAL_MS = 45000;
|
|
@@ -31,6 +31,14 @@ function isFolder(value) {
|
|
|
31
31
|
const folder = value;
|
|
32
32
|
return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
|
|
33
33
|
}
|
|
34
|
+
function isMessage(value) {
|
|
35
|
+
const message = value;
|
|
36
|
+
return (!!message &&
|
|
37
|
+
typeof message.uid === "string" &&
|
|
38
|
+
typeof message.folderUid === "string" &&
|
|
39
|
+
typeof message.flags === "object" &&
|
|
40
|
+
message.flags !== null);
|
|
41
|
+
}
|
|
34
42
|
/** The folder a message event is about: the message's own `folderUid`, else the channel it arrived on (when the server said). */
|
|
35
43
|
function folderOfMessageEvent(event) {
|
|
36
44
|
const data = event.data;
|
|
@@ -44,17 +52,20 @@ function folderOfMessageEvent(event) {
|
|
|
44
52
|
* folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
|
|
45
53
|
* `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
|
|
46
54
|
* tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
|
|
47
|
-
* (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's
|
|
55
|
+
* (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's counts are re-read. A new message
|
|
56
|
+
* also bumps its folder's badge at once and is announced to `onMessageCreated`, and the server's `Folder` update events (the
|
|
57
|
+
* folder's real counts) are applied to the badges - see `useFolderCounts()`.
|
|
48
58
|
*
|
|
49
59
|
* Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
|
|
50
60
|
* Renders nothing and does nothing where there is no window (server-side rendering).
|
|
51
61
|
*/
|
|
52
|
-
export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }) {
|
|
62
|
+
export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onMessageCreated }) {
|
|
53
63
|
const [live, setLive] = useState(NO_LIVE_UPDATES);
|
|
54
|
-
const
|
|
64
|
+
const folders = useMemo(() => mailboxFolders.flatMap((entry) => entry.folders), [mailboxFolders]);
|
|
65
|
+
const folderCounts = useFolderCounts(mailboxes, folders);
|
|
55
66
|
// The latest inputs, for the long-lived listeners below.
|
|
56
|
-
const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated });
|
|
57
|
-
latestRef.current = { mailboxes, mailboxFolders, onFolderCreated };
|
|
67
|
+
const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated, onMessageCreated, folderCounts });
|
|
68
|
+
latestRef.current = { mailboxes, mailboxFolders, onFolderCreated, onMessageCreated, folderCounts };
|
|
58
69
|
const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
|
|
59
70
|
const channelKey = channels.join("|");
|
|
60
71
|
const channelsRef = useRef(channels);
|
|
@@ -68,25 +79,7 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
|
|
|
68
79
|
let timer;
|
|
69
80
|
let pendingFolders = new Set();
|
|
70
81
|
let pendingUnknown = false;
|
|
71
|
-
let countsRun = 0;
|
|
72
82
|
let everOpen = false;
|
|
73
|
-
async function refreshCounts() {
|
|
74
|
-
const run = ++countsRun;
|
|
75
|
-
const results = await Promise.all(latestRef.current.mailboxes.map((mailbox) => listFolders(mailbox.uid).catch(() => undefined)));
|
|
76
|
-
if (stopped || run !== countsRun) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
const fresh = {};
|
|
80
|
-
for (const folders of results) {
|
|
81
|
-
for (const folder of folders ?? []) {
|
|
82
|
-
fresh[folder.uid] = folder.unreadCount;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
setUnreadCounts((previous) => {
|
|
86
|
-
const changed = Object.entries(fresh).some(([uid, count]) => previous[uid] !== count);
|
|
87
|
-
return changed ? { ...previous, ...fresh } : previous;
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
83
|
// Never runs once `stopped`: whatever sets it clears the timer first.
|
|
91
84
|
function flush() {
|
|
92
85
|
timer = undefined;
|
|
@@ -94,7 +87,7 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
|
|
|
94
87
|
pendingFolders = new Set();
|
|
95
88
|
pendingUnknown = false;
|
|
96
89
|
setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
|
|
97
|
-
|
|
90
|
+
latestRef.current.folderCounts.refresh(0);
|
|
98
91
|
}
|
|
99
92
|
/** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
|
|
100
93
|
function schedule(folderUid) {
|
|
@@ -111,6 +104,12 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
|
|
|
111
104
|
}
|
|
112
105
|
const offEvent = client.onEvent((event) => {
|
|
113
106
|
if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
|
|
107
|
+
if (event.action === "create" && isMessage(event.data)) {
|
|
108
|
+
// Once per message, whatever else the event triggers: the badge first, then whoever announces it.
|
|
109
|
+
if (latestRef.current.folderCounts.noteCreated(event.data)) {
|
|
110
|
+
latestRef.current.onMessageCreated?.(event.data);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
114
113
|
schedule(folderOfMessageEvent(event));
|
|
115
114
|
}
|
|
116
115
|
else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
|
|
@@ -120,6 +119,9 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
|
|
|
120
119
|
latestRef.current.onFolderCreated(folder);
|
|
121
120
|
}
|
|
122
121
|
}
|
|
122
|
+
else if (FOLDER_EVENT.test(event.type) && event.action === "update") {
|
|
123
|
+
latestRef.current.folderCounts.applyFolderEvent(event.data);
|
|
124
|
+
}
|
|
123
125
|
});
|
|
124
126
|
// Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
|
|
125
127
|
const offStatus = client.onStatus((status) => {
|
|
@@ -176,5 +178,5 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
|
|
|
176
178
|
getPushClient().setChannels(channels);
|
|
177
179
|
}
|
|
178
180
|
}, [userUid, channelKey]);
|
|
179
|
-
return { live,
|
|
181
|
+
return { live, folderCounts };
|
|
180
182
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
/**
|
|
3
|
+
* Marks a message read when it is opened in a reading pane, through `setReadState()`: the row and the folder badge change at
|
|
4
|
+
* once. `onPatched` is handed each copy of the message as it changes (the optimistic one, then the server's, or the original
|
|
5
|
+
* again if the server refused).
|
|
6
|
+
*
|
|
7
|
+
* Replaces `@rapidmx/react-shared`'s `useMarkMessageRead`, which waits for the server before telling anyone and drops that
|
|
8
|
+
* answer if the message changes meanwhile - which the optimistic copy itself does. A message is asked for once each time it is
|
|
9
|
+
* opened: one the reader then marks unread stays unread while it is open, and one whose request failed is not retried in a loop
|
|
10
|
+
* (its own revert changes it again), only when it is opened again.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useMarkMessageRead(message: Message | null, onPatched: (updated: Message, previous?: Message) => void): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { useMailShell } from "../components/mail/layout/MailShell.js";
|
|
7
|
+
import { setReadState } from "./messageReadState.js";
|
|
8
|
+
/**
|
|
9
|
+
* Marks a message read when it is opened in a reading pane, through `setReadState()`: the row and the folder badge change at
|
|
10
|
+
* once. `onPatched` is handed each copy of the message as it changes (the optimistic one, then the server's, or the original
|
|
11
|
+
* again if the server refused).
|
|
12
|
+
*
|
|
13
|
+
* Replaces `@rapidmx/react-shared`'s `useMarkMessageRead`, which waits for the server before telling anyone and drops that
|
|
14
|
+
* answer if the message changes meanwhile - which the optimistic copy itself does. A message is asked for once each time it is
|
|
15
|
+
* opened: one the reader then marks unread stays unread while it is open, and one whose request failed is not retried in a loop
|
|
16
|
+
* (its own revert changes it again), only when it is opened again.
|
|
17
|
+
*/
|
|
18
|
+
export function useMarkMessageRead(message, onPatched) {
|
|
19
|
+
const { trackMessageChange } = useMailShell();
|
|
20
|
+
const openUidRef = useRef(undefined);
|
|
21
|
+
const requestedRef = useRef(new Set());
|
|
22
|
+
const latestRef = useRef({ onPatched, trackMessageChange });
|
|
23
|
+
latestRef.current = { onPatched, trackMessageChange };
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (message?.uid !== openUidRef.current) {
|
|
26
|
+
// Another message (or none) is open now: whatever was asked about the last one is over.
|
|
27
|
+
openUidRef.current = message?.uid;
|
|
28
|
+
requestedRef.current.clear();
|
|
29
|
+
}
|
|
30
|
+
if (!message || requestedRef.current.has(message.uid)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Every opening is asked about once - a message that is already read included, so that marking it unread while it is open (the
|
|
34
|
+
// keyboard's Ctrl+U) doesn't make this run again and read it straight back.
|
|
35
|
+
requestedRef.current.add(message.uid);
|
|
36
|
+
if (message.flags.read === true) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
void setReadState(message, true, {
|
|
40
|
+
patch: (updated, previous) => latestRef.current.onPatched(updated, previous),
|
|
41
|
+
track: (previous, next) => latestRef.current.trackMessageChange(previous, next),
|
|
42
|
+
});
|
|
43
|
+
}, [message]);
|
|
44
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
3
|
+
import { NewMailNotice } from "./newMailNotifications.js";
|
|
4
|
+
/** The most pop-ups on screen at once; a new one pushes the oldest off. */
|
|
5
|
+
export declare const MAX_TOASTS = 3;
|
|
6
|
+
/** How long a pop-up stays before it goes by itself (while it is neither hovered nor focused, and the tab is in view). */
|
|
7
|
+
export declare const TOAST_DURATION_MS = 8000;
|
|
8
|
+
/** At most this many desktop notifications in `DESKTOP_BURST_WINDOW_MS`: a flood of mail is a few notifications, not a hundred. */
|
|
9
|
+
export declare const DESKTOP_BURST_LIMIT = 5;
|
|
10
|
+
export declare const DESKTOP_BURST_WINDOW_MS = 30000;
|
|
11
|
+
export interface NewMailNotifications {
|
|
12
|
+
/** The pop-ups on screen, oldest first. */
|
|
13
|
+
toasts: NewMailNotice[];
|
|
14
|
+
/** Takes one off the screen. */
|
|
15
|
+
dismiss(uid: string): void;
|
|
16
|
+
/** Whether the first pop-up should also offer to turn on desktop notifications: the browser can, has not been asked,
|
|
17
|
+
* and the user has not said "Not now". */
|
|
18
|
+
offerDesktop: boolean;
|
|
19
|
+
/** The offer's "Turn on desktop notifications": asks the browser. Must be called from a click. */
|
|
20
|
+
enableDesktop(): Promise<void>;
|
|
21
|
+
/** The offer's "Not now": remembered, so it is not made again. */
|
|
22
|
+
declineDesktop(): void;
|
|
23
|
+
/** Announces a message that just arrived - if it is worth it (see `shouldAnnounce()`) and pop-ups are on. Stable. */
|
|
24
|
+
announce(message: Message): void;
|
|
25
|
+
}
|
|
26
|
+
export interface UseNewMailNotificationsOptions {
|
|
27
|
+
mailboxes: Mailbox[];
|
|
28
|
+
mailboxFolders: MailboxFolders[];
|
|
29
|
+
/** Opens a message from a desktop notification's click. Defaults to a plain navigation. */
|
|
30
|
+
open?: (href: string) => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* New-mail announcements: an in-app pop-up for each message that arrives (`MAX_TOASTS` at most on screen), and - while the tab
|
|
34
|
+
* is in the background and the user has allowed it - a desktop notification with the same content, one per message however
|
|
35
|
+
* many events name it. `announce()` is what `useMailLiveUpdates()`'s `onMessageCreated` calls; nothing else feeds it, so a
|
|
36
|
+
* page load, a list refetch and a reconnect never announce anything.
|
|
37
|
+
*
|
|
38
|
+
* Nothing is ever asked of the browser on load: permission is requested only by `enableDesktop()`, from the offer in the
|
|
39
|
+
* first pop-up, or from Settings. What the user answered is remembered in `localStorage` (see `newMailNotifications.ts`).
|
|
40
|
+
*/
|
|
41
|
+
export declare function useNewMailNotifications({ mailboxes, mailboxFolders, open }: UseNewMailNotificationsOptions): NewMailNotifications;
|