@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,150 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { createContext, useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
9
|
+
import { LiveUpdates, useMailLiveUpdates } from "./useMailLiveUpdates.js";
|
|
10
|
+
import type { FolderCounts } from "./folderCounts.js";
|
|
11
|
+
import { NewMailNotifications, useNewMailNotifications } from "./useNewMailNotifications.js";
|
|
12
|
+
|
|
13
|
+
/** Page size of the one `listMailboxes()` call. */
|
|
14
|
+
export const MAILBOX_LIST_LIMIT = 100;
|
|
15
|
+
|
|
16
|
+
/** Well-known folders sort first in the sidebar, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
|
|
17
|
+
export const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "archive", "deleted_items"];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see `CalendarShell`/`ContactsShell`/`TasksShell`), not
|
|
21
|
+
* Mail - `listFolders()` returns every well-known folder for the mailbox regardless of which app owns it, so Mail's own folder tree filters down
|
|
22
|
+
* to just the mail ones itself, or those other apps' folders leak into its sidebar.
|
|
23
|
+
*/
|
|
24
|
+
export const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
|
|
25
|
+
|
|
26
|
+
export type MailConnectionStatus = "checking" | "error" | "ready";
|
|
27
|
+
|
|
28
|
+
/** Everything the mail apps share about the user's mailboxes, and the one live connection that keeps it current. */
|
|
29
|
+
export interface MailConnection {
|
|
30
|
+
/** Where the one `listMailboxes()` call is. */
|
|
31
|
+
status: MailConnectionStatus;
|
|
32
|
+
/** Why `status` is `"error"`. */
|
|
33
|
+
error: string | null;
|
|
34
|
+
mailboxes: Mailbox[];
|
|
35
|
+
/** Every accessible mailbox's own mail folders, one entry per mailbox - see `MailboxFolders`. */
|
|
36
|
+
mailboxFolders: MailboxFolders[];
|
|
37
|
+
/** Their folder lists are still being fetched. */
|
|
38
|
+
foldersLoading: boolean;
|
|
39
|
+
/** Files a folder that was just created (by this client or another) under its mailbox, so the sidebar and every folder picker show it. */
|
|
40
|
+
onFolderCreated: (folder: Folder) => void;
|
|
41
|
+
/** Bumped whenever new mail may have arrived - see `useMailLiveUpdates()`. */
|
|
42
|
+
live: LiveUpdates;
|
|
43
|
+
/** The folder badges' numbers and how to change them. */
|
|
44
|
+
folderCounts: FolderCounts;
|
|
45
|
+
/** The new-mail pop-ups and desktop notifications, and what feeds them. */
|
|
46
|
+
notifications: NewMailNotifications;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What the persistent app frame (`AppChrome`) offers to the Mail shell so that it doesn't open a second connection. `null` outside a frame. */
|
|
50
|
+
export const MailConnectionContext = createContext<MailConnection | null>(null);
|
|
51
|
+
|
|
52
|
+
export interface UseMailConnectionOptions {
|
|
53
|
+
/** Nothing is fetched, and no socket opened, without a signed-in user. */
|
|
54
|
+
userUid?: string;
|
|
55
|
+
/** Runs only while true. The frame turns it on; a Mail shell rendered outside a frame turns it on for itself; and the one that isn't
|
|
56
|
+
* the owner leaves it off - so exactly one connection exists, whichever of them the page is rendered by. */
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
/** Opens a message from a desktop notification's click (the router's `useNavigate()`); defaults to a plain navigation. */
|
|
59
|
+
open?: (href: string) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The user's mailboxes and their mail folders, plus the live connection that keeps them - and the badges, the list, the new-mail
|
|
64
|
+
* pop-ups - current. It is everything `MailShell` used to do on mount, gathered so that it can live in the persistent app frame instead: the
|
|
65
|
+
* push socket (the server allows ten per user), the safety-net poll, the folder counters and the pop-ups then belong to the signed-in session
|
|
66
|
+
* rather than to whichever page happens to be showing, and keep working in Calendar, Contacts, Tasks and Settings. `MailShell` reads the same
|
|
67
|
+
* state from `MailConnectionContext` when there is a frame, and runs this hook itself when there isn't (a page rendered outside the router).
|
|
68
|
+
*/
|
|
69
|
+
export function useMailConnection({ userUid, enabled, open }: UseMailConnectionOptions): MailConnection {
|
|
70
|
+
const [status, setStatus] = useState<MailConnectionStatus>("checking");
|
|
71
|
+
const [error, setError] = useState<string | null>(null);
|
|
72
|
+
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
|
73
|
+
const [mailboxFolders, setMailboxFolders] = useState<MailboxFolders[]>([]);
|
|
74
|
+
const [foldersLoading, setFoldersLoading] = useState(true);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!userUid || !enabled) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
listMailboxes({ limit: MAILBOX_LIST_LIMIT })
|
|
81
|
+
.then((result) => {
|
|
82
|
+
setMailboxes(result);
|
|
83
|
+
setStatus("ready");
|
|
84
|
+
})
|
|
85
|
+
.catch((err) => {
|
|
86
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
87
|
+
setStatus("error");
|
|
88
|
+
});
|
|
89
|
+
}, [userUid, enabled]);
|
|
90
|
+
|
|
91
|
+
// Fans out one listFolders() call per accessible mailbox in parallel - each call catches its own failure into a MailboxFolders.error
|
|
92
|
+
// rather than letting Promise.all reject, so one mailbox's fetch failure renders that section's own inline Alert instead of blanking out
|
|
93
|
+
// every other mailbox's folder tree.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (mailboxes.length === 0) {
|
|
96
|
+
// Deliberately leaves foldersLoading as-is: this also runs once before listMailboxes() has resolved, and clearing it here would
|
|
97
|
+
// briefly render an empty sidebar the moment mailboxes arrive, before their folders do. A genuinely mailbox-less caller gets
|
|
98
|
+
// MailboxProvisioning.
|
|
99
|
+
setMailboxFolders([]);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setFoldersLoading(true);
|
|
103
|
+
void Promise.all(
|
|
104
|
+
mailboxes.map((mailbox) =>
|
|
105
|
+
listFolders(mailbox.uid)
|
|
106
|
+
.then((result): MailboxFolders => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
|
|
107
|
+
.catch(
|
|
108
|
+
(err): MailboxFolders => ({
|
|
109
|
+
mailbox,
|
|
110
|
+
folders: [],
|
|
111
|
+
error: err instanceof ApiRequestError ? err.message : "Could not load folders.",
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
.then(setMailboxFolders)
|
|
117
|
+
.finally(() => setFoldersLoading(false));
|
|
118
|
+
}, [mailboxes]);
|
|
119
|
+
|
|
120
|
+
/** Files a newly created folder under its own mailbox, leaving every other mailbox's list untouched - and ignoring a type the
|
|
121
|
+
* sidebar doesn't list at all, the same filter the fetch above applies. */
|
|
122
|
+
const onFolderCreated = useCallback((folder: Folder) => {
|
|
123
|
+
if (!MAIL_FOLDER_TYPES.has(folder.type)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
setMailboxFolders((prev) =>
|
|
127
|
+
prev.map((entry) =>
|
|
128
|
+
// Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
|
|
129
|
+
entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
|
|
130
|
+
? { ...entry, folders: [...entry.folders, folder] }
|
|
131
|
+
: entry,
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
// New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders` rather than
|
|
137
|
+
// written into it, so a refreshed unread count never looks like a change of folders to the list on screen (which reloads, and forgets its
|
|
138
|
+
// selection, whenever the folders do). A pop-up (and, in the background, a desktop notification) for each new message the connection
|
|
139
|
+
// announces.
|
|
140
|
+
const notifications = useNewMailNotifications({ mailboxes, mailboxFolders, open });
|
|
141
|
+
const { live, folderCounts } = useMailLiveUpdates({
|
|
142
|
+
userUid: enabled ? userUid : undefined,
|
|
143
|
+
mailboxes,
|
|
144
|
+
mailboxFolders,
|
|
145
|
+
onFolderCreated,
|
|
146
|
+
onMessageCreated: notifications.announce,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, live, folderCounts, notifications };
|
|
150
|
+
}
|
|
@@ -1,224 +1,232 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
-
import { Folder, Mailbox,
|
|
7
|
-
import { getPushClient, PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
8
|
-
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
9
|
-
import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* folders
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// Never runs once `stopped`: whatever sets it clears the timer first.
|
|
129
|
-
function flush() {
|
|
130
|
-
timer = undefined;
|
|
131
|
-
const folderUids = pendingUnknown ? null : new Set(pendingFolders);
|
|
132
|
-
pendingFolders = new Set();
|
|
133
|
-
pendingUnknown = false;
|
|
134
|
-
setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
|
|
139
|
-
function schedule(folderUid?: string) {
|
|
140
|
-
if (stopped) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (folderUid) {
|
|
144
|
-
pendingFolders.add(folderUid);
|
|
145
|
-
} else {
|
|
146
|
-
pendingUnknown = true;
|
|
147
|
-
}
|
|
148
|
-
timer ??= setTimeout(flush, LIVE_EVENT_DEBOUNCE_MS);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const offEvent = client.onEvent((event) => {
|
|
152
|
-
if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
import { getPushClient, PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
8
|
+
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
9
|
+
import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
|
|
10
|
+
import { FolderCounts, useFolderCounts } from "./folderCounts.js";
|
|
11
|
+
|
|
12
|
+
/** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
|
|
13
|
+
* that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
|
|
14
|
+
export const LIVE_POLL_INTERVAL_MS = 45_000;
|
|
15
|
+
|
|
16
|
+
/** Events arriving this close together are answered with one refresh - a burst of new mail is one refetch, not one each. */
|
|
17
|
+
export const LIVE_EVENT_DEBOUNCE_MS = 400;
|
|
18
|
+
|
|
19
|
+
/** What changed, as far as a list on screen is concerned. */
|
|
20
|
+
export interface LiveUpdates {
|
|
21
|
+
/** Bumped each time the list should quietly refetch its first page. `0` until the first bump. */
|
|
22
|
+
tick: number;
|
|
23
|
+
/** The folders the events behind the latest bump touched - `null` when that isn't known (a poll, a reconnect, the tab
|
|
24
|
+
* coming back to the front), meaning any folder may have changed. */
|
|
25
|
+
folderUids: ReadonlySet<string> | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const NO_LIVE_UPDATES: LiveUpdates = { tick: 0, folderUids: null };
|
|
29
|
+
|
|
30
|
+
/** The class names `NotificationUtils.sendMessage()` publishes are the model's (`MessageMongo`, `MessageSQL`, `FolderMongo`). */
|
|
31
|
+
const MESSAGE_EVENT = /^Message/;
|
|
32
|
+
const FOLDER_EVENT = /^Folder/;
|
|
33
|
+
const MESSAGE_ACTIONS = new Set(["create", "update", "delete"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
|
|
37
|
+
* mailbox's Inbox - where mail lands - then each mailbox's other folders in the sidebar's own order, then the mailbox uids
|
|
38
|
+
* themselves (which announce new folders). A folder is a channel of its own; subscribing to a mailbox does not deliver its
|
|
39
|
+
* folders' events.
|
|
40
|
+
*/
|
|
41
|
+
export function pushChannelsFor(mailboxFolders: MailboxFolders[], mailboxes: Mailbox[]): string[] {
|
|
42
|
+
const inboxes = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type === "inbox"));
|
|
43
|
+
const others = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type !== "inbox"));
|
|
44
|
+
return [...new Set([...inboxes, ...others].map((folder) => folder.uid).concat(mailboxes.map((mailbox) => mailbox.uid)))];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isFolder(value: unknown): value is Folder {
|
|
48
|
+
const folder = value as Partial<Folder> | null;
|
|
49
|
+
return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isMessage(value: unknown): value is Message {
|
|
53
|
+
const message = value as Partial<Message> | null;
|
|
54
|
+
return (
|
|
55
|
+
!!message &&
|
|
56
|
+
typeof message.uid === "string" &&
|
|
57
|
+
typeof message.folderUid === "string" &&
|
|
58
|
+
typeof message.flags === "object" &&
|
|
59
|
+
message.flags !== null
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The folder a message event is about: the message's own `folderUid`, else the channel it arrived on (when the server said). */
|
|
64
|
+
function folderOfMessageEvent(event: PushEvent): string | undefined {
|
|
65
|
+
const data = event.data as { folderUid?: unknown } | null | undefined;
|
|
66
|
+
if (typeof data?.folderUid === "string") {
|
|
67
|
+
return data.folderUid;
|
|
68
|
+
}
|
|
69
|
+
return event.channel;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface UseMailLiveUpdatesOptions {
|
|
73
|
+
/** Nothing runs without a signed-in user. */
|
|
74
|
+
userUid?: string;
|
|
75
|
+
mailboxes: Mailbox[];
|
|
76
|
+
mailboxFolders: MailboxFolders[];
|
|
77
|
+
/** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
|
|
78
|
+
onFolderCreated: (folder: Folder) => void;
|
|
79
|
+
/** Called with each new message the push connection announces (a `create` event) - once per message, never for a list
|
|
80
|
+
* refetch, the initial load or a reconnect (nothing published while the socket was down is replayed). The caller decides
|
|
81
|
+
* whether it is worth telling the user about. */
|
|
82
|
+
onMessageCreated?: (message: Message) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface MailLiveUpdates {
|
|
86
|
+
live: LiveUpdates;
|
|
87
|
+
/** The folder badges' numbers and how to change them - see `useFolderCounts()`. Kept in an overlay of their own, **not** in
|
|
88
|
+
* `mailboxFolders`, because the list effect reloads - and forgets the selection - whenever `mailboxFolders` changes. */
|
|
89
|
+
folderCounts: FolderCounts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
|
|
94
|
+
* folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
|
|
95
|
+
* `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
|
|
96
|
+
* tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
|
|
97
|
+
* (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's counts are re-read. A new message
|
|
98
|
+
* also bumps its folder's badge at once and is announced to `onMessageCreated`, and the server's `Folder` update events (the
|
|
99
|
+
* folder's real counts) are applied to the badges - see `useFolderCounts()`.
|
|
100
|
+
*
|
|
101
|
+
* Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
|
|
102
|
+
* Renders nothing and does nothing where there is no window (server-side rendering).
|
|
103
|
+
*/
|
|
104
|
+
export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onMessageCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates {
|
|
105
|
+
const [live, setLive] = useState<LiveUpdates>(NO_LIVE_UPDATES);
|
|
106
|
+
const folders = useMemo(() => mailboxFolders.flatMap((entry) => entry.folders), [mailboxFolders]);
|
|
107
|
+
const folderCounts = useFolderCounts(mailboxes, folders);
|
|
108
|
+
// The latest inputs, for the long-lived listeners below.
|
|
109
|
+
const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated, onMessageCreated, folderCounts });
|
|
110
|
+
latestRef.current = { mailboxes, mailboxFolders, onFolderCreated, onMessageCreated, folderCounts };
|
|
111
|
+
|
|
112
|
+
const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
|
|
113
|
+
const channelKey = channels.join("|");
|
|
114
|
+
const channelsRef = useRef(channels);
|
|
115
|
+
channelsRef.current = channels;
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!userUid) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const client = getPushClient();
|
|
122
|
+
let stopped = false;
|
|
123
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
124
|
+
let pendingFolders = new Set<string>();
|
|
125
|
+
let pendingUnknown = false;
|
|
126
|
+
let everOpen = false;
|
|
127
|
+
|
|
128
|
+
// Never runs once `stopped`: whatever sets it clears the timer first.
|
|
129
|
+
function flush() {
|
|
130
|
+
timer = undefined;
|
|
131
|
+
const folderUids = pendingUnknown ? null : new Set(pendingFolders);
|
|
132
|
+
pendingFolders = new Set();
|
|
133
|
+
pendingUnknown = false;
|
|
134
|
+
setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
|
|
135
|
+
latestRef.current.folderCounts.refresh(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
|
|
139
|
+
function schedule(folderUid?: string) {
|
|
140
|
+
if (stopped) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (folderUid) {
|
|
144
|
+
pendingFolders.add(folderUid);
|
|
145
|
+
} else {
|
|
146
|
+
pendingUnknown = true;
|
|
147
|
+
}
|
|
148
|
+
timer ??= setTimeout(flush, LIVE_EVENT_DEBOUNCE_MS);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const offEvent = client.onEvent((event) => {
|
|
152
|
+
if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
|
|
153
|
+
if (event.action === "create" && isMessage(event.data)) {
|
|
154
|
+
// Once per message, whatever else the event triggers: the badge first, then whoever announces it.
|
|
155
|
+
if (latestRef.current.folderCounts.noteCreated(event.data)) {
|
|
156
|
+
latestRef.current.onMessageCreated?.(event.data);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
schedule(folderOfMessageEvent(event));
|
|
160
|
+
} else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
|
|
161
|
+
const folder = event.data;
|
|
162
|
+
const known = latestRef.current.mailboxFolders.some((entry) => entry.folders.some((f) => f.uid === folder.uid));
|
|
163
|
+
if (!known) {
|
|
164
|
+
latestRef.current.onFolderCreated(folder);
|
|
165
|
+
}
|
|
166
|
+
} else if (FOLDER_EVENT.test(event.type) && event.action === "update") {
|
|
167
|
+
latestRef.current.folderCounts.applyFolderEvent(event.data);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
|
|
171
|
+
const offStatus = client.onStatus((status) => {
|
|
172
|
+
if (status === "open") {
|
|
173
|
+
if (everOpen) {
|
|
174
|
+
schedule();
|
|
175
|
+
}
|
|
176
|
+
everOpen = true;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const poll = setInterval(() => {
|
|
181
|
+
if (document.visibilityState === "visible") {
|
|
182
|
+
schedule();
|
|
183
|
+
}
|
|
184
|
+
}, LIVE_POLL_INTERVAL_MS);
|
|
185
|
+
function refreshNow() {
|
|
186
|
+
if (document.visibilityState === "visible") {
|
|
187
|
+
client.reconnectNow();
|
|
188
|
+
schedule();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function handleOnline() {
|
|
192
|
+
client.reconnectNow();
|
|
193
|
+
schedule();
|
|
194
|
+
}
|
|
195
|
+
document.addEventListener("visibilitychange", refreshNow);
|
|
196
|
+
window.addEventListener("focus", refreshNow);
|
|
197
|
+
window.addEventListener("online", handleOnline);
|
|
198
|
+
|
|
199
|
+
// A sign-out, in this tab or another, ended the session behind the socket: close it for good and stop refreshing.
|
|
200
|
+
const signOut = typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(SIGN_OUT_CHANNEL);
|
|
201
|
+
signOut?.addEventListener("message", (event: MessageEvent<{ type?: string }>) => {
|
|
202
|
+
if (event.data?.type === "sign-out") {
|
|
203
|
+
stopped = true;
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
client.close();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
client.setChannels(channelsRef.current);
|
|
210
|
+
client.start();
|
|
211
|
+
|
|
212
|
+
return () => {
|
|
213
|
+
stopped = true;
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
clearInterval(poll);
|
|
216
|
+
offEvent();
|
|
217
|
+
offStatus();
|
|
218
|
+
document.removeEventListener("visibilitychange", refreshNow);
|
|
219
|
+
window.removeEventListener("focus", refreshNow);
|
|
220
|
+
window.removeEventListener("online", handleOnline);
|
|
221
|
+
signOut?.close();
|
|
222
|
+
};
|
|
223
|
+
}, [userUid]);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (userUid) {
|
|
227
|
+
getPushClient().setChannels(channels);
|
|
228
|
+
}
|
|
229
|
+
}, [userUid, channelKey]);
|
|
230
|
+
|
|
231
|
+
return { live, folderCounts };
|
|
232
|
+
}
|