@rapidmx/web-client 0.8.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/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
- 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/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
- package/apps/shared/components/layout/UserMenu.tsx +303 -150
- package/apps/shared/components/mail/ConversationList.tsx +269 -253
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
- 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 -1700
- 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/SendFailureAlert.tsx +48 -0
- 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 -445
- 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/mergeFirstPage.ts +47 -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 -0
- 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 -1854
- 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/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- 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/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
- package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
- package/dist/apps/shared/components/layout/UserMenu.js +87 -14
- package/dist/apps/shared/components/mail/ConversationList.js +9 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
- 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/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -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 +87 -36
- 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 +122 -27
- 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/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- 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 +19 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
- 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/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -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 +55 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
- 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 +361 -34
- 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,78 @@
|
|
|
1
|
+
import { Folder, FolderType, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
/**
|
|
3
|
+
* The folder badges' numbers, kept right without a reload.
|
|
4
|
+
*
|
|
5
|
+
* A folder's `unreadCount`/`totalCount` come from the server (`GET /mail/folders`, the server-published `Folder` update
|
|
6
|
+
* event, and the poll), but waiting for a round trip to see a badge move after reading a message feels broken, so a change
|
|
7
|
+
* made here is applied to the badge at once - **optimistically** - and then *reconciled*: every so often, and after every
|
|
8
|
+
* change, the real counts are read back and replace whatever this page worked out. So the page can be wrong for a moment,
|
|
9
|
+
* never for good.
|
|
10
|
+
*
|
|
11
|
+
* What the server sends (`@rapidmx/restapi`): `GET /mail/folders` returns each folder with a derived `unreadCount`/`totalCount`
|
|
12
|
+
* (unread messages, all messages), and after any change to a folder's messages it publishes
|
|
13
|
+
* `{ type: /^Folder/, action: "update", data: { uid, mailboxUid, unreadCount, totalCount } }` on the folder's push channel *and* its
|
|
14
|
+
* mailbox's, so each is heard twice - the values are absolute and applying one again changes nothing. Nothing here fails without
|
|
15
|
+
* that event, or for a write that publishes none (ActiveSync/MAPI, erasure jobs): the read-back after each change and the poll do
|
|
16
|
+
* the same job a little later, and overlapping writes to one folder are unordered, so they always have the last word.
|
|
17
|
+
*/
|
|
18
|
+
export interface FolderCount {
|
|
19
|
+
unread: number;
|
|
20
|
+
total: number;
|
|
21
|
+
}
|
|
22
|
+
/** A change to one folder's counts. */
|
|
23
|
+
export interface CountDelta {
|
|
24
|
+
folderUid: string;
|
|
25
|
+
unread: number;
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
/** What one message change - a new message (`previous` absent), a deletion (`next` absent), a read/unread flip, a move - does to the folders' counts. */
|
|
29
|
+
export declare function countDeltas(previous: Message | null | undefined, next: Message | null | undefined): CountDelta[];
|
|
30
|
+
/** 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. */
|
|
31
|
+
export declare function countOfFolder(folder: Folder, counts: Record<string, FolderCount>): FolderCount;
|
|
32
|
+
/** What a folder's badge shows: how many unread, or (Drafts and Outbox, Outlook-style) how many messages are in it. */
|
|
33
|
+
export interface FolderBadge {
|
|
34
|
+
kind: "unread" | "total";
|
|
35
|
+
value: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The badge a folder gets, if any. Inbox, Archive and the user's own folders show their unread count when it is above
|
|
39
|
+
* zero; Drafts and Outbox show how many messages they hold (an unread count means nothing there); Sent Items, Deleted
|
|
40
|
+
* Items and Junk Email show nothing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function badgeFor(type: FolderType, count: FolderCount): FolderBadge | undefined;
|
|
43
|
+
/** The badge's text for assistive technology: "3 unread", "2 messages". */
|
|
44
|
+
export declare function badgeLabel(badge: FolderBadge): string;
|
|
45
|
+
/** The unread messages in every mailbox's Inbox - what the tab title counts. */
|
|
46
|
+
export declare function inboxUnreadTotal(mailboxFolders: {
|
|
47
|
+
folders: Folder[];
|
|
48
|
+
}[], counts: Record<string, FolderCount>): number;
|
|
49
|
+
/** How long after the last change of this page's own has finished a server-published count is trusted again: one published
|
|
50
|
+
* about an earlier state of the folder could otherwise arrive late and put back what the change just took away. */
|
|
51
|
+
export declare const COUNT_QUIET_MS = 1500;
|
|
52
|
+
/** How long after a change the counts are read back from the server. */
|
|
53
|
+
export declare const COUNT_REFRESH_DELAY_MS = 600;
|
|
54
|
+
/** An optimistic change in flight: call exactly one of these once the server has answered. */
|
|
55
|
+
export interface CountTracker {
|
|
56
|
+
/** The server accepted the change: keep what was applied, and read the real counts back. */
|
|
57
|
+
settle(): void;
|
|
58
|
+
/** The server refused it (or it failed): put the counts back as they were, and read the real ones back. */
|
|
59
|
+
revert(): void;
|
|
60
|
+
}
|
|
61
|
+
export interface FolderCounts {
|
|
62
|
+
/** The counts this page has worked out or been told since load, by folder uid - see `countOfFolder()`. */
|
|
63
|
+
counts: Record<string, FolderCount>;
|
|
64
|
+
/** Applies the effect of changing `previous` into `next` now, before the server has answered - see `CountTracker`. */
|
|
65
|
+
track(previous: Message | null | undefined, next: Message | null | undefined): CountTracker;
|
|
66
|
+
/** A new message arrived over the push connection: counts it (once per message) until the real counts are read back.
|
|
67
|
+
* `false` when this one was already counted - a duplicate event. */
|
|
68
|
+
noteCreated(message: Message): boolean;
|
|
69
|
+
/** The server's `Folder` update event's `data`: the folder's real counts - when this page has nothing of its own in flight. */
|
|
70
|
+
applyFolderEvent(data: unknown): void;
|
|
71
|
+
/** Reads every mailbox's real counts back and shows them, after `delayMs` (at once for 0). A later call replaces one still waiting. */
|
|
72
|
+
refresh(delayMs?: number): void;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Holds the folder counts overlay for `MailShell`. `folders` is every folder the sidebar lists (the overlay starts from what
|
|
76
|
+
* each was loaded with); `mailboxes` is whose folders to read back. The overlay starts again whenever `mailboxes` does.
|
|
77
|
+
*/
|
|
78
|
+
export declare function useFolderCounts(mailboxes: Mailbox[], folders: Folder[]): FolderCounts;
|
|
@@ -0,0 +1,212 @@
|
|
|
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 { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
/** What one message change - a new message (`previous` absent), a deletion (`next` absent), a read/unread flip, a move - does to the folders' counts. */
|
|
8
|
+
export function countDeltas(previous, next) {
|
|
9
|
+
const byFolder = new Map();
|
|
10
|
+
function add(message, sign) {
|
|
11
|
+
const entry = byFolder.get(message.folderUid) ?? { folderUid: message.folderUid, unread: 0, total: 0 };
|
|
12
|
+
entry.total += sign;
|
|
13
|
+
entry.unread += message.flags.read === true ? 0 : sign;
|
|
14
|
+
byFolder.set(message.folderUid, entry);
|
|
15
|
+
}
|
|
16
|
+
if (previous) {
|
|
17
|
+
add(previous, -1);
|
|
18
|
+
}
|
|
19
|
+
if (next) {
|
|
20
|
+
add(next, 1);
|
|
21
|
+
}
|
|
22
|
+
return [...byFolder.values()].filter((delta) => delta.unread !== 0 || delta.total !== 0);
|
|
23
|
+
}
|
|
24
|
+
/** 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. */
|
|
25
|
+
export function countOfFolder(folder, counts) {
|
|
26
|
+
return counts[folder.uid] ?? { unread: folder.unreadCount, total: folder.totalCount };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The badge a folder gets, if any. Inbox, Archive and the user's own folders show their unread count when it is above
|
|
30
|
+
* zero; Drafts and Outbox show how many messages they hold (an unread count means nothing there); Sent Items, Deleted
|
|
31
|
+
* Items and Junk Email show nothing.
|
|
32
|
+
*/
|
|
33
|
+
export function badgeFor(type, count) {
|
|
34
|
+
switch (type) {
|
|
35
|
+
case "sent_items":
|
|
36
|
+
case "deleted_items":
|
|
37
|
+
case "junk":
|
|
38
|
+
return undefined;
|
|
39
|
+
case "drafts":
|
|
40
|
+
case "outbox":
|
|
41
|
+
return count.total > 0 ? { kind: "total", value: count.total } : undefined;
|
|
42
|
+
default:
|
|
43
|
+
return count.unread > 0 ? { kind: "unread", value: count.unread } : undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** The badge's text for assistive technology: "3 unread", "2 messages". */
|
|
47
|
+
export function badgeLabel(badge) {
|
|
48
|
+
return badge.kind === "unread" ? `${badge.value} unread` : `${badge.value} ${badge.value === 1 ? "message" : "messages"}`;
|
|
49
|
+
}
|
|
50
|
+
/** The unread messages in every mailbox's Inbox - what the tab title counts. */
|
|
51
|
+
export function inboxUnreadTotal(mailboxFolders, counts) {
|
|
52
|
+
return mailboxFolders.reduce((total, entry) => total + entry.folders.filter((folder) => folder.type === "inbox").reduce((sum, folder) => sum + countOfFolder(folder, counts).unread, 0), 0);
|
|
53
|
+
}
|
|
54
|
+
/** How long after the last change of this page's own has finished a server-published count is trusted again: one published
|
|
55
|
+
* about an earlier state of the folder could otherwise arrive late and put back what the change just took away. */
|
|
56
|
+
export const COUNT_QUIET_MS = 1500;
|
|
57
|
+
/** How long after a change the counts are read back from the server. */
|
|
58
|
+
export const COUNT_REFRESH_DELAY_MS = 600;
|
|
59
|
+
/** How many message uids the "already counted this new message" memory holds. */
|
|
60
|
+
const SEEN_LIMIT = 500;
|
|
61
|
+
/**
|
|
62
|
+
* Holds the folder counts overlay for `MailShell`. `folders` is every folder the sidebar lists (the overlay starts from what
|
|
63
|
+
* each was loaded with); `mailboxes` is whose folders to read back. The overlay starts again whenever `mailboxes` does.
|
|
64
|
+
*/
|
|
65
|
+
export function useFolderCounts(mailboxes, folders) {
|
|
66
|
+
const [counts, setCounts] = useState({});
|
|
67
|
+
const latestRef = useRef({ mailboxes, folders });
|
|
68
|
+
latestRef.current = { mailboxes, folders };
|
|
69
|
+
// Bumped by every change of the overlay this page makes, so a read that started before one can tell it is out of date.
|
|
70
|
+
const changesRef = useRef(0);
|
|
71
|
+
const inFlightRef = useRef(0);
|
|
72
|
+
const lastDoneRef = useRef(0);
|
|
73
|
+
const runRef = useRef(0);
|
|
74
|
+
// Assigned during every render, before anything can call it.
|
|
75
|
+
const readBackRef = useRef(undefined);
|
|
76
|
+
const timerRef = useRef(undefined);
|
|
77
|
+
const stoppedRef = useRef(false);
|
|
78
|
+
const seenRef = useRef(new Set());
|
|
79
|
+
const adjust = useCallback((deltas) => {
|
|
80
|
+
const known = deltas.filter((delta) => latestRef.current.folders.some((folder) => folder.uid === delta.folderUid));
|
|
81
|
+
if (known.length === 0) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
changesRef.current++;
|
|
85
|
+
setCounts((previous) => {
|
|
86
|
+
const next = { ...previous };
|
|
87
|
+
for (const delta of known) {
|
|
88
|
+
const folder = latestRef.current.folders.find((f) => f.uid === delta.folderUid);
|
|
89
|
+
const base = next[delta.folderUid] ?? { unread: folder.unreadCount, total: folder.totalCount };
|
|
90
|
+
next[delta.folderUid] = {
|
|
91
|
+
unread: Math.max(0, base.unread + delta.unread),
|
|
92
|
+
total: Math.max(0, base.total + delta.total),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return next;
|
|
96
|
+
});
|
|
97
|
+
}, []);
|
|
98
|
+
const refresh = useCallback((delayMs = COUNT_REFRESH_DELAY_MS) => {
|
|
99
|
+
if (stoppedRef.current) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
clearTimeout(timerRef.current);
|
|
103
|
+
if (delayMs <= 0) {
|
|
104
|
+
timerRef.current = undefined;
|
|
105
|
+
void readBackRef.current();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
timerRef.current = setTimeout(() => void readBackRef.current(), delayMs);
|
|
109
|
+
}, []);
|
|
110
|
+
// Re-assigned on every render so it reads the latest mailboxes; `refresh` (stable) calls whichever is current.
|
|
111
|
+
readBackRef.current = async () => {
|
|
112
|
+
timerRef.current = undefined;
|
|
113
|
+
const run = ++runRef.current;
|
|
114
|
+
const startedAt = changesRef.current;
|
|
115
|
+
const results = await Promise.all(latestRef.current.mailboxes.map((mailbox) => listFolders(mailbox.uid).catch(() => undefined)));
|
|
116
|
+
if (stoppedRef.current || run !== runRef.current) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (inFlightRef.current > 0 || changesRef.current !== startedAt) {
|
|
120
|
+
// A change of this page's own began or ended while that was being read, so it may predate it: read again.
|
|
121
|
+
refresh();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const fresh = {};
|
|
125
|
+
for (const list of results) {
|
|
126
|
+
for (const folder of list ?? []) {
|
|
127
|
+
fresh[folder.uid] = { unread: folder.unreadCount, total: folder.totalCount };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
setCounts((previous) => {
|
|
131
|
+
const changed = Object.entries(fresh).some(([uid, count]) => previous[uid]?.unread !== count.unread || previous[uid]?.total !== count.total);
|
|
132
|
+
return changed ? { ...previous, ...fresh } : previous;
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
const track = useCallback((previous, next) => {
|
|
136
|
+
const deltas = countDeltas(previous, next);
|
|
137
|
+
inFlightRef.current++;
|
|
138
|
+
adjust(deltas);
|
|
139
|
+
let finished = false;
|
|
140
|
+
/** Whether this call was the one that ended it (a tracker ends once). */
|
|
141
|
+
function finish() {
|
|
142
|
+
if (finished) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
finished = true;
|
|
146
|
+
inFlightRef.current--;
|
|
147
|
+
lastDoneRef.current = Date.now();
|
|
148
|
+
changesRef.current++;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
settle() {
|
|
153
|
+
if (finish()) {
|
|
154
|
+
refresh();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
revert() {
|
|
158
|
+
if (finish()) {
|
|
159
|
+
adjust(deltas.map((delta) => ({ folderUid: delta.folderUid, unread: -delta.unread, total: -delta.total })));
|
|
160
|
+
refresh();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}, [adjust, refresh]);
|
|
165
|
+
const noteCreated = useCallback((message) => {
|
|
166
|
+
const seen = seenRef.current;
|
|
167
|
+
if (seen.has(message.uid)) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
seen.add(message.uid);
|
|
171
|
+
if (seen.size > SEEN_LIMIT) {
|
|
172
|
+
seen.delete(seen.values().next().value);
|
|
173
|
+
}
|
|
174
|
+
adjust(countDeltas(null, message));
|
|
175
|
+
refresh();
|
|
176
|
+
return true;
|
|
177
|
+
}, [adjust, refresh]);
|
|
178
|
+
const applyFolderEvent = useCallback((data) => {
|
|
179
|
+
const event = data;
|
|
180
|
+
const uid = event?.uid;
|
|
181
|
+
const folder = typeof uid === "string" ? latestRef.current.folders.find((f) => f.uid === uid) : undefined;
|
|
182
|
+
if (!folder) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (inFlightRef.current > 0 || Date.now() - lastDoneRef.current < COUNT_QUIET_MS) {
|
|
186
|
+
// Its number may be about a state older than this page's own change: let a fresh read settle it.
|
|
187
|
+
refresh();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const valid = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
191
|
+
setCounts((previous) => {
|
|
192
|
+
const current = previous[folder.uid] ?? { unread: folder.unreadCount, total: folder.totalCount };
|
|
193
|
+
const unread = valid(event.unreadCount) ? event.unreadCount : current.unread;
|
|
194
|
+
const total = valid(event.totalCount) ? event.totalCount : current.total;
|
|
195
|
+
return unread === current.unread && total === current.total && previous[folder.uid] ? previous : { ...previous, [folder.uid]: { unread, total } };
|
|
196
|
+
});
|
|
197
|
+
}, [refresh]);
|
|
198
|
+
// 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.)
|
|
199
|
+
const mailboxKey = mailboxes.map((mailbox) => mailbox.uid).join("|");
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
changesRef.current++;
|
|
202
|
+
setCounts((previous) => (Object.keys(previous).length > 0 ? {} : previous));
|
|
203
|
+
}, [mailboxKey]);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
stoppedRef.current = false;
|
|
206
|
+
return () => {
|
|
207
|
+
stoppedRef.current = true;
|
|
208
|
+
clearTimeout(timerRef.current);
|
|
209
|
+
};
|
|
210
|
+
}, []);
|
|
211
|
+
return { counts, track, noteCreated, applyFolderEvent, refresh };
|
|
212
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
2
|
+
import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
|
+
/**
|
|
4
|
+
* 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
|
|
5
|
+
* to Mail from another app) shows its rows on the very frame of the click and revalidates behind them (stale-while-revalidate),
|
|
6
|
+
* instead of replacing the list with a loading state and waiting for the network. It is the client-side router's answer to
|
|
7
|
+
* "folder navigation takes seconds": the list of the folder being switched to is usually already known.
|
|
8
|
+
*
|
|
9
|
+
* 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 -
|
|
10
|
+
* and what is kept is exactly what was on screen, including the rows "load more" added, the selection and the scroll position,
|
|
11
|
+
* so returning to a folder puts the reader where they left it. A snapshot only makes the first paint fast; the listing is always
|
|
12
|
+
* fetched again and folded in (`mergeFirstPage()`), so nothing shown from here is ever taken as current.
|
|
13
|
+
*/
|
|
14
|
+
export interface ListSnapshot {
|
|
15
|
+
messages: Message[];
|
|
16
|
+
conversations: ConversationSummary[];
|
|
17
|
+
hasMore: boolean;
|
|
18
|
+
/** The message that was selected (the reading pane's), if it is still in `messages` when the snapshot is shown. */
|
|
19
|
+
selectedUid: string | null;
|
|
20
|
+
/** The list's `scrollTop`, saved when the listing was left. */
|
|
21
|
+
scrollTop: number;
|
|
22
|
+
/** When it was last written. */
|
|
23
|
+
at: number;
|
|
24
|
+
}
|
|
25
|
+
/** How long a snapshot may be shown after it was last written. Longer than that the folder is shown as loading, as before. */
|
|
26
|
+
export declare const LIST_SNAPSHOT_TTL_MS: number;
|
|
27
|
+
/** How many folders are remembered; the least recently written is dropped first. */
|
|
28
|
+
export declare const LIST_SNAPSHOT_MAX = 16;
|
|
29
|
+
/** Everything that makes one listing different from another: which folder, as messages or conversations, and how it is
|
|
30
|
+
* filtered and sorted. */
|
|
31
|
+
export declare function listSnapshotKey(parts: {
|
|
32
|
+
mailboxUid: string | undefined;
|
|
33
|
+
folderUid: string | undefined;
|
|
34
|
+
conversations: boolean;
|
|
35
|
+
filter: string;
|
|
36
|
+
labels: string;
|
|
37
|
+
sort: string;
|
|
38
|
+
}): string;
|
|
39
|
+
/** The snapshot for `key`, or `undefined` when there is none or it is older than `LIST_SNAPSHOT_TTL_MS`. */
|
|
40
|
+
export declare function readListSnapshot(key: string, now?: number): ListSnapshot | undefined;
|
|
41
|
+
/** Remembers a listing as it is on screen (keeping the scroll position saved for it, if any). */
|
|
42
|
+
export declare function writeListSnapshot(key: string, listing: Omit<ListSnapshot, "scrollTop" | "at">, now?: number): void;
|
|
43
|
+
/** Records how far `key`'s list was scrolled when it was left. A no-op for a listing that was never remembered. */
|
|
44
|
+
export declare function saveListScroll(key: string, scrollTop: number): void;
|
|
45
|
+
/** Forgets every snapshot (sign-out, and tests). */
|
|
46
|
+
export declare function clearListSnapshots(): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** How long a snapshot may be shown after it was last written. Longer than that the folder is shown as loading, as before. */
|
|
2
|
+
export const LIST_SNAPSHOT_TTL_MS = 5 * 60000;
|
|
3
|
+
/** How many folders are remembered; the least recently written is dropped first. */
|
|
4
|
+
export const LIST_SNAPSHOT_MAX = 16;
|
|
5
|
+
const snapshots = new Map();
|
|
6
|
+
/** Everything that makes one listing different from another: which folder, as messages or conversations, and how it is
|
|
7
|
+
* filtered and sorted. */
|
|
8
|
+
export function listSnapshotKey(parts) {
|
|
9
|
+
return [parts.mailboxUid, parts.folderUid, parts.conversations ? "conversations" : "messages", parts.filter, parts.labels, parts.sort].join("|");
|
|
10
|
+
}
|
|
11
|
+
/** The snapshot for `key`, or `undefined` when there is none or it is older than `LIST_SNAPSHOT_TTL_MS`. */
|
|
12
|
+
export function readListSnapshot(key, now = Date.now()) {
|
|
13
|
+
const snapshot = snapshots.get(key);
|
|
14
|
+
if (!snapshot) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
if (now - snapshot.at > LIST_SNAPSHOT_TTL_MS) {
|
|
18
|
+
snapshots.delete(key);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return snapshot;
|
|
22
|
+
}
|
|
23
|
+
/** Remembers a listing as it is on screen (keeping the scroll position saved for it, if any). */
|
|
24
|
+
export function writeListSnapshot(key, listing, now = Date.now()) {
|
|
25
|
+
const previous = snapshots.get(key);
|
|
26
|
+
// Re-inserted so that the Map's order is least-recently-written first.
|
|
27
|
+
snapshots.delete(key);
|
|
28
|
+
snapshots.set(key, { ...listing, scrollTop: previous?.scrollTop ?? 0, at: now });
|
|
29
|
+
while (snapshots.size > LIST_SNAPSHOT_MAX) {
|
|
30
|
+
snapshots.delete(snapshots.keys().next().value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Records how far `key`'s list was scrolled when it was left. A no-op for a listing that was never remembered. */
|
|
34
|
+
export function saveListScroll(key, scrollTop) {
|
|
35
|
+
const snapshot = snapshots.get(key);
|
|
36
|
+
if (snapshot) {
|
|
37
|
+
snapshot.scrollTop = scrollTop;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Forgets every snapshot (sign-out, and tests). */
|
|
41
|
+
export function clearListSnapshots() {
|
|
42
|
+
snapshots.clear();
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** What `mergeFirstPage()` made of a fresh first page and the rows already on screen. */
|
|
2
|
+
export interface MergedPage<T> {
|
|
3
|
+
/** The rows to show. */
|
|
4
|
+
rows: T[];
|
|
5
|
+
/** How many of the fresh page's rows were not on screen yet - the mail that arrived since. */
|
|
6
|
+
added: number;
|
|
7
|
+
/** `true` when `rows` is exactly the fresh page, because the whole listing fits on it. */
|
|
8
|
+
complete: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
|
|
12
|
+
* added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
|
|
13
|
+
* re-scrolled or re-selected the way a full reload would.
|
|
14
|
+
*
|
|
15
|
+
* - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
|
|
16
|
+
* deleted or moved elsewhere.
|
|
17
|
+
* - A full page is only the top of a longer listing. Its rows go first, in the server's order, followed by the loaded rows
|
|
18
|
+
* it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
|
|
19
|
+
* stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
|
|
20
|
+
* - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
|
|
21
|
+
* just changed (a higher `version`) is not put back by a fetch that started before the change landed.
|
|
22
|
+
*/
|
|
23
|
+
export declare function mergeFirstPage<T>(current: T[], fresh: T[], idOf: (row: T) => string, pageSize: number, pick?: (current: T, fresh: T) => T): MergedPage<T>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
|
|
7
|
+
* added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
|
|
8
|
+
* re-scrolled or re-selected the way a full reload would.
|
|
9
|
+
*
|
|
10
|
+
* - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
|
|
11
|
+
* deleted or moved elsewhere.
|
|
12
|
+
* - A full page is only the top of a longer listing. Its rows go first, in the server's order, followed by the loaded rows
|
|
13
|
+
* it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
|
|
14
|
+
* stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
|
|
15
|
+
* - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
|
|
16
|
+
* just changed (a higher `version`) is not put back by a fetch that started before the change landed.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeFirstPage(current, fresh, idOf, pageSize, pick = (_current, next) => next) {
|
|
19
|
+
const currentById = new Map(current.map((row) => [idOf(row), row]));
|
|
20
|
+
const added = fresh.filter((row) => !currentById.has(idOf(row))).length;
|
|
21
|
+
const rows = fresh.map((row) => {
|
|
22
|
+
const existing = currentById.get(idOf(row));
|
|
23
|
+
return existing ? pick(existing, row) : row;
|
|
24
|
+
});
|
|
25
|
+
if (fresh.length < pageSize) {
|
|
26
|
+
return { rows, added, complete: true };
|
|
27
|
+
}
|
|
28
|
+
const freshIds = new Set(fresh.map(idOf));
|
|
29
|
+
return { rows: [...rows, ...current.filter((row) => !freshIds.has(idOf(row)))], added, complete: false };
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
import type { CountTracker } from "./folderCounts.js";
|
|
3
|
+
/**
|
|
4
|
+
* The one way a message's read state changes in the webmail, so that every place that shows it moves together: the row in
|
|
5
|
+
* the list (and the conversation row's unread count), the folder badge, and the unread styling.
|
|
6
|
+
*
|
|
7
|
+
* It is **optimistic**: the row and the badge change the moment the user acts, the server is asked, and if it refuses the
|
|
8
|
+
* change is taken back. What each place is handed to do that is a `ReadStateSink`.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReadStateSink {
|
|
11
|
+
/**
|
|
12
|
+
* Shows a message wherever the caller lists it - the message list, the conversation list's child rows, the open thread.
|
|
13
|
+
* `previous` is given with the optimistic copy (and with the revert of it) and is the copy being replaced, so a
|
|
14
|
+
* conversation row can tell that its unread count went up or down; it is absent for the server's own copy, which
|
|
15
|
+
* differs only in its `version`.
|
|
16
|
+
*/
|
|
17
|
+
patch(updated: Message, previous?: Message): void;
|
|
18
|
+
/** Applies the change to the folder badges: `MailShell`'s `trackMessageChange`. */
|
|
19
|
+
track(previous: Message, next: Message): CountTracker;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Marks one message read (`read: true`) or unread. Resolves the server's updated copy, or `undefined` when nothing needed
|
|
23
|
+
* doing (it already was that way) or the server refused - in which case everything has already been put back as it was.
|
|
24
|
+
* Never rejects: like the old mark-as-read, a failure must not get in the way of reading the message.
|
|
25
|
+
*/
|
|
26
|
+
export declare function setReadState(message: Message, read: boolean, sink: ReadStateSink): Promise<Message | undefined>;
|
|
27
|
+
/**
|
|
28
|
+
* The same for a whole selection, in one request. Resolves the updated messages, in request order. Unlike `setReadState()`
|
|
29
|
+
* it rejects when the server refuses (after putting everything back), because a bulk update is not atomic - some of it may
|
|
30
|
+
* have landed - and the caller's own handling reloads the list and says so (see `bulkUpdateMessages()`).
|
|
31
|
+
*/
|
|
32
|
+
export declare function setReadStateMany(messages: Message[], read: boolean, sink: ReadStateSink): Promise<Message[]>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { setMessageRead, setMessagesRead } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
function withRead(message, read) {
|
|
7
|
+
return { ...message, flags: { ...message.flags, read } };
|
|
8
|
+
}
|
|
9
|
+
/** Whether `message` is not yet in the state `read` asks for. A message with no `read` flag counts as unread. */
|
|
10
|
+
function needsChange(message, read) {
|
|
11
|
+
return (message.flags.read === true) !== read;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Marks one message read (`read: true`) or unread. Resolves the server's updated copy, or `undefined` when nothing needed
|
|
15
|
+
* doing (it already was that way) or the server refused - in which case everything has already been put back as it was.
|
|
16
|
+
* Never rejects: like the old mark-as-read, a failure must not get in the way of reading the message.
|
|
17
|
+
*/
|
|
18
|
+
export async function setReadState(message, read, sink) {
|
|
19
|
+
if (!needsChange(message, read)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const optimistic = withRead(message, read);
|
|
23
|
+
sink.patch(optimistic, message);
|
|
24
|
+
const tracker = sink.track(message, optimistic);
|
|
25
|
+
try {
|
|
26
|
+
const updated = await setMessageRead(message, read);
|
|
27
|
+
sink.patch(updated);
|
|
28
|
+
tracker.settle();
|
|
29
|
+
return updated;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
sink.patch(message, optimistic);
|
|
33
|
+
tracker.revert();
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The same for a whole selection, in one request. Resolves the updated messages, in request order. Unlike `setReadState()`
|
|
39
|
+
* it rejects when the server refuses (after putting everything back), because a bulk update is not atomic - some of it may
|
|
40
|
+
* have landed - and the caller's own handling reloads the list and says so (see `bulkUpdateMessages()`).
|
|
41
|
+
*/
|
|
42
|
+
export async function setReadStateMany(messages, read, sink) {
|
|
43
|
+
const changes = messages.filter((message) => needsChange(message, read)).map((message) => ({ message, optimistic: withRead(message, read) }));
|
|
44
|
+
const trackers = changes.map(({ message, optimistic }) => {
|
|
45
|
+
sink.patch(optimistic, message);
|
|
46
|
+
return sink.track(message, optimistic);
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
const updated = await setMessagesRead(messages, read);
|
|
50
|
+
for (const message of updated) {
|
|
51
|
+
sink.patch(message);
|
|
52
|
+
}
|
|
53
|
+
trackers.forEach((tracker) => tracker.settle());
|
|
54
|
+
return updated;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
changes.forEach(({ message, optimistic }, index) => {
|
|
58
|
+
sink.patch(message, optimistic);
|
|
59
|
+
trackers[index].revert();
|
|
60
|
+
});
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.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 declare 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 declare const DESKTOP_OFFER_KEY = "rapidmx-desktop-notifications-offer";
|
|
14
|
+
/** How much of the body an announcement quotes. */
|
|
15
|
+
export declare 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 declare const MAX_NEW_AGE_MS: number;
|
|
18
|
+
/** Whether new-mail pop-ups are on (the default). */
|
|
19
|
+
export declare function getNewMailPopupsEnabled(): boolean;
|
|
20
|
+
export declare function setNewMailPopupsEnabled(enabled: boolean): void;
|
|
21
|
+
/** Whether the "turn on desktop notifications" offer has been put away with "Not now" (or answered). */
|
|
22
|
+
export declare function getDesktopOfferDismissed(): boolean;
|
|
23
|
+
export declare function setDesktopOfferDismissed(dismissed: boolean): void;
|
|
24
|
+
/** The browser's notification permission, or `"unsupported"` where there is no Notifications API. */
|
|
25
|
+
export type DesktopPermission = NotificationPermission | "unsupported";
|
|
26
|
+
export declare function desktopPermission(): DesktopPermission;
|
|
27
|
+
/** Asks the browser for permission - call it from a click. Resolves the answer; never rejects. */
|
|
28
|
+
export declare function requestDesktopPermission(): Promise<DesktopPermission>;
|
|
29
|
+
/** What an announcement shows. */
|
|
30
|
+
export interface NewMailNotice {
|
|
31
|
+
/** The message's uid: the announcement's identity (and the desktop notification's `tag`). */
|
|
32
|
+
uid: string;
|
|
33
|
+
mailboxUid: string;
|
|
34
|
+
folderUid: string;
|
|
35
|
+
/** The sender's display name, `""` when there is none. */
|
|
36
|
+
senderName: string;
|
|
37
|
+
/** The sender's address - always shown, so a name can't stand in for who really sent it. */
|
|
38
|
+
senderAddress: string;
|
|
39
|
+
subject: string;
|
|
40
|
+
/** About `PREVIEW_MAX_LENGTH` characters of the body as plain text; `Encrypted message` for an encrypted one. */
|
|
41
|
+
preview: string;
|
|
42
|
+
/** Where opening it goes. */
|
|
43
|
+
href: string;
|
|
44
|
+
}
|
|
45
|
+
/** `text` with those characters replaced by spaces, whitespace folded, and cut to `max` characters (with an ellipsis). */
|
|
46
|
+
export declare function cleanPreview(text: string, max?: number): string;
|
|
47
|
+
/** The text of an announcement of `message`. */
|
|
48
|
+
export declare function noticeFor(message: Message): NewMailNotice;
|
|
49
|
+
/** Every address that is this user's own, lowercased - a message from one of them is not new mail worth announcing. */
|
|
50
|
+
export declare function ownAddressesOf(mailboxes: Mailbox[]): Set<string>;
|
|
51
|
+
/**
|
|
52
|
+
* Whether the arrival of `message` is worth announcing: unread mail in an Inbox (not Drafts, Sent Items, Outbox, Deleted Items,
|
|
53
|
+
* Junk Email or quarantine, which are other folders), that Focused Inbox did not put under Other, that its own user did not
|
|
54
|
+
* send, and that is not old (a bulk import).
|
|
55
|
+
*/
|
|
56
|
+
export declare function shouldAnnounce(message: Message, context: {
|
|
57
|
+
folders: Folder[];
|
|
58
|
+
ownAddresses: Set<string>;
|
|
59
|
+
now?: number;
|
|
60
|
+
}): boolean;
|
|
61
|
+
/** `Name <address>`, or the address alone - the desktop notification's title. */
|
|
62
|
+
export declare function noticeSender(notice: Pick<NewMailNotice, "senderName" | "senderAddress">): string;
|