@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,47 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
import { useMailShell } from "../components/mail/layout/MailShell.js";
|
|
8
|
+
import { setReadState } from "./messageReadState.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Marks a message read when it is opened in a reading pane, through `setReadState()`: the row and the folder badge change at
|
|
12
|
+
* once. `onPatched` is handed each copy of the message as it changes (the optimistic one, then the server's, or the original
|
|
13
|
+
* again if the server refused).
|
|
14
|
+
*
|
|
15
|
+
* Replaces `@rapidmx/react-shared`'s `useMarkMessageRead`, which waits for the server before telling anyone and drops that
|
|
16
|
+
* answer if the message changes meanwhile - which the optimistic copy itself does. A message is asked for once each time it is
|
|
17
|
+
* opened: one the reader then marks unread stays unread while it is open, and one whose request failed is not retried in a loop
|
|
18
|
+
* (its own revert changes it again), only when it is opened again.
|
|
19
|
+
*/
|
|
20
|
+
export function useMarkMessageRead(message: Message | null, onPatched: (updated: Message, previous?: Message) => void): void {
|
|
21
|
+
const { trackMessageChange } = useMailShell();
|
|
22
|
+
const openUidRef = useRef<string | undefined>(undefined);
|
|
23
|
+
const requestedRef = useRef<Set<string>>(new Set());
|
|
24
|
+
const latestRef = useRef({ onPatched, trackMessageChange });
|
|
25
|
+
latestRef.current = { onPatched, trackMessageChange };
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (message?.uid !== openUidRef.current) {
|
|
29
|
+
// Another message (or none) is open now: whatever was asked about the last one is over.
|
|
30
|
+
openUidRef.current = message?.uid;
|
|
31
|
+
requestedRef.current.clear();
|
|
32
|
+
}
|
|
33
|
+
if (!message || requestedRef.current.has(message.uid)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Every opening is asked about once - a message that is already read included, so that marking it unread while it is open (the
|
|
37
|
+
// keyboard's Ctrl+U) doesn't make this run again and read it straight back.
|
|
38
|
+
requestedRef.current.add(message.uid);
|
|
39
|
+
if (message.flags.read === true) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
void setReadState(message, true, {
|
|
43
|
+
patch: (updated, previous) => latestRef.current.onPatched(updated, previous),
|
|
44
|
+
track: (previous, next) => latestRef.current.trackMessageChange(previous, next),
|
|
45
|
+
});
|
|
46
|
+
}, [message]);
|
|
47
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import type { Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
8
|
+
import {
|
|
9
|
+
DesktopPermission,
|
|
10
|
+
NewMailNotice,
|
|
11
|
+
desktopPermission,
|
|
12
|
+
getDesktopOfferDismissed,
|
|
13
|
+
getNewMailPopupsEnabled,
|
|
14
|
+
noticeFor,
|
|
15
|
+
noticeSender,
|
|
16
|
+
ownAddressesOf,
|
|
17
|
+
requestDesktopPermission,
|
|
18
|
+
setDesktopOfferDismissed,
|
|
19
|
+
shouldAnnounce,
|
|
20
|
+
} from "./newMailNotifications.js";
|
|
21
|
+
|
|
22
|
+
/** The most pop-ups on screen at once; a new one pushes the oldest off. */
|
|
23
|
+
export const MAX_TOASTS = 3;
|
|
24
|
+
|
|
25
|
+
/** How long a pop-up stays before it goes by itself (while it is neither hovered nor focused, and the tab is in view). */
|
|
26
|
+
export const TOAST_DURATION_MS = 8_000;
|
|
27
|
+
|
|
28
|
+
/** At most this many desktop notifications in `DESKTOP_BURST_WINDOW_MS`: a flood of mail is a few notifications, not a hundred. */
|
|
29
|
+
export const DESKTOP_BURST_LIMIT = 5;
|
|
30
|
+
export const DESKTOP_BURST_WINDOW_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
/** How many announced message uids are remembered, so an event delivered twice is announced once. */
|
|
33
|
+
const ANNOUNCED_LIMIT = 500;
|
|
34
|
+
|
|
35
|
+
export interface NewMailNotifications {
|
|
36
|
+
/** The pop-ups on screen, oldest first. */
|
|
37
|
+
toasts: NewMailNotice[];
|
|
38
|
+
/** Takes one off the screen. */
|
|
39
|
+
dismiss(uid: string): void;
|
|
40
|
+
/** Whether the first pop-up should also offer to turn on desktop notifications: the browser can, has not been asked,
|
|
41
|
+
* and the user has not said "Not now". */
|
|
42
|
+
offerDesktop: boolean;
|
|
43
|
+
/** The offer's "Turn on desktop notifications": asks the browser. Must be called from a click. */
|
|
44
|
+
enableDesktop(): Promise<void>;
|
|
45
|
+
/** The offer's "Not now": remembered, so it is not made again. */
|
|
46
|
+
declineDesktop(): void;
|
|
47
|
+
/** Announces a message that just arrived - if it is worth it (see `shouldAnnounce()`) and pop-ups are on. Stable. */
|
|
48
|
+
announce(message: Message): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether the user is not looking at this tab: it is hidden, or another window has the focus. */
|
|
52
|
+
function tabIsInBackground(): boolean {
|
|
53
|
+
return document.visibilityState === "hidden" || !document.hasFocus();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UseNewMailNotificationsOptions {
|
|
57
|
+
mailboxes: Mailbox[];
|
|
58
|
+
mailboxFolders: MailboxFolders[];
|
|
59
|
+
/** Opens a message from a desktop notification's click. Defaults to a plain navigation. */
|
|
60
|
+
open?: (href: string) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultOpen(href: string): void {
|
|
64
|
+
window.location.href = href;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* New-mail announcements: an in-app pop-up for each message that arrives (`MAX_TOASTS` at most on screen), and - while the tab
|
|
69
|
+
* is in the background and the user has allowed it - a desktop notification with the same content, one per message however
|
|
70
|
+
* many events name it. `announce()` is what `useMailLiveUpdates()`'s `onMessageCreated` calls; nothing else feeds it, so a
|
|
71
|
+
* page load, a list refetch and a reconnect never announce anything.
|
|
72
|
+
*
|
|
73
|
+
* Nothing is ever asked of the browser on load: permission is requested only by `enableDesktop()`, from the offer in the
|
|
74
|
+
* first pop-up, or from Settings. What the user answered is remembered in `localStorage` (see `newMailNotifications.ts`).
|
|
75
|
+
*/
|
|
76
|
+
export function useNewMailNotifications({ mailboxes, mailboxFolders, open = defaultOpen }: UseNewMailNotificationsOptions): NewMailNotifications {
|
|
77
|
+
const [toasts, setToasts] = useState<NewMailNotice[]>([]);
|
|
78
|
+
const [permission, setPermission] = useState<DesktopPermission>("unsupported");
|
|
79
|
+
const [offerDismissed, setOfferDismissed] = useState(true);
|
|
80
|
+
const folders = useMemo(() => mailboxFolders.flatMap((entry) => entry.folders), [mailboxFolders]);
|
|
81
|
+
const ownAddresses = useMemo(() => ownAddressesOf(mailboxes), [mailboxes]);
|
|
82
|
+
const latestRef = useRef({ folders, ownAddresses, open });
|
|
83
|
+
latestRef.current = { folders, ownAddresses, open };
|
|
84
|
+
const announcedRef = useRef<Set<string>>(new Set());
|
|
85
|
+
const desktopShownRef = useRef<number[]>([]);
|
|
86
|
+
|
|
87
|
+
// The browser's own state is only read on the client, after hydration, so the server and first client render agree.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
setPermission(desktopPermission());
|
|
90
|
+
setOfferDismissed(getDesktopOfferDismissed());
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const dismiss = useCallback((uid: string) => {
|
|
94
|
+
setToasts((previous) => previous.filter((toast) => toast.uid !== uid));
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const showDesktop = useCallback((notice: NewMailNotice) => {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
desktopShownRef.current = desktopShownRef.current.filter((at) => now - at < DESKTOP_BURST_WINDOW_MS);
|
|
100
|
+
if (desktopShownRef.current.length >= DESKTOP_BURST_LIMIT) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const notification = new Notification(noticeSender(notice), {
|
|
105
|
+
body: [notice.subject, notice.preview].filter(Boolean).join("\n"),
|
|
106
|
+
// One per message: a duplicate (another tab of this app, a re-delivered event) replaces instead of stacking.
|
|
107
|
+
tag: notice.uid,
|
|
108
|
+
});
|
|
109
|
+
desktopShownRef.current.push(now);
|
|
110
|
+
notification.onclick = () => {
|
|
111
|
+
window.focus();
|
|
112
|
+
notification.close();
|
|
113
|
+
latestRef.current.open(notice.href);
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
// Some browsers refuse `new Notification()` outright (a mobile one wanting a service worker): the pop-up is enough.
|
|
117
|
+
}
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
const announce = useCallback(
|
|
121
|
+
(message: Message) => {
|
|
122
|
+
if (!getNewMailPopupsEnabled() || announcedRef.current.has(message.uid)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const { folders: knownFolders, ownAddresses: own } = latestRef.current;
|
|
126
|
+
if (!shouldAnnounce(message, { folders: knownFolders, ownAddresses: own })) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
announcedRef.current.add(message.uid);
|
|
130
|
+
if (announcedRef.current.size > ANNOUNCED_LIMIT) {
|
|
131
|
+
announcedRef.current.delete(announcedRef.current.values().next().value!);
|
|
132
|
+
}
|
|
133
|
+
const notice = noticeFor(message);
|
|
134
|
+
setToasts((previous) => [...previous.filter((toast) => toast.uid !== notice.uid), notice].slice(-MAX_TOASTS));
|
|
135
|
+
if (desktopPermission() === "granted" && tabIsInBackground()) {
|
|
136
|
+
showDesktop(notice);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
[showDesktop],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const enableDesktop = useCallback(async () => {
|
|
143
|
+
const answer = await requestDesktopPermission();
|
|
144
|
+
setPermission(answer);
|
|
145
|
+
// Answered either way, so the offer goes: on granted it has done its job, on denied it can't be made again.
|
|
146
|
+
setDesktopOfferDismissed(true);
|
|
147
|
+
setOfferDismissed(true);
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const declineDesktop = useCallback(() => {
|
|
151
|
+
setDesktopOfferDismissed(true);
|
|
152
|
+
setOfferDismissed(true);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
toasts,
|
|
157
|
+
dismiss,
|
|
158
|
+
offerDesktop: permission === "default" && !offerDismissed,
|
|
159
|
+
enableDesktop,
|
|
160
|
+
declineDesktop,
|
|
161
|
+
announce,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
|
|
7
|
+
/** A count this hook put in front of the title, so it can be taken off again: `(3) `. */
|
|
8
|
+
const TITLE_PREFIX = /^\(\d+\) /;
|
|
9
|
+
|
|
10
|
+
/** `title` with `count` unread in front - `(3) Acme: Mail` - or as it is when there are none. */
|
|
11
|
+
export function titleWithUnread(title: string, count: number): string {
|
|
12
|
+
const base = title.replace(TITLE_PREFIX, "");
|
|
13
|
+
return count > 0 ? `(${count > 99 ? "99+" : count}) ${base}` : base;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseUnreadTitleOptions {
|
|
17
|
+
/** Does nothing while false - for the one of two owners that isn't. Default true. */
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
/** Changes whenever something else has rewritten the title (the app frame sets it on every page change), so the count is put in front of
|
|
20
|
+
* the new one. */
|
|
21
|
+
resetKey?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Keeps the browser tab's title in step with the unread count, as Outlook on the web does - `(3) Acme: Mail` - so mail that
|
|
26
|
+
* arrives while the tab is in the background can be seen from the tab strip. The count is put in front of whatever the title is
|
|
27
|
+
* (the page sets its own, from the branding) and taken off again when it drops to zero or the component goes away.
|
|
28
|
+
*
|
|
29
|
+
* Order matters when the same component also sets the title: declare this hook after the effect that does, so that on a change of `resetKey`
|
|
30
|
+
* the count is taken off the old title, the new title is written, and the count goes back in front of it.
|
|
31
|
+
*/
|
|
32
|
+
export function useUnreadTitle(count: number, { enabled = true, resetKey }: UseUnreadTitleOptions = {}): void {
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!enabled) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
document.title = titleWithUnread(document.title, count);
|
|
38
|
+
return () => {
|
|
39
|
+
document.title = titleWithUnread(document.title, 0);
|
|
40
|
+
};
|
|
41
|
+
}, [count, enabled, resetKey]);
|
|
42
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The web client's client-side router: what lets Mail, Calendar, Contacts, Tasks and Settings replace one another - and a
|
|
7
|
+
* folder replace a folder - without a page load.
|
|
8
|
+
*
|
|
9
|
+
* `@rapidrest/react` has no router of its own: every page is a server-rendered document with its own hydration entry, and
|
|
10
|
+
* hydrates only the page component (never `_layout.tsx`). So the router is a component the pages render themselves - each
|
|
11
|
+
* page's default export is `routedPage(path, Page)` (see `apps/www/_routedPage.tsx`), which renders `AppRouter` with the page
|
|
12
|
+
* as its first content. The first load is unchanged: the server renders the same tree, the browser hydrates it.
|
|
13
|
+
*
|
|
14
|
+
* `AppRouter` keeps ONE `AppChrome` (the icon rail, header, user menu, impersonation banner, compose windows, unlock prompt,
|
|
15
|
+
* idle-key timer, sign-out listener) mounted and swaps only what is inside it. A page's own shell (`MailShell`,
|
|
16
|
+
* `CalendarShell`, ...) still renders `AppShell` as it always did; inside the frame that renders nothing but its children.
|
|
17
|
+
*
|
|
18
|
+
* Navigation:
|
|
19
|
+
*
|
|
20
|
+
* Every `<a href>` to a same-origin path in the route table is intercepted (plain left clicks only - not with a modifier key, not
|
|
21
|
+
* `target`/`download`, not a hash-only link, not one marked `data-full-reload`), so pages just write ordinary links and
|
|
22
|
+
* middle-click, "open in new tab" and copy-link keep working. Anything not in the table - the admin and escrow consoles, plugin
|
|
23
|
+
* pages, other sites - is left to the browser. `useNavigate()` does the same for code (`navigate("/contacts")`).
|
|
24
|
+
*
|
|
25
|
+
* Back and forward work (`popstate`), and the URL is always the real, shareable one (`?mailboxUid=&folderUid=` and all).
|
|
26
|
+
*
|
|
27
|
+
* A route's code is loaded (`route.load()`) before the URL and the page change, so the old page stays up meanwhile (the frame is
|
|
28
|
+
* `aria-busy`); a route that fails to load falls back to a real navigation, which also picks up a new deploy.
|
|
29
|
+
*
|
|
30
|
+
* The next page's code is fetched on hover, focus and pointer-down of a link to it, and for the app rail's pages when the browser
|
|
31
|
+
* is idle.
|
|
32
|
+
*
|
|
33
|
+
* After a page change the frame moves keyboard focus to the content region, scrolls to the top, sets `document.title` and
|
|
34
|
+
* announces the new page to screen readers.
|
|
35
|
+
*
|
|
36
|
+
* The props of every `apps/www` page are the same for all of them (`WwwRoute.fetchProps()`: the user, auth-server URL,
|
|
37
|
+
* branding, plugin navigation, impersonation) except `params`, which the router recomputes from the URL - so the props of the
|
|
38
|
+
* page that was loaded are what every later page gets.
|
|
39
|
+
*/
|
|
40
|
+
import React, { ComponentType, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
41
|
+
import { AppChrome, type AppShellProps } from "../components/layout/AppShell.js";
|
|
42
|
+
import { AppFrameContext, AppFrameContextValue } from "./frameContext.js";
|
|
43
|
+
import { whenIdle, shouldSaveData } from "./idle.js";
|
|
44
|
+
import { RouteDefinition, RouteMatch, matchRoute } from "./routes.js";
|
|
45
|
+
import { NavigateFn, RouterContext, RouterContextValue, RouterLocation, UNKNOWN_LOCATION, readWindowLocation } from "./routerContext.js";
|
|
46
|
+
|
|
47
|
+
export { useLocation, useLocationSearch, useNavigate } from "./routerContext.js";
|
|
48
|
+
export type { NavigateFn, NavigateOptions, RouterLocation } from "./routerContext.js";
|
|
49
|
+
|
|
50
|
+
/** What `routedPage()` gives the router: the page component itself, without the router around it. */
|
|
51
|
+
export type RoutedPageComponent<P = any> = ComponentType<P> & { page: ComponentType<P> };
|
|
52
|
+
|
|
53
|
+
interface CurrentPage {
|
|
54
|
+
Page: ComponentType<any>;
|
|
55
|
+
/** The route template shown. */
|
|
56
|
+
routePath: string;
|
|
57
|
+
active: string;
|
|
58
|
+
params: Record<string, string>;
|
|
59
|
+
/** Changes with the pathname, so a different page (or the same page for a different `:uid`) mounts fresh, while a change
|
|
60
|
+
* of query string alone - a folder - keeps the page and its state. */
|
|
61
|
+
key: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AppRouterProps {
|
|
65
|
+
routes: readonly RouteDefinition[];
|
|
66
|
+
/** The template of the route this document was rendered for. */
|
|
67
|
+
initialPath: string;
|
|
68
|
+
initialPage: ComponentType<any>;
|
|
69
|
+
/** The props the server rendered the page with. */
|
|
70
|
+
pageProps: Record<string, any>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function pickChromeProps(props: Record<string, any>): Omit<AppShellProps, "active"> {
|
|
74
|
+
const { userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav } = props;
|
|
75
|
+
return { userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The anchor a click/pointer event is on or in, if it is a link. */
|
|
79
|
+
function linkOf(event: Event): HTMLAnchorElement | null {
|
|
80
|
+
const target = event.target;
|
|
81
|
+
return target instanceof Element ? target.closest("a[href]") : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The URL of a link the router may take over, `null` for one the browser must handle itself. */
|
|
85
|
+
function routerUrlOf(link: HTMLAnchorElement): URL | null {
|
|
86
|
+
if ((link.target && link.target !== "_self") || link.hasAttribute("download") || link.hasAttribute("data-full-reload")) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const url = new URL(link.href, window.location.href);
|
|
90
|
+
if (url.origin !== window.location.origin) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
// A link that only moves within the page (`#section`) is the browser's own.
|
|
94
|
+
if (url.hash !== "" && url.pathname === window.location.pathname && url.search === window.location.search) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return url;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default function AppRouter({ routes, initialPath, initialPage, pageProps }: AppRouterProps) {
|
|
101
|
+
const [current, setCurrent] = useState<CurrentPage>(() => ({
|
|
102
|
+
Page: initialPage,
|
|
103
|
+
routePath: initialPath,
|
|
104
|
+
active: routes.find((route) => route.path === initialPath)?.active ?? "mail",
|
|
105
|
+
params: pageProps.params ?? {},
|
|
106
|
+
key: "initial",
|
|
107
|
+
}));
|
|
108
|
+
const [location, setLocation] = useState<RouterLocation>(UNKNOWN_LOCATION);
|
|
109
|
+
const [pending, setPending] = useState(false);
|
|
110
|
+
const [takeovers, setTakeovers] = useState(0);
|
|
111
|
+
|
|
112
|
+
const currentRef = useRef(current);
|
|
113
|
+
currentRef.current = current;
|
|
114
|
+
/** Identifies the latest navigation; one that finds it changed while loading has been overtaken and does nothing. */
|
|
115
|
+
const navigationRef = useRef(0);
|
|
116
|
+
/** The page components loaded or loading, by route template - one request per page however many links are touched. */
|
|
117
|
+
const pagesRef = useRef(new Map<string, Promise<ComponentType<any>>>([[initialPath, Promise.resolve(initialPage)]]));
|
|
118
|
+
const routesRef = useRef(routes);
|
|
119
|
+
routesRef.current = routes;
|
|
120
|
+
|
|
121
|
+
/** Loads a route's page component (once). Rejects if its code can't be loaded - and then a later attempt tries again. */
|
|
122
|
+
const pageFor = useCallback((route: RouteDefinition): Promise<ComponentType<any>> => {
|
|
123
|
+
let loading = pagesRef.current.get(route.path);
|
|
124
|
+
if (!loading) {
|
|
125
|
+
loading = route.load().then((module) => {
|
|
126
|
+
const page = (module.default as Partial<RoutedPageComponent>).page;
|
|
127
|
+
if (!page) {
|
|
128
|
+
throw new Error(`The page for ${route.path} is not a routedPage().`);
|
|
129
|
+
}
|
|
130
|
+
return page;
|
|
131
|
+
});
|
|
132
|
+
pagesRef.current.set(route.path, loading);
|
|
133
|
+
loading.catch(() => pagesRef.current.delete(route.path));
|
|
134
|
+
}
|
|
135
|
+
return loading;
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
/** The pathname of the page on screen (the address bar's, once a navigation has been committed). */
|
|
139
|
+
const shownPathRef = useRef<string | null>(null);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Shows `match` (loading its page first if it is a different page from the one on screen), calling `commit` - which puts
|
|
143
|
+
* the URL in the address bar - at the moment the new page replaces the old one. Resolves `false` when the page's code
|
|
144
|
+
* couldn't be loaded, and `true` otherwise, including when a newer navigation overtook this one while it loaded.
|
|
145
|
+
*/
|
|
146
|
+
const show = useCallback(
|
|
147
|
+
async (match: RouteMatch, target: RouterLocation, sequence: number, commit: () => void): Promise<boolean> => {
|
|
148
|
+
const pathChanged = target.pathname !== shownPathRef.current;
|
|
149
|
+
let Page = currentRef.current.Page;
|
|
150
|
+
if (pathChanged && match.route.path !== currentRef.current.routePath) {
|
|
151
|
+
try {
|
|
152
|
+
Page = await pageFor(match.route);
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (sequence !== navigationRef.current) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
commit();
|
|
161
|
+
shownPathRef.current = target.pathname;
|
|
162
|
+
if (pathChanged) {
|
|
163
|
+
setCurrent({ Page, routePath: match.route.path, active: match.route.active, params: match.params, key: target.pathname });
|
|
164
|
+
}
|
|
165
|
+
setLocation(target);
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
[pageFor],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const navigate = useCallback<NavigateFn>(
|
|
172
|
+
(href, options = {}) => {
|
|
173
|
+
const url = new URL(href, window.location.href);
|
|
174
|
+
const leave = () => (options.replace ? window.location.replace(url.href) : (window.location.href = url.href));
|
|
175
|
+
const match = url.origin === window.location.origin ? matchRoute(routesRef.current, url.pathname) : undefined;
|
|
176
|
+
if (!match) {
|
|
177
|
+
leave();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const target: RouterLocation = { pathname: url.pathname, search: url.search, hash: url.hash };
|
|
181
|
+
const now = readWindowLocation();
|
|
182
|
+
if (target.pathname === now.pathname && target.search === now.search && target.hash === now.hash) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const sequence = ++navigationRef.current;
|
|
186
|
+
setPending(true);
|
|
187
|
+
void show(match, target, sequence, () => {
|
|
188
|
+
window.history[options.replace ? "replaceState" : "pushState"](null, "", target.pathname + target.search + target.hash);
|
|
189
|
+
}).then((ok) => {
|
|
190
|
+
if (!ok) {
|
|
191
|
+
leave();
|
|
192
|
+
}
|
|
193
|
+
if (sequence === navigationRef.current) {
|
|
194
|
+
setPending(false);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
[show],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// The browser's own location is read after the first render, never during it: the server can't know the query string.
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const here = readWindowLocation();
|
|
204
|
+
shownPathRef.current = here.pathname;
|
|
205
|
+
setLocation(here);
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
// Back and forward.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
function handlePopState() {
|
|
211
|
+
const here = readWindowLocation();
|
|
212
|
+
const match = matchRoute(routesRef.current, here.pathname);
|
|
213
|
+
if (!match) {
|
|
214
|
+
window.location.reload();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const sequence = ++navigationRef.current;
|
|
218
|
+
setPending(true);
|
|
219
|
+
void show(match, here, sequence, () => undefined).then((ok) => {
|
|
220
|
+
if (!ok) {
|
|
221
|
+
window.location.reload();
|
|
222
|
+
}
|
|
223
|
+
if (sequence === navigationRef.current) {
|
|
224
|
+
setPending(false);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
window.addEventListener("popstate", handlePopState);
|
|
229
|
+
return () => window.removeEventListener("popstate", handlePopState);
|
|
230
|
+
}, [show]);
|
|
231
|
+
|
|
232
|
+
// Plain links, and fetching what they lead to before they are clicked.
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
function handleClick(event: MouseEvent) {
|
|
235
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const link = linkOf(event);
|
|
239
|
+
const url = link && routerUrlOf(link);
|
|
240
|
+
if (!url || !matchRoute(routesRef.current, url.pathname)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
navigate(url.pathname + url.search + url.hash);
|
|
245
|
+
}
|
|
246
|
+
function handleIntent(event: Event) {
|
|
247
|
+
const link = linkOf(event);
|
|
248
|
+
const url = link && routerUrlOf(link);
|
|
249
|
+
const match = url && matchRoute(routesRef.current, url.pathname);
|
|
250
|
+
if (match) {
|
|
251
|
+
pageFor(match.route).catch(() => undefined);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
document.addEventListener("click", handleClick);
|
|
255
|
+
document.addEventListener("pointerover", handleIntent);
|
|
256
|
+
document.addEventListener("pointerdown", handleIntent);
|
|
257
|
+
document.addEventListener("focusin", handleIntent);
|
|
258
|
+
return () => {
|
|
259
|
+
document.removeEventListener("click", handleClick);
|
|
260
|
+
document.removeEventListener("pointerover", handleIntent);
|
|
261
|
+
document.removeEventListener("pointerdown", handleIntent);
|
|
262
|
+
document.removeEventListener("focusin", handleIntent);
|
|
263
|
+
};
|
|
264
|
+
}, [navigate, pageFor]);
|
|
265
|
+
|
|
266
|
+
// The app rail's other pages, once the browser has nothing else to do - so that the likeliest next click is instant.
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (shouldSaveData()) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const cancels = routesRef.current
|
|
272
|
+
.filter((route) => route.idlePrefetch)
|
|
273
|
+
.map((route) => whenIdle(() => void pageFor(route).catch(() => undefined)));
|
|
274
|
+
return () => cancels.forEach((cancel) => cancel());
|
|
275
|
+
}, [pageFor]);
|
|
276
|
+
|
|
277
|
+
const enterTakeover = useCallback(() => {
|
|
278
|
+
setTakeovers((n) => n + 1);
|
|
279
|
+
return () => setTakeovers((n) => n - 1);
|
|
280
|
+
}, []);
|
|
281
|
+
const frame = useMemo<AppFrameContextValue>(() => ({ enterTakeover }), [enterTakeover]);
|
|
282
|
+
const router = useMemo<RouterContextValue>(() => ({ location, navigate }), [location, navigate]);
|
|
283
|
+
|
|
284
|
+
const Page = current.Page;
|
|
285
|
+
return (
|
|
286
|
+
<RouterContext.Provider value={router}>
|
|
287
|
+
<AppFrameContext.Provider value={frame}>
|
|
288
|
+
<AppChrome
|
|
289
|
+
active={current.active}
|
|
290
|
+
routeKey={current.key}
|
|
291
|
+
busy={pending}
|
|
292
|
+
hideChrome={takeovers > 0}
|
|
293
|
+
{...pickChromeProps(pageProps)}
|
|
294
|
+
>
|
|
295
|
+
<Page key={current.key} {...pageProps} params={current.params} />
|
|
296
|
+
</AppChrome>
|
|
297
|
+
</AppFrameContext.Provider>
|
|
298
|
+
</RouterContext.Provider>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** Where each of the four rail apps lives - what the icon rail links to and what the keyboard's "Go to ..." shortcuts navigate to. */
|
|
7
|
+
export const APP_HREFS = {
|
|
8
|
+
mail: "/",
|
|
9
|
+
calendar: "/calendar",
|
|
10
|
+
contacts: "/contacts",
|
|
11
|
+
tasks: "/tasks",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Settings is reached from the account menu, not the rail. It links straight to the first settings section, the only one that always
|
|
16
|
+
* exists - repoint this at a real `/settings` landing page once there is one.
|
|
17
|
+
*/
|
|
18
|
+
export const SETTINGS_HREF = "/settings/auto-reply";
|
|
19
|
+
|
|
20
|
+
/** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
|
|
21
|
+
export function isSettingsPath(pathname: string): boolean {
|
|
22
|
+
return pathname === "/settings" || pathname.startsWith("/settings/");
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { PropsWithChildren, createContext, useContext, useLayoutEffect } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the persistent app frame (`AppRouter`'s one mounted `AppShell` chrome) offers to what is rendered inside it. `null`
|
|
9
|
+
* outside a frame - a page not shown by the router (an admin page, a plugin page, a test) renders its own chrome.
|
|
10
|
+
*/
|
|
11
|
+
export interface AppFrameContextValue {
|
|
12
|
+
/** Hides the frame's chrome (icon rail, header, footer) until the returned function is called - see `FrameTakeover`. */
|
|
13
|
+
enterTakeover: () => () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const AppFrameContext = createContext<AppFrameContextValue | null>(null);
|
|
17
|
+
|
|
18
|
+
/** `true` inside the persistent app frame. */
|
|
19
|
+
export function useInAppFrame(): boolean {
|
|
20
|
+
return useContext(AppFrameContext) !== null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wraps a screen that takes over the whole window - first-time key setup, "your mailbox is being set up" - which used to
|
|
25
|
+
* replace the page's shell entirely. Inside the persistent frame the shell can't be replaced (it stays mounted), so the frame
|
|
26
|
+
* hides its chrome for as long as this is mounted instead (and gives the screen the full width); outside a frame it renders its children as they are. A layout effect, so the chrome
|
|
27
|
+
* is gone before the screen is first painted.
|
|
28
|
+
*/
|
|
29
|
+
export function FrameTakeover({ children }: PropsWithChildren) {
|
|
30
|
+
const frame = useContext(AppFrameContext);
|
|
31
|
+
useLayoutEffect(() => frame?.enterTakeover(), [frame]);
|
|
32
|
+
// The frame puts a page in `#app-content`, a flex row: a full-window screen there would shrink to its content and sit at the left
|
|
33
|
+
// edge instead of centring, so inside the frame it is given the whole row, as a block its own root fills.
|
|
34
|
+
return frame ? <div className="flex-1 min-w-0">{children}</div> : <>{children}</>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** How long an idle callback may be held back on a busy page before it runs anyway. */
|
|
7
|
+
export const IDLE_TIMEOUT_MS = 3_000;
|
|
8
|
+
|
|
9
|
+
/** The delay used where the browser has no `requestIdleCallback` (Safari). */
|
|
10
|
+
export const IDLE_FALLBACK_DELAY_MS = 200;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Runs `callback` once the page has finished loading (the window `load` event, which waits for every script and image the page
|
|
14
|
+
* asked for) and the browser has nothing better to do (`requestIdleCallback`), or shortly after where there is no such thing.
|
|
15
|
+
* For work that only makes later things faster - fetching a code chunk, warming a cache - and must never compete with what
|
|
16
|
+
* the user is waiting for. Returns a function that cancels it if it hasn't run yet.
|
|
17
|
+
*/
|
|
18
|
+
export function whenIdle(callback: () => void): () => void {
|
|
19
|
+
let cancelIdle: () => void = () => undefined;
|
|
20
|
+
function schedule() {
|
|
21
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
22
|
+
const handle = window.requestIdleCallback(callback, { timeout: IDLE_TIMEOUT_MS });
|
|
23
|
+
cancelIdle = () => window.cancelIdleCallback(handle);
|
|
24
|
+
} else {
|
|
25
|
+
const timer = setTimeout(callback, IDLE_FALLBACK_DELAY_MS);
|
|
26
|
+
cancelIdle = () => clearTimeout(timer);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (document.readyState === "complete") {
|
|
30
|
+
schedule();
|
|
31
|
+
} else {
|
|
32
|
+
window.addEventListener("load", schedule, { once: true });
|
|
33
|
+
}
|
|
34
|
+
return () => {
|
|
35
|
+
window.removeEventListener("load", schedule);
|
|
36
|
+
cancelIdle();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `true` when the user has asked the browser to save data (or is on a very slow connection): work that only speculates
|
|
41
|
+
* about what will be needed - prefetching - should not run then. */
|
|
42
|
+
export function shouldSaveData(): boolean {
|
|
43
|
+
const connection = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection;
|
|
44
|
+
return connection?.saveData === true || connection?.effectiveType === "slow-2g" || connection?.effectiveType === "2g";
|
|
45
|
+
}
|