@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,302 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { Folder, FolderType, Mailbox, Message, listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The folder badges' numbers, kept right without a reload.
|
|
10
|
+
*
|
|
11
|
+
* A folder's `unreadCount`/`totalCount` come from the server (`GET /mail/folders`, the server-published `Folder` update
|
|
12
|
+
* event, and the poll), but waiting for a round trip to see a badge move after reading a message feels broken, so a change
|
|
13
|
+
* made here is applied to the badge at once - **optimistically** - and then *reconciled*: every so often, and after every
|
|
14
|
+
* change, the real counts are read back and replace whatever this page worked out. So the page can be wrong for a moment,
|
|
15
|
+
* never for good.
|
|
16
|
+
*
|
|
17
|
+
* What the server sends (`@rapidmx/restapi`): `GET /mail/folders` returns each folder with a derived `unreadCount`/`totalCount`
|
|
18
|
+
* (unread messages, all messages), and after any change to a folder's messages it publishes
|
|
19
|
+
* `{ type: /^Folder/, action: "update", data: { uid, mailboxUid, unreadCount, totalCount } }` on the folder's push channel *and* its
|
|
20
|
+
* mailbox's, so each is heard twice - the values are absolute and applying one again changes nothing. Nothing here fails without
|
|
21
|
+
* that event, or for a write that publishes none (ActiveSync/MAPI, erasure jobs): the read-back after each change and the poll do
|
|
22
|
+
* the same job a little later, and overlapping writes to one folder are unordered, so they always have the last word.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface FolderCount {
|
|
26
|
+
unread: number;
|
|
27
|
+
total: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A change to one folder's counts. */
|
|
31
|
+
export interface CountDelta {
|
|
32
|
+
folderUid: string;
|
|
33
|
+
unread: number;
|
|
34
|
+
total: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What one message change - a new message (`previous` absent), a deletion (`next` absent), a read/unread flip, a move - does to the folders' counts. */
|
|
38
|
+
export function countDeltas(previous: Message | null | undefined, next: Message | null | undefined): CountDelta[] {
|
|
39
|
+
const byFolder = new Map<string, CountDelta>();
|
|
40
|
+
function add(message: Message, sign: 1 | -1) {
|
|
41
|
+
const entry = byFolder.get(message.folderUid) ?? { folderUid: message.folderUid, unread: 0, total: 0 };
|
|
42
|
+
entry.total += sign;
|
|
43
|
+
entry.unread += message.flags.read === true ? 0 : sign;
|
|
44
|
+
byFolder.set(message.folderUid, entry);
|
|
45
|
+
}
|
|
46
|
+
if (previous) {
|
|
47
|
+
add(previous, -1);
|
|
48
|
+
}
|
|
49
|
+
if (next) {
|
|
50
|
+
add(next, 1);
|
|
51
|
+
}
|
|
52
|
+
return [...byFolder.values()].filter((delta) => delta.unread !== 0 || delta.total !== 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A folder's count as it should be shown: what this page last worked out or was told, else what the folder was listed with. */
|
|
56
|
+
export function countOfFolder(folder: Folder, counts: Record<string, FolderCount>): FolderCount {
|
|
57
|
+
return counts[folder.uid] ?? { unread: folder.unreadCount, total: folder.totalCount };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What a folder's badge shows: how many unread, or (Drafts and Outbox, Outlook-style) how many messages are in it. */
|
|
61
|
+
export interface FolderBadge {
|
|
62
|
+
kind: "unread" | "total";
|
|
63
|
+
value: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The badge a folder gets, if any. Inbox, Archive and the user's own folders show their unread count when it is above
|
|
68
|
+
* zero; Drafts and Outbox show how many messages they hold (an unread count means nothing there); Sent Items, Deleted
|
|
69
|
+
* Items and Junk Email show nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function badgeFor(type: FolderType, count: FolderCount): FolderBadge | undefined {
|
|
72
|
+
switch (type) {
|
|
73
|
+
case "sent_items":
|
|
74
|
+
case "deleted_items":
|
|
75
|
+
case "junk":
|
|
76
|
+
return undefined;
|
|
77
|
+
case "drafts":
|
|
78
|
+
case "outbox":
|
|
79
|
+
return count.total > 0 ? { kind: "total", value: count.total } : undefined;
|
|
80
|
+
default:
|
|
81
|
+
return count.unread > 0 ? { kind: "unread", value: count.unread } : undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The badge's text for assistive technology: "3 unread", "2 messages". */
|
|
86
|
+
export function badgeLabel(badge: FolderBadge): string {
|
|
87
|
+
return badge.kind === "unread" ? `${badge.value} unread` : `${badge.value} ${badge.value === 1 ? "message" : "messages"}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The unread messages in every mailbox's Inbox - what the tab title counts. */
|
|
91
|
+
export function inboxUnreadTotal(mailboxFolders: { folders: Folder[] }[], counts: Record<string, FolderCount>): number {
|
|
92
|
+
return mailboxFolders.reduce(
|
|
93
|
+
(total, entry) =>
|
|
94
|
+
total + entry.folders.filter((folder) => folder.type === "inbox").reduce((sum, folder) => sum + countOfFolder(folder, counts).unread, 0),
|
|
95
|
+
0,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** How long after the last change of this page's own has finished a server-published count is trusted again: one published
|
|
100
|
+
* about an earlier state of the folder could otherwise arrive late and put back what the change just took away. */
|
|
101
|
+
export const COUNT_QUIET_MS = 1_500;
|
|
102
|
+
|
|
103
|
+
/** How long after a change the counts are read back from the server. */
|
|
104
|
+
export const COUNT_REFRESH_DELAY_MS = 600;
|
|
105
|
+
|
|
106
|
+
/** How many message uids the "already counted this new message" memory holds. */
|
|
107
|
+
const SEEN_LIMIT = 500;
|
|
108
|
+
|
|
109
|
+
/** An optimistic change in flight: call exactly one of these once the server has answered. */
|
|
110
|
+
export interface CountTracker {
|
|
111
|
+
/** The server accepted the change: keep what was applied, and read the real counts back. */
|
|
112
|
+
settle(): void;
|
|
113
|
+
/** The server refused it (or it failed): put the counts back as they were, and read the real ones back. */
|
|
114
|
+
revert(): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface FolderCounts {
|
|
118
|
+
/** The counts this page has worked out or been told since load, by folder uid - see `countOfFolder()`. */
|
|
119
|
+
counts: Record<string, FolderCount>;
|
|
120
|
+
/** Applies the effect of changing `previous` into `next` now, before the server has answered - see `CountTracker`. */
|
|
121
|
+
track(previous: Message | null | undefined, next: Message | null | undefined): CountTracker;
|
|
122
|
+
/** A new message arrived over the push connection: counts it (once per message) until the real counts are read back.
|
|
123
|
+
* `false` when this one was already counted - a duplicate event. */
|
|
124
|
+
noteCreated(message: Message): boolean;
|
|
125
|
+
/** The server's `Folder` update event's `data`: the folder's real counts - when this page has nothing of its own in flight. */
|
|
126
|
+
applyFolderEvent(data: unknown): void;
|
|
127
|
+
/** Reads every mailbox's real counts back and shows them, after `delayMs` (at once for 0). A later call replaces one still waiting. */
|
|
128
|
+
refresh(delayMs?: number): void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Holds the folder counts overlay for `MailShell`. `folders` is every folder the sidebar lists (the overlay starts from what
|
|
133
|
+
* each was loaded with); `mailboxes` is whose folders to read back. The overlay starts again whenever `mailboxes` does.
|
|
134
|
+
*/
|
|
135
|
+
export function useFolderCounts(mailboxes: Mailbox[], folders: Folder[]): FolderCounts {
|
|
136
|
+
const [counts, setCounts] = useState<Record<string, FolderCount>>({});
|
|
137
|
+
const latestRef = useRef({ mailboxes, folders });
|
|
138
|
+
latestRef.current = { mailboxes, folders };
|
|
139
|
+
// Bumped by every change of the overlay this page makes, so a read that started before one can tell it is out of date.
|
|
140
|
+
const changesRef = useRef(0);
|
|
141
|
+
const inFlightRef = useRef(0);
|
|
142
|
+
const lastDoneRef = useRef(0);
|
|
143
|
+
const runRef = useRef(0);
|
|
144
|
+
// Assigned during every render, before anything can call it.
|
|
145
|
+
const readBackRef = useRef<() => Promise<void>>(undefined as never);
|
|
146
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
147
|
+
const stoppedRef = useRef(false);
|
|
148
|
+
const seenRef = useRef<Set<string>>(new Set());
|
|
149
|
+
|
|
150
|
+
const adjust = useCallback((deltas: CountDelta[]) => {
|
|
151
|
+
const known = deltas.filter((delta) => latestRef.current.folders.some((folder) => folder.uid === delta.folderUid));
|
|
152
|
+
if (known.length === 0) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
changesRef.current++;
|
|
156
|
+
setCounts((previous) => {
|
|
157
|
+
const next = { ...previous };
|
|
158
|
+
for (const delta of known) {
|
|
159
|
+
const folder = latestRef.current.folders.find((f) => f.uid === delta.folderUid)!;
|
|
160
|
+
const base = next[delta.folderUid] ?? { unread: folder.unreadCount, total: folder.totalCount };
|
|
161
|
+
next[delta.folderUid] = {
|
|
162
|
+
unread: Math.max(0, base.unread + delta.unread),
|
|
163
|
+
total: Math.max(0, base.total + delta.total),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return next;
|
|
167
|
+
});
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const refresh = useCallback((delayMs: number = COUNT_REFRESH_DELAY_MS) => {
|
|
171
|
+
if (stoppedRef.current) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
clearTimeout(timerRef.current);
|
|
175
|
+
if (delayMs <= 0) {
|
|
176
|
+
timerRef.current = undefined;
|
|
177
|
+
void readBackRef.current();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
timerRef.current = setTimeout(() => void readBackRef.current(), delayMs);
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
// Re-assigned on every render so it reads the latest mailboxes; `refresh` (stable) calls whichever is current.
|
|
184
|
+
readBackRef.current = async () => {
|
|
185
|
+
timerRef.current = undefined;
|
|
186
|
+
const run = ++runRef.current;
|
|
187
|
+
const startedAt = changesRef.current;
|
|
188
|
+
const results = await Promise.all(latestRef.current.mailboxes.map((mailbox) => listFolders(mailbox.uid).catch(() => undefined)));
|
|
189
|
+
if (stoppedRef.current || run !== runRef.current) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (inFlightRef.current > 0 || changesRef.current !== startedAt) {
|
|
193
|
+
// A change of this page's own began or ended while that was being read, so it may predate it: read again.
|
|
194
|
+
refresh();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const fresh: Record<string, FolderCount> = {};
|
|
198
|
+
for (const list of results) {
|
|
199
|
+
for (const folder of list ?? []) {
|
|
200
|
+
fresh[folder.uid] = { unread: folder.unreadCount, total: folder.totalCount };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
setCounts((previous) => {
|
|
204
|
+
const changed = Object.entries(fresh).some(
|
|
205
|
+
([uid, count]) => previous[uid]?.unread !== count.unread || previous[uid]?.total !== count.total,
|
|
206
|
+
);
|
|
207
|
+
return changed ? { ...previous, ...fresh } : previous;
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const track = useCallback(
|
|
212
|
+
(previous: Message | null | undefined, next: Message | null | undefined): CountTracker => {
|
|
213
|
+
const deltas = countDeltas(previous, next);
|
|
214
|
+
inFlightRef.current++;
|
|
215
|
+
adjust(deltas);
|
|
216
|
+
let finished = false;
|
|
217
|
+
/** Whether this call was the one that ended it (a tracker ends once). */
|
|
218
|
+
function finish(): boolean {
|
|
219
|
+
if (finished) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
finished = true;
|
|
223
|
+
inFlightRef.current--;
|
|
224
|
+
lastDoneRef.current = Date.now();
|
|
225
|
+
changesRef.current++;
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
settle() {
|
|
230
|
+
if (finish()) {
|
|
231
|
+
refresh();
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
revert() {
|
|
235
|
+
if (finish()) {
|
|
236
|
+
adjust(deltas.map((delta) => ({ folderUid: delta.folderUid, unread: -delta.unread, total: -delta.total })));
|
|
237
|
+
refresh();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
[adjust, refresh],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const noteCreated = useCallback(
|
|
246
|
+
(message: Message) => {
|
|
247
|
+
const seen = seenRef.current;
|
|
248
|
+
if (seen.has(message.uid)) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
seen.add(message.uid);
|
|
252
|
+
if (seen.size > SEEN_LIMIT) {
|
|
253
|
+
seen.delete(seen.values().next().value!);
|
|
254
|
+
}
|
|
255
|
+
adjust(countDeltas(null, message));
|
|
256
|
+
refresh();
|
|
257
|
+
return true;
|
|
258
|
+
},
|
|
259
|
+
[adjust, refresh],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const applyFolderEvent = useCallback(
|
|
263
|
+
(data: unknown) => {
|
|
264
|
+
const event = data as { uid?: unknown; unreadCount?: unknown; totalCount?: unknown } | null | undefined;
|
|
265
|
+
const uid = event?.uid;
|
|
266
|
+
const folder = typeof uid === "string" ? latestRef.current.folders.find((f) => f.uid === uid) : undefined;
|
|
267
|
+
if (!folder) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (inFlightRef.current > 0 || Date.now() - lastDoneRef.current < COUNT_QUIET_MS) {
|
|
271
|
+
// Its number may be about a state older than this page's own change: let a fresh read settle it.
|
|
272
|
+
refresh();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const valid = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
276
|
+
setCounts((previous) => {
|
|
277
|
+
const current = previous[folder.uid] ?? { unread: folder.unreadCount, total: folder.totalCount };
|
|
278
|
+
const unread = valid(event!.unreadCount) ? event!.unreadCount : current.unread;
|
|
279
|
+
const total = valid(event!.totalCount) ? event!.totalCount : current.total;
|
|
280
|
+
return unread === current.unread && total === current.total && previous[folder.uid] ? previous : { ...previous, [folder.uid]: { unread, total } };
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
[refresh],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// A fresh load of the mailboxes is a fresh start: the folders come with counts of their own. (Keyed on the uids, not the array.)
|
|
287
|
+
const mailboxKey = mailboxes.map((mailbox) => mailbox.uid).join("|");
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
changesRef.current++;
|
|
290
|
+
setCounts((previous) => (Object.keys(previous).length > 0 ? {} : previous));
|
|
291
|
+
}, [mailboxKey]);
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
stoppedRef.current = false;
|
|
295
|
+
return () => {
|
|
296
|
+
stoppedRef.current = true;
|
|
297
|
+
clearTimeout(timerRef.current);
|
|
298
|
+
};
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
return { counts, track, noteCreated, applyFolderEvent, refresh };
|
|
302
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
6
|
+
import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A short-lived, in-memory copy of a folder's listing, so that switching to a folder that was shown a moment ago (or coming back
|
|
10
|
+
* to Mail from another app) shows its rows on the very frame of the click and revalidates behind them (stale-while-revalidate),
|
|
11
|
+
* instead of replacing the list with a loading state and waiting for the network. It is the client-side router's answer to
|
|
12
|
+
* "folder navigation takes seconds": the list of the folder being switched to is usually already known.
|
|
13
|
+
*
|
|
14
|
+
* Only a plain folder's listing is kept - a search's, an aggregate ("All Mailboxes") view's and a load-more page's rows are not -
|
|
15
|
+
* and what is kept is exactly what was on screen, including the rows "load more" added, the selection and the scroll position,
|
|
16
|
+
* so returning to a folder puts the reader where they left it. A snapshot only makes the first paint fast; the listing is always
|
|
17
|
+
* fetched again and folded in (`mergeFirstPage()`), so nothing shown from here is ever taken as current.
|
|
18
|
+
*/
|
|
19
|
+
export interface ListSnapshot {
|
|
20
|
+
messages: Message[];
|
|
21
|
+
conversations: ConversationSummary[];
|
|
22
|
+
hasMore: boolean;
|
|
23
|
+
/** The message that was selected (the reading pane's), if it is still in `messages` when the snapshot is shown. */
|
|
24
|
+
selectedUid: string | null;
|
|
25
|
+
/** The list's `scrollTop`, saved when the listing was left. */
|
|
26
|
+
scrollTop: number;
|
|
27
|
+
/** When it was last written. */
|
|
28
|
+
at: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How long a snapshot may be shown after it was last written. Longer than that the folder is shown as loading, as before. */
|
|
32
|
+
export const LIST_SNAPSHOT_TTL_MS = 5 * 60_000;
|
|
33
|
+
|
|
34
|
+
/** How many folders are remembered; the least recently written is dropped first. */
|
|
35
|
+
export const LIST_SNAPSHOT_MAX = 16;
|
|
36
|
+
|
|
37
|
+
const snapshots = new Map<string, ListSnapshot>();
|
|
38
|
+
|
|
39
|
+
/** Everything that makes one listing different from another: which folder, as messages or conversations, and how it is
|
|
40
|
+
* filtered and sorted. */
|
|
41
|
+
export function listSnapshotKey(parts: {
|
|
42
|
+
mailboxUid: string | undefined;
|
|
43
|
+
folderUid: string | undefined;
|
|
44
|
+
conversations: boolean;
|
|
45
|
+
filter: string;
|
|
46
|
+
labels: string;
|
|
47
|
+
sort: string;
|
|
48
|
+
}): string {
|
|
49
|
+
return [parts.mailboxUid, parts.folderUid, parts.conversations ? "conversations" : "messages", parts.filter, parts.labels, parts.sort].join("|");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The snapshot for `key`, or `undefined` when there is none or it is older than `LIST_SNAPSHOT_TTL_MS`. */
|
|
53
|
+
export function readListSnapshot(key: string, now: number = Date.now()): ListSnapshot | undefined {
|
|
54
|
+
const snapshot = snapshots.get(key);
|
|
55
|
+
if (!snapshot) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (now - snapshot.at > LIST_SNAPSHOT_TTL_MS) {
|
|
59
|
+
snapshots.delete(key);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return snapshot;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Remembers a listing as it is on screen (keeping the scroll position saved for it, if any). */
|
|
66
|
+
export function writeListSnapshot(key: string, listing: Omit<ListSnapshot, "scrollTop" | "at">, now: number = Date.now()): void {
|
|
67
|
+
const previous = snapshots.get(key);
|
|
68
|
+
// Re-inserted so that the Map's order is least-recently-written first.
|
|
69
|
+
snapshots.delete(key);
|
|
70
|
+
snapshots.set(key, { ...listing, scrollTop: previous?.scrollTop ?? 0, at: now });
|
|
71
|
+
while (snapshots.size > LIST_SNAPSHOT_MAX) {
|
|
72
|
+
snapshots.delete(snapshots.keys().next().value as string);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Records how far `key`'s list was scrolled when it was left. A no-op for a listing that was never remembered. */
|
|
77
|
+
export function saveListScroll(key: string, scrollTop: number): void {
|
|
78
|
+
const snapshot = snapshots.get(key);
|
|
79
|
+
if (snapshot) {
|
|
80
|
+
snapshot.scrollTop = scrollTop;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Forgets every snapshot (sign-out, and tests). */
|
|
85
|
+
export function clearListSnapshots(): void {
|
|
86
|
+
snapshots.clear();
|
|
87
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { Message, setMessageRead, setMessagesRead } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
import type { CountTracker } from "./folderCounts.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one way a message's read state changes in the webmail, so that every place that shows it moves together: the row in
|
|
10
|
+
* the list (and the conversation row's unread count), the folder badge, and the unread styling.
|
|
11
|
+
*
|
|
12
|
+
* It is **optimistic**: the row and the badge change the moment the user acts, the server is asked, and if it refuses the
|
|
13
|
+
* change is taken back. What each place is handed to do that is a `ReadStateSink`.
|
|
14
|
+
*/
|
|
15
|
+
export interface ReadStateSink {
|
|
16
|
+
/**
|
|
17
|
+
* Shows a message wherever the caller lists it - the message list, the conversation list's child rows, the open thread.
|
|
18
|
+
* `previous` is given with the optimistic copy (and with the revert of it) and is the copy being replaced, so a
|
|
19
|
+
* conversation row can tell that its unread count went up or down; it is absent for the server's own copy, which
|
|
20
|
+
* differs only in its `version`.
|
|
21
|
+
*/
|
|
22
|
+
patch(updated: Message, previous?: Message): void;
|
|
23
|
+
/** Applies the change to the folder badges: `MailShell`'s `trackMessageChange`. */
|
|
24
|
+
track(previous: Message, next: Message): CountTracker;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function withRead(message: Message, read: boolean): Message {
|
|
28
|
+
return { ...message, flags: { ...message.flags, read } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether `message` is not yet in the state `read` asks for. A message with no `read` flag counts as unread. */
|
|
32
|
+
function needsChange(message: Message, read: boolean): boolean {
|
|
33
|
+
return (message.flags.read === true) !== read;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Marks one message read (`read: true`) or unread. Resolves the server's updated copy, or `undefined` when nothing needed
|
|
38
|
+
* doing (it already was that way) or the server refused - in which case everything has already been put back as it was.
|
|
39
|
+
* Never rejects: like the old mark-as-read, a failure must not get in the way of reading the message.
|
|
40
|
+
*/
|
|
41
|
+
export async function setReadState(message: Message, read: boolean, sink: ReadStateSink): Promise<Message | undefined> {
|
|
42
|
+
if (!needsChange(message, read)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const optimistic = withRead(message, read);
|
|
46
|
+
sink.patch(optimistic, message);
|
|
47
|
+
const tracker = sink.track(message, optimistic);
|
|
48
|
+
try {
|
|
49
|
+
const updated = await setMessageRead(message, read);
|
|
50
|
+
sink.patch(updated);
|
|
51
|
+
tracker.settle();
|
|
52
|
+
return updated;
|
|
53
|
+
} catch {
|
|
54
|
+
sink.patch(message, optimistic);
|
|
55
|
+
tracker.revert();
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The same for a whole selection, in one request. Resolves the updated messages, in request order. Unlike `setReadState()`
|
|
62
|
+
* it rejects when the server refuses (after putting everything back), because a bulk update is not atomic - some of it may
|
|
63
|
+
* have landed - and the caller's own handling reloads the list and says so (see `bulkUpdateMessages()`).
|
|
64
|
+
*/
|
|
65
|
+
export async function setReadStateMany(messages: Message[], read: boolean, sink: ReadStateSink): Promise<Message[]> {
|
|
66
|
+
const changes = messages.filter((message) => needsChange(message, read)).map((message) => ({ message, optimistic: withRead(message, read) }));
|
|
67
|
+
const trackers = changes.map(({ message, optimistic }) => {
|
|
68
|
+
sink.patch(optimistic, message);
|
|
69
|
+
return sink.track(message, optimistic);
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
const updated = await setMessagesRead(messages, read);
|
|
73
|
+
for (const message of updated) {
|
|
74
|
+
sink.patch(message);
|
|
75
|
+
}
|
|
76
|
+
trackers.forEach((tracker) => tracker.settle());
|
|
77
|
+
return updated;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
changes.forEach(({ message, optimistic }, index) => {
|
|
80
|
+
sink.patch(message, optimistic);
|
|
81
|
+
trackers[index].revert();
|
|
82
|
+
});
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What decides whether a new message is announced, what the announcement says, and the two settings behind it (both per
|
|
10
|
+
* browser, in `localStorage`): whether new-mail pop-ups are on at all, and what the user told the "turn on desktop
|
|
11
|
+
* notifications" offer.
|
|
12
|
+
*
|
|
13
|
+
* The announcement is built only from what the push event carries - the whole `Message`, including `bodyPreview` - and only
|
|
14
|
+
* ever as text: React escapes it on screen and the Notifications API takes plain strings, so nothing here is HTML.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** `localStorage` key of the pop-ups switch. Absent means on; `"off"` means the user turned them off. */
|
|
18
|
+
export const NEW_MAIL_POPUPS_KEY = "rapidmx-new-mail-popups";
|
|
19
|
+
|
|
20
|
+
/** `localStorage` key of the answer to the desktop-notifications offer: `"later"` (not now) or `"asked"` (they were asked). */
|
|
21
|
+
export const DESKTOP_OFFER_KEY = "rapidmx-desktop-notifications-offer";
|
|
22
|
+
|
|
23
|
+
/** How much of the body an announcement quotes. */
|
|
24
|
+
export const PREVIEW_MAX_LENGTH = 140;
|
|
25
|
+
|
|
26
|
+
/** A message received longer ago than this is not "new": mail imported or migrated in bulk arrives as `create` events too. */
|
|
27
|
+
export const MAX_NEW_AGE_MS = 6 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** The subject the server stores for an encrypted message (the real one is inside the ciphertext). */
|
|
30
|
+
const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
|
|
31
|
+
|
|
32
|
+
function readStorage(key: string): string | null {
|
|
33
|
+
try {
|
|
34
|
+
return localStorage.getItem(key);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeStorage(key: string, value: string | null): void {
|
|
41
|
+
try {
|
|
42
|
+
if (value === null) {
|
|
43
|
+
localStorage.removeItem(key);
|
|
44
|
+
} else {
|
|
45
|
+
localStorage.setItem(key, value);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether new-mail pop-ups are on (the default). */
|
|
53
|
+
export function getNewMailPopupsEnabled(): boolean {
|
|
54
|
+
return readStorage(NEW_MAIL_POPUPS_KEY) !== "off";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function setNewMailPopupsEnabled(enabled: boolean): void {
|
|
58
|
+
writeStorage(NEW_MAIL_POPUPS_KEY, enabled ? null : "off");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether the "turn on desktop notifications" offer has been put away with "Not now" (or answered). */
|
|
62
|
+
export function getDesktopOfferDismissed(): boolean {
|
|
63
|
+
return readStorage(DESKTOP_OFFER_KEY) !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function setDesktopOfferDismissed(dismissed: boolean): void {
|
|
67
|
+
writeStorage(DESKTOP_OFFER_KEY, dismissed ? "later" : null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The browser's notification permission, or `"unsupported"` where there is no Notifications API. */
|
|
71
|
+
export type DesktopPermission = NotificationPermission | "unsupported";
|
|
72
|
+
|
|
73
|
+
export function desktopPermission(): DesktopPermission {
|
|
74
|
+
return typeof Notification === "undefined" ? "unsupported" : Notification.permission;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Asks the browser for permission - call it from a click. Resolves the answer; never rejects. */
|
|
78
|
+
export async function requestDesktopPermission(): Promise<DesktopPermission> {
|
|
79
|
+
if (typeof Notification === "undefined") {
|
|
80
|
+
return "unsupported";
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return await Notification.requestPermission();
|
|
84
|
+
} catch {
|
|
85
|
+
return desktopPermission();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What an announcement shows. */
|
|
90
|
+
export interface NewMailNotice {
|
|
91
|
+
/** The message's uid: the announcement's identity (and the desktop notification's `tag`). */
|
|
92
|
+
uid: string;
|
|
93
|
+
mailboxUid: string;
|
|
94
|
+
folderUid: string;
|
|
95
|
+
/** The sender's display name, `""` when there is none. */
|
|
96
|
+
senderName: string;
|
|
97
|
+
/** The sender's address - always shown, so a name can't stand in for who really sent it. */
|
|
98
|
+
senderAddress: string;
|
|
99
|
+
subject: string;
|
|
100
|
+
/** About `PREVIEW_MAX_LENGTH` characters of the body as plain text; `Encrypted message` for an encrypted one. */
|
|
101
|
+
preview: string;
|
|
102
|
+
/** Where opening it goes. */
|
|
103
|
+
href: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Control characters, and the invisible or bidirectional-override characters that can make text read as something else, as code point ranges. */
|
|
107
|
+
const UNWANTED_RANGES: [number, number][] = [
|
|
108
|
+
[0x0000, 0x001f],
|
|
109
|
+
[0x007f, 0x009f],
|
|
110
|
+
[0x200b, 0x200f],
|
|
111
|
+
[0x2028, 0x202e],
|
|
112
|
+
[0x2060, 0x2064],
|
|
113
|
+
[0x2066, 0x2069],
|
|
114
|
+
[0xfeff, 0xfeff],
|
|
115
|
+
];
|
|
116
|
+
const hex = (code: number) => "\\u" + code.toString(16).padStart(4, "0");
|
|
117
|
+
const UNWANTED = new RegExp("[" + UNWANTED_RANGES.map(([from, to]) => hex(from) + "-" + hex(to)).join("") + "]+", "g");
|
|
118
|
+
|
|
119
|
+
/** `text` with those characters replaced by spaces, whitespace folded, and cut to `max` characters (with an ellipsis). */
|
|
120
|
+
export function cleanPreview(text: string, max: number = PREVIEW_MAX_LENGTH): string {
|
|
121
|
+
const cleaned = text.replace(UNWANTED, " ").replace(/\s+/g, " ").trim();
|
|
122
|
+
return cleaned.length > max ? cleaned.slice(0, max - 1).trimEnd() + String.fromCharCode(0x2026) : cleaned;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The text of an announcement of `message`. */
|
|
126
|
+
export function noticeFor(message: Message): NewMailNotice {
|
|
127
|
+
const { name, address } = splitMailAddress(message.from);
|
|
128
|
+
const encrypted = message.encrypted === true;
|
|
129
|
+
const rawSubject = cleanPreview(message.subject ?? "", 200);
|
|
130
|
+
const subject =
|
|
131
|
+
!rawSubject || rawSubject === ENCRYPTED_SUBJECT_PLACEHOLDER ? (encrypted ? "(encrypted subject)" : "(no subject)") : rawSubject;
|
|
132
|
+
return {
|
|
133
|
+
uid: message.uid,
|
|
134
|
+
mailboxUid: message.mailboxUid,
|
|
135
|
+
folderUid: message.folderUid,
|
|
136
|
+
// `splitMailAddress()` has already taken control and bidirectional-override characters out of both.
|
|
137
|
+
senderName: name ?? "",
|
|
138
|
+
senderAddress: address,
|
|
139
|
+
subject,
|
|
140
|
+
preview: encrypted ? "Encrypted message" : cleanPreview(message.bodyPreview ?? ""),
|
|
141
|
+
href: `/messages/${encodeURIComponent(message.uid)}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every address that is this user's own, lowercased - a message from one of them is not new mail worth announcing. */
|
|
146
|
+
export function ownAddressesOf(mailboxes: Mailbox[]): Set<string> {
|
|
147
|
+
const own = new Set<string>();
|
|
148
|
+
for (const mailbox of mailboxes) {
|
|
149
|
+
for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
|
|
150
|
+
if (address) {
|
|
151
|
+
own.add(address.trim().toLowerCase());
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return own;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether the arrival of `message` is worth announcing: unread mail in an Inbox (not Drafts, Sent Items, Outbox, Deleted Items,
|
|
160
|
+
* Junk Email or quarantine, which are other folders), that Focused Inbox did not put under Other, that its own user did not
|
|
161
|
+
* send, and that is not old (a bulk import).
|
|
162
|
+
*/
|
|
163
|
+
export function shouldAnnounce(
|
|
164
|
+
message: Message,
|
|
165
|
+
context: { folders: Folder[]; ownAddresses: Set<string>; now?: number },
|
|
166
|
+
): boolean {
|
|
167
|
+
if (context.folders.find((folder) => folder.uid === message.folderUid)?.type !== "inbox") {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (message.flags.read === true || message.inferenceClassification === "other") {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (context.ownAddresses.has((message.from?.address ?? "").trim().toLowerCase())) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const received = Date.parse(message.receivedDate);
|
|
177
|
+
return Number.isNaN(received) || (context.now ?? Date.now()) - received < MAX_NEW_AGE_MS;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `Name <address>`, or the address alone - the desktop notification's title. */
|
|
181
|
+
export function noticeSender(notice: Pick<NewMailNotice, "senderName" | "senderAddress">): string {
|
|
182
|
+
return formatMailAddress({ address: notice.senderAddress, displayName: notice.senderName || undefined });
|
|
183
|
+
}
|