@rapidmx/web-client 0.16.0 → 0.17.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 +36 -28
- package/apps/admin/distribution-lists/new/index.tsx +3 -1
- package/apps/admin/domains/[uid].tsx +3 -1
- package/apps/admin/domains/new/index.tsx +3 -1
- package/apps/admin/escrow-scopes/[uid].tsx +3 -1
- package/apps/admin/escrow-scopes/new/index.tsx +3 -1
- package/apps/admin/index.tsx +3 -1
- package/apps/admin/mailboxes/[uid].tsx +3 -1
- package/apps/admin/mailboxes/new/index.tsx +3 -1
- package/apps/admin/transport-rules/new/index.tsx +3 -1
- package/apps/escrow/matters/new/index.tsx +3 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +4 -1
- package/apps/shared/components/admin/setup/SetupWizard.tsx +3 -1
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +2 -2
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +3 -2
- package/apps/shared/components/layout/AppShell.tsx +24 -43
- package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -0
- package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +19 -15
- package/apps/shared/components/mail/MenuButton.tsx +31 -11
- package/apps/shared/components/mail/MessageDetailPane.tsx +202 -4
- package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +37 -34
- package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
- package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
- package/apps/shared/components/mail/reading/messageExport.ts +59 -0
- package/apps/shared/components/mail/reading/printMessage.ts +99 -0
- package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +4 -2
- package/apps/shared/components/tasks/layout/TasksShell.tsx +3 -2
- package/apps/shared/keyboard/GlobalShortcuts.tsx +1 -1
- package/apps/shared/mail/folderOfType.ts +49 -0
- package/apps/shared/mail/permanentDelete.ts +160 -0
- package/apps/shared/mail/reportNotices.ts +66 -0
- package/apps/shared/mail/senderBlocking.ts +141 -0
- package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
- package/apps/shared/mail/usePermanentDelete.tsx +160 -0
- package/apps/shared/navigation/frameContext.tsx +1 -1
- package/apps/shared/navigation/index.ts +43 -0
- package/apps/shared/navigation/pageTitle.ts +19 -0
- package/apps/www/_shell.tsx +82 -0
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/contacts/[uid].tsx +6 -3
- package/apps/www/contacts/index.tsx +8 -3
- package/apps/www/index.tsx +146 -31
- package/apps/www/messages/[uid].tsx +8 -4
- package/apps/www/settings/appearance/index.tsx +5 -2
- package/apps/www/settings/auto-reply/index.tsx +5 -2
- package/apps/www/settings/blocked-senders/index.tsx +303 -0
- package/apps/www/settings/encryption/index.tsx +5 -2
- package/apps/www/settings/filters/[uid].tsx +5 -2
- package/apps/www/settings/filters/index.tsx +5 -2
- package/apps/www/settings/filters/new/index.tsx +30 -5
- package/apps/www/settings/labels/index.tsx +5 -2
- package/apps/www/settings/privacy/index.tsx +5 -2
- package/apps/www/settings/profile/index.tsx +5 -2
- package/apps/www/settings/read-receipts/index.tsx +5 -2
- package/apps/www/settings/sharing/index.tsx +5 -2
- package/apps/www/settings/signatures/[uid].tsx +5 -2
- package/apps/www/settings/signatures/index.tsx +5 -2
- package/apps/www/settings/signatures/new/index.tsx +6 -3
- package/apps/www/tasks/index.tsx +7 -2
- package/dist/apps/admin/distribution-lists/new/index.js +3 -1
- package/dist/apps/admin/domains/[uid].js +3 -1
- package/dist/apps/admin/domains/new/index.js +3 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
- package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
- package/dist/apps/admin/index.js +3 -1
- package/dist/apps/admin/mailboxes/[uid].js +3 -1
- package/dist/apps/admin/mailboxes/new/index.js +3 -1
- package/dist/apps/admin/transport-rules/new/index.js +3 -1
- package/dist/apps/escrow/matters/new/index.js +3 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +4 -1
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +3 -2
- package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
- package/dist/apps/shared/components/layout/AppShell.js +24 -33
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
- package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
- package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +9 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +10 -12
- package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
- package/dist/apps/shared/components/mail/MenuButton.js +15 -11
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +13 -9
- package/dist/apps/shared/components/mail/layout/MailShell.js +27 -33
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
- package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
- package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
- package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
- package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -2
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
- package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
- package/dist/apps/shared/mail/folderOfType.js +40 -0
- package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
- package/dist/apps/shared/mail/permanentDelete.js +127 -0
- package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
- package/dist/apps/shared/mail/reportNotices.js +47 -0
- package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
- package/dist/apps/shared/mail/senderBlocking.js +92 -0
- package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
- package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
- package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
- package/dist/apps/shared/navigation/index.d.ts +16 -0
- package/dist/apps/shared/navigation/index.js +34 -0
- package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
- package/dist/apps/shared/navigation/pageTitle.js +12 -0
- package/dist/apps/www/_shell.d.ts +26 -0
- package/dist/apps/www/_shell.js +31 -0
- package/dist/apps/www/calendar/index.d.ts +7 -2
- package/dist/apps/www/calendar/index.js +4 -2
- package/dist/apps/www/contacts/[uid].d.ts +14 -3
- package/dist/apps/www/contacts/[uid].js +5 -3
- package/dist/apps/www/contacts/index.d.ts +7 -2
- package/dist/apps/www/contacts/index.js +6 -4
- package/dist/apps/www/index.d.ts +7 -2
- package/dist/apps/www/index.js +117 -19
- package/dist/apps/www/messages/[uid].d.ts +14 -3
- package/dist/apps/www/messages/[uid].js +7 -5
- package/dist/apps/www/settings/appearance/index.d.ts +8 -2
- package/dist/apps/www/settings/appearance/index.js +4 -2
- package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
- package/dist/apps/www/settings/auto-reply/index.js +4 -2
- package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
- package/dist/apps/www/settings/blocked-senders/index.js +134 -0
- package/dist/apps/www/settings/encryption/index.d.ts +7 -2
- package/dist/apps/www/settings/encryption/index.js +4 -2
- package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
- package/dist/apps/www/settings/filters/[uid].js +4 -2
- package/dist/apps/www/settings/filters/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/index.js +4 -2
- package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/new/index.js +28 -5
- package/dist/apps/www/settings/labels/index.d.ts +7 -2
- package/dist/apps/www/settings/labels/index.js +4 -2
- package/dist/apps/www/settings/privacy/index.d.ts +7 -2
- package/dist/apps/www/settings/privacy/index.js +4 -2
- package/dist/apps/www/settings/profile/index.d.ts +7 -2
- package/dist/apps/www/settings/profile/index.js +4 -2
- package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
- package/dist/apps/www/settings/read-receipts/index.js +4 -2
- package/dist/apps/www/settings/sharing/index.d.ts +7 -2
- package/dist/apps/www/settings/sharing/index.js +4 -2
- package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
- package/dist/apps/www/settings/signatures/[uid].js +4 -2
- package/dist/apps/www/settings/signatures/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/index.js +4 -2
- package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/new/index.js +5 -3
- package/dist/apps/www/tasks/index.d.ts +7 -2
- package/dist/apps/www/tasks/index.js +5 -3
- package/package.json +4 -2
- package/apps/shared/navigation/AppRouter.tsx +0 -300
- package/apps/shared/navigation/idle.ts +0 -45
- package/apps/shared/navigation/routerContext.tsx +0 -83
- package/apps/shared/navigation/routes.ts +0 -72
- package/apps/www/_routedPage.tsx +0 -24
- package/apps/www/_routes.ts +0 -37
- package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
- package/dist/apps/shared/navigation/AppRouter.js +0 -242
- package/dist/apps/shared/navigation/idle.d.ts +0 -14
- package/dist/apps/shared/navigation/idle.js +0 -43
- package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
- package/dist/apps/shared/navigation/routerContext.js +0 -56
- package/dist/apps/shared/navigation/routes.d.ts +0 -32
- package/dist/apps/shared/navigation/routes.js +0 -37
- package/dist/apps/www/_routedPage.d.ts +0 -12
- package/dist/apps/www/_routedPage.js +0 -19
- package/dist/apps/www/_routes.d.ts +0 -11
- package/dist/apps/www/_routes.js +0 -31
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
6
|
+
|
|
7
|
+
/** The name the deployment goes by in the browser tab: its branding title, else its company name, else RapidMX. */
|
|
8
|
+
export function brandName(branding?: Pick<Branding, "title" | "companyName"> | null): string {
|
|
9
|
+
return branding?.title || branding?.companyName || "RapidMX";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `title` export of a webmail page: `export const title = pageTitle("Calendar")`. `@rapidrest/react` renders it into the
|
|
14
|
+
* document's `<title>` on the server - so the tab is right from the first byte, and a crawler sees it - and sends it with every
|
|
15
|
+
* client navigation, so the tab follows the page. `label` is the app's name (`Acme: Calendar`), the same for all of an app's pages.
|
|
16
|
+
*/
|
|
17
|
+
export function pageTitle(label: string): (props: { branding?: Pick<Branding, "title" | "companyName"> | null }) => string {
|
|
18
|
+
return ({ branding }) => `${brandName(branding)}: ${label}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { PropsWithChildren, useCallback, useMemo, useState } from "react";
|
|
6
|
+
import { useNavigationEffects, useRouter } from "@rapidrest/react/client";
|
|
7
|
+
import { AppChrome, type AppShellActive, type AppShellProps } from "../shared/components/layout/AppShell.js";
|
|
8
|
+
import { AppFrameContext, type AppFrameContextValue } from "../shared/navigation/frameContext.js";
|
|
9
|
+
import { titleWithUnread } from "../shared/mail/useUnreadTitle.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The webmail's persistent app frame: `@rapidrest/react`'s app shell (`_shell.tsx`), rendered around every page of `apps/www` -
|
|
13
|
+
* on the server, in the same tree the browser hydrates - and kept mounted while the router swaps the pages inside it, so that
|
|
14
|
+
* moving between Mail, Calendar, Contacts, Tasks and Settings replaces only the page. It is what holds the state that must outlive
|
|
15
|
+
* a page: the compose windows in progress, the unlock prompt, the idle-key timer, the sign-out listener, the mail connection (one
|
|
16
|
+
* push socket for every app), the user menu and the notification pop-ups (all `AppChrome`'s).
|
|
17
|
+
*
|
|
18
|
+
* The props are the current page's - `WwwRoute.fetchProps()`'s, the same for every page: the user, the auth-server URL, the
|
|
19
|
+
* branding, the plugin navigation, the impersonation state and the appearance - and are fetched again on every navigation, so a
|
|
20
|
+
* change of any of them (a plugin installed, the branding edited) shows on the next page. A page still renders its own shell
|
|
21
|
+
* (`MailShell`, `CalendarShell`, ...) with `AppShell` inside it as it always did; inside this frame that renders only its children.
|
|
22
|
+
*
|
|
23
|
+
* What a page load did by itself and the router now does after each navigation - focus moves to the content, the window scrolls to
|
|
24
|
+
* the top, a screen reader is told the page's title - is configured here (`useNavigationEffects()`); the tab's title is each
|
|
25
|
+
* page's `title` export (`pageTitle()`). `busy` is the router's pending state: the content is `aria-busy` while the next page
|
|
26
|
+
* loads and the old one is still on screen.
|
|
27
|
+
*/
|
|
28
|
+
export type WwwShellProps = PropsWithChildren<Omit<AppShellProps, "active">>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The app-rail entry (or `"settings"`, which has none) that a route highlights: the first segment of its template names the
|
|
32
|
+
* app. Anything else - Mail's `/` and `/messages/:uid` - is Mail.
|
|
33
|
+
*/
|
|
34
|
+
export function activeAppOf(route: string): AppShellActive {
|
|
35
|
+
const app = route.split("/")[1];
|
|
36
|
+
return app === "calendar" || app === "contacts" || app === "tasks" || app === "settings" ? app : "mail";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function WwwShell({
|
|
40
|
+
children,
|
|
41
|
+
userUid,
|
|
42
|
+
authServerUrl,
|
|
43
|
+
impersonating,
|
|
44
|
+
impersonationBaseUrl,
|
|
45
|
+
trusted,
|
|
46
|
+
trustedRoles,
|
|
47
|
+
pluginNav,
|
|
48
|
+
branding,
|
|
49
|
+
appearance,
|
|
50
|
+
}: WwwShellProps) {
|
|
51
|
+
const { route, pending } = useRouter();
|
|
52
|
+
const [takeovers, setTakeovers] = useState(0);
|
|
53
|
+
const enterTakeover = useCallback(() => {
|
|
54
|
+
setTakeovers((n) => n + 1);
|
|
55
|
+
return () => setTakeovers((n) => n - 1);
|
|
56
|
+
}, []);
|
|
57
|
+
const frame = useMemo<AppFrameContextValue>(() => ({ enterTakeover }), [enterTakeover]);
|
|
58
|
+
// The content region is what a page load would have started at: focus it (it is only focusable by script), start at the top, and say the
|
|
59
|
+
// page's title - without the unread count that the tab's title carries in front of it.
|
|
60
|
+
useNavigationEffects({ focus: "#app-content", scroll: "top", announce: ({ title }) => titleWithUnread(title, 0) });
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<AppFrameContext.Provider value={frame}>
|
|
64
|
+
<AppChrome
|
|
65
|
+
active={activeAppOf(route)}
|
|
66
|
+
busy={pending}
|
|
67
|
+
hideChrome={takeovers > 0}
|
|
68
|
+
userUid={userUid}
|
|
69
|
+
authServerUrl={authServerUrl}
|
|
70
|
+
impersonating={impersonating}
|
|
71
|
+
impersonationBaseUrl={impersonationBaseUrl}
|
|
72
|
+
trusted={trusted}
|
|
73
|
+
trustedRoles={trustedRoles}
|
|
74
|
+
pluginNav={pluginNav}
|
|
75
|
+
branding={branding}
|
|
76
|
+
appearance={appearance}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</AppChrome>
|
|
80
|
+
</AppFrameContext.Provider>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
5
|
+
import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
6
6
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
8
8
|
import { HiOutlineBars3, HiOutlinePlus } from "react-icons/hi2";
|
|
@@ -575,4 +575,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
|
|
|
575
575
|
);
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
-
export default
|
|
578
|
+
export default CalendarPage;
|
|
579
|
+
|
|
580
|
+
/** The tab's title: `Brand: Calendar` (see `pageTitle()`). */
|
|
581
|
+
export const title = pageTitle("Calendar");
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
6
|
-
import { useNavigate } from "../../shared/navigation/
|
|
5
|
+
import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
6
|
+
import { useNavigate } from "../../shared/navigation/index.js";
|
|
7
7
|
import React, { useEffect, useState } from "react";
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
9
|
import { Contact, deleteContact, getContact } from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
@@ -106,4 +106,7 @@ function ContactDetailContent({ uid }: { uid: string }) {
|
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
export default
|
|
109
|
+
export default ContactDetailPage;
|
|
110
|
+
|
|
111
|
+
/** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
|
|
112
|
+
export const title = pageTitle("Contacts");
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
6
|
-
import { useNavigate } from "../../shared/navigation/
|
|
5
|
+
import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
6
|
+
import { useNavigate } from "../../shared/navigation/index.js";
|
|
7
7
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
9
|
import {
|
|
@@ -536,6 +536,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
536
536
|
{mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
|
|
537
537
|
<a
|
|
538
538
|
href={`/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`}
|
|
539
|
+
data-router-shallow=""
|
|
540
|
+
data-router-prefetch="false"
|
|
539
541
|
className="font-medium text-primary-dark hover:underline"
|
|
540
542
|
>
|
|
541
543
|
View that mailbox’s contacts
|
|
@@ -590,4 +592,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
590
592
|
);
|
|
591
593
|
}
|
|
592
594
|
|
|
593
|
-
export default
|
|
595
|
+
export default ContactsPage;
|
|
596
|
+
|
|
597
|
+
/** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
|
|
598
|
+
export const title = pageTitle("Contacts");
|
package/apps/www/index.tsx
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
6
|
-
import { useNavigate } from "../shared/navigation/
|
|
7
|
-
import { whenIdle } from "
|
|
5
|
+
import { pageTitle } from "../shared/navigation/pageTitle.js";
|
|
6
|
+
import { useNavigate } from "../shared/navigation/index.js";
|
|
7
|
+
import { whenIdle } from "@rapidrest/react/client";
|
|
8
8
|
import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
|
|
9
9
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
10
10
|
import { createPortal } from "react-dom";
|
|
@@ -76,6 +76,10 @@ import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js"
|
|
|
76
76
|
import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
|
|
77
77
|
import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
78
78
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
79
|
+
import EmptyFolderBar from "../shared/components/mail/EmptyFolderBar.js";
|
|
80
|
+
import { EMPTIABLE_FOLDER_TYPES } from "../shared/mail/permanentDelete.js";
|
|
81
|
+
import { usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
|
|
82
|
+
import { useMailboxUpdateAccess } from "../shared/mail/useMailboxUpdateAccess.js";
|
|
79
83
|
import {
|
|
80
84
|
CONVERSATION_SORT_NOTE,
|
|
81
85
|
CONVERSATION_SORT_UNAVAILABLE,
|
|
@@ -538,7 +542,7 @@ function InboxPage(props: MailShellProps) {
|
|
|
538
542
|
}
|
|
539
543
|
|
|
540
544
|
function InboxContent({ userUid }: { userUid?: string }) {
|
|
541
|
-
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
|
|
545
|
+
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, folderCountOf, mobileSearchSlot } = useMailShell();
|
|
542
546
|
const isMobile = useIsMobile();
|
|
543
547
|
const navigate = useNavigate();
|
|
544
548
|
const { requestUnlock } = useUnlockPrompt();
|
|
@@ -581,6 +585,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
581
585
|
// or it would act on a selection that is still arriving.
|
|
582
586
|
const [resolvingSelection, setResolvingSelection] = useState(0);
|
|
583
587
|
const [bulkBusy, setBulkBusy] = useState(false);
|
|
588
|
+
// Delete on what is already in Deleted Items, and Empty folder: confirmed, then done for good (see `usePermanentDelete()`).
|
|
589
|
+
const permanent = usePermanentDelete();
|
|
584
590
|
// Bumped to force the list effect below to re-run - a bulk update is deliberately neither atomic nor
|
|
585
591
|
// all-or-nothing (see `bulkUpdateMessages()`), so a rejection means refetching rather than guessing
|
|
586
592
|
// which half of the selection actually landed.
|
|
@@ -640,6 +646,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
640
646
|
// Always set once this page renders: a caller with no mailbox at all gets `MailboxProvisioning` from the shell instead.
|
|
641
647
|
const activeMailboxUid = (mailboxUid ?? primaryMailboxUid(mailboxes, userUid))!;
|
|
642
648
|
const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
|
|
649
|
+
// False for a mailbox shared with the reader view-only: Empty folder is not offered there (the server would refuse it).
|
|
650
|
+
const mailboxWritable = useMailboxUpdateAccess(mailboxes.find((mb) => mb.uid === activeMailboxUid));
|
|
643
651
|
// The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
|
|
644
652
|
// reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
|
|
645
653
|
// already uses the remembered arrangement rather than fetching the default one and immediately
|
|
@@ -2014,6 +2022,50 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2014
2022
|
return runBulkAction((moving) => moveToFolderOfType(moving, type, name), true, chosen);
|
|
2015
2023
|
}
|
|
2016
2024
|
|
|
2025
|
+
/**
|
|
2026
|
+
* Permanently deletes `chosen` - messages that are already in Deleted Items - once the reader has confirmed it (`usePermanentDelete()`
|
|
2027
|
+
* asks, deletes, tells the badges and says what happened). The rows that went leave the list, and selection and advance behave as for
|
|
2028
|
+
* Archive; a conversation list is reloaded, as after every bulk action on it, and so is a flat one when the server refused some
|
|
2029
|
+
* (those stay listed, and the reload shows what is really there). Resolves whether anything was deleted.
|
|
2030
|
+
*/
|
|
2031
|
+
async function purgeChosen(chosen: Message[]): Promise<boolean> {
|
|
2032
|
+
const outcome = await permanent.requestPermanentDelete(chosen);
|
|
2033
|
+
if (!outcome) {
|
|
2034
|
+
return false;
|
|
2035
|
+
}
|
|
2036
|
+
if (asConversations) {
|
|
2037
|
+
setSelectedConversationIds(new Set());
|
|
2038
|
+
setConversationMessagesById({});
|
|
2039
|
+
setRefreshKey((n) => n + 1);
|
|
2040
|
+
} else {
|
|
2041
|
+
removeListedMessages(new Set(outcome.deleted.map((m) => m.uid)));
|
|
2042
|
+
if (outcome.failed.length > 0) {
|
|
2043
|
+
setRefreshKey((n) => n + 1);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
setSelectedUids(new Set());
|
|
2047
|
+
return outcome.deleted.length > 0;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/** Empty folder: permanently deletes everything in the folder being listed (Deleted Items or Junk Email) after the reader has confirmed. */
|
|
2051
|
+
async function emptyListedFolder(folder: Folder, count: number | undefined) {
|
|
2052
|
+
const outcome = await permanent.requestEmptyFolder({ uid: folder.uid, name: folder.name }, count);
|
|
2053
|
+
if (!outcome) {
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
if (outcome.emptied) {
|
|
2057
|
+
setMessages([]);
|
|
2058
|
+
setConversations([]);
|
|
2059
|
+
}
|
|
2060
|
+
setSelectedUid(null);
|
|
2061
|
+
setOpenThread(null);
|
|
2062
|
+
setSelectedUids(new Set());
|
|
2063
|
+
setSelectedConversationIds(new Set());
|
|
2064
|
+
setConversationMessagesById({});
|
|
2065
|
+
// What is really left - after a partial delete, or one that failed outright - is whatever the server lists.
|
|
2066
|
+
setRefreshKey((n) => n + 1);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2017
2069
|
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
2018
2070
|
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
2019
2071
|
// ---- implementation. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
|
|
@@ -2250,6 +2302,11 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2250
2302
|
return mailboxFolders.flatMap((mf) => mf.folders).find((f) => f.uid === folderUidOfMessage)?.type;
|
|
2251
2303
|
}
|
|
2252
2304
|
|
|
2305
|
+
/** Whether a message is in its mailbox's Deleted Items - where Delete is the permanent one. */
|
|
2306
|
+
function inDeletedItems(message: Message): boolean {
|
|
2307
|
+
return folderTypeOf(message.folderUid) === "deleted_items";
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2253
2310
|
/** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
|
|
2254
2311
|
function focusRow(uid: string) {
|
|
2255
2312
|
// Only called from a key press, when the list is on screen.
|
|
@@ -2314,17 +2371,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2314
2371
|
}
|
|
2315
2372
|
|
|
2316
2373
|
/**
|
|
2317
|
-
*
|
|
2318
|
-
*
|
|
2319
|
-
*
|
|
2374
|
+
* What the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only the messages `applies`
|
|
2375
|
+
* accepts (Mark read leaves out what is already read: nothing to do then). Nothing (an empty list) while another bulk action or a
|
|
2376
|
+
* permanent delete is on the wire, like the bar's buttons, or when the open conversation could not be loaded.
|
|
2320
2377
|
*/
|
|
2321
|
-
async function
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
applies: (message: Message) => boolean = () => true,
|
|
2325
|
-
) {
|
|
2326
|
-
if (bulkBusy || resolvingSelection > 0) {
|
|
2327
|
-
return;
|
|
2378
|
+
async function keyboardTargets(applies: (message: Message) => boolean = () => true): Promise<Message[]> {
|
|
2379
|
+
if (bulkBusy || resolvingSelection > 0 || permanent.busy) {
|
|
2380
|
+
return [];
|
|
2328
2381
|
}
|
|
2329
2382
|
let chosen: Message[];
|
|
2330
2383
|
try {
|
|
@@ -2335,33 +2388,61 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2335
2388
|
: [selected!];
|
|
2336
2389
|
} catch (err) {
|
|
2337
2390
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
2338
|
-
return;
|
|
2391
|
+
return [];
|
|
2339
2392
|
}
|
|
2340
|
-
|
|
2393
|
+
return chosen.filter(applies);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** Closes the open thread (and the selection in it) - what a delete of the whole conversation leaves nothing to show of. */
|
|
2397
|
+
function closeOpenThread() {
|
|
2398
|
+
setOpenThread(null);
|
|
2399
|
+
setSelectedUid(null);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/** Runs a bulk action on `chosen` (from `keyboardTargets()`), closing the open thread after a move away from it. */
|
|
2403
|
+
async function actOnKeyboardTargets(chosen: Message[], action: (chosen: Message[]) => Promise<Message[]>, removesRows: boolean) {
|
|
2341
2404
|
if (chosen.length === 0) {
|
|
2342
2405
|
return;
|
|
2343
2406
|
}
|
|
2344
2407
|
const succeeded = await runBulkAction(action, removesRows, chosen);
|
|
2345
2408
|
if (succeeded && removesRows && threadPane && !selectMode) {
|
|
2346
|
-
|
|
2347
|
-
|
|
2409
|
+
closeOpenThread();
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/** Runs a bulk action on what the keyboard acts on (`keyboardTargets()`). */
|
|
2414
|
+
async function runOnKeyboardTargets(
|
|
2415
|
+
action: (chosen: Message[]) => Promise<Message[]>,
|
|
2416
|
+
removesRows: boolean,
|
|
2417
|
+
applies: (message: Message) => boolean = () => true,
|
|
2418
|
+
) {
|
|
2419
|
+
await actOnKeyboardTargets(await keyboardTargets(applies), action, removesRows);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/**
|
|
2423
|
+
* Delete, from the keyboard: what is in Deleted Items is permanently deleted (after a confirmation), anything else moves to its mailbox's
|
|
2424
|
+
* Deleted Items - as the selection bar's Delete does. A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2425
|
+
*/
|
|
2426
|
+
async function deleteFromKeyboard() {
|
|
2427
|
+
const targets = await keyboardTargets();
|
|
2428
|
+
if (targets.length > 0 && targets.every(inDeletedItems)) {
|
|
2429
|
+
if ((await purgeChosen(targets)) && threadPane && !selectMode) {
|
|
2430
|
+
closeOpenThread();
|
|
2431
|
+
}
|
|
2432
|
+
return;
|
|
2348
2433
|
}
|
|
2434
|
+
await actOnKeyboardTargets(
|
|
2435
|
+
targets.filter((message) => !inDeletedItems(message)),
|
|
2436
|
+
(chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"),
|
|
2437
|
+
true,
|
|
2438
|
+
);
|
|
2349
2439
|
}
|
|
2350
2440
|
|
|
2351
2441
|
useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
|
|
2352
2442
|
useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
|
|
2353
2443
|
useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
|
|
2354
2444
|
useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
|
|
2355
|
-
useShortcut(
|
|
2356
|
-
SHORTCUTS.mail.delete,
|
|
2357
|
-
() =>
|
|
2358
|
-
void runOnKeyboardTargets(
|
|
2359
|
-
(chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"),
|
|
2360
|
-
true,
|
|
2361
|
-
(message) => folderTypeOf(message.folderUid) !== "deleted_items",
|
|
2362
|
-
),
|
|
2363
|
-
{ enabled: keyboardActions && keyboardTargetExists },
|
|
2364
|
-
);
|
|
2445
|
+
useShortcut(SHORTCUTS.mail.delete, () => void deleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
|
|
2365
2446
|
useShortcut(
|
|
2366
2447
|
SHORTCUTS.mail.markRead,
|
|
2367
2448
|
() =>
|
|
@@ -2432,6 +2513,20 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2432
2513
|
crossMailbox && selectedMessages.some((m) => m.mailboxUid !== activeMailboxUid)
|
|
2433
2514
|
? "Labels can only be applied here to messages in the open mailbox"
|
|
2434
2515
|
: undefined;
|
|
2516
|
+
// Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
|
|
2517
|
+
// else for the folder being viewed, so the button says so before anything is ticked.
|
|
2518
|
+
const deletesPermanently =
|
|
2519
|
+
selectedMessages.length > 0 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
|
|
2520
|
+
// Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email - not for search results, nor for the "All
|
|
2521
|
+
// mailboxes" views, which have no one folder to empty.
|
|
2522
|
+
const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
|
|
2523
|
+
// How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
|
|
2524
|
+
const emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
|
|
2525
|
+
const emptyDisabledReason = !mailboxWritable
|
|
2526
|
+
? "This mailbox is shared with you view-only"
|
|
2527
|
+
: listedRowCount === 0 && emptiableCount === undefined
|
|
2528
|
+
? "This folder is already empty"
|
|
2529
|
+
: undefined;
|
|
2435
2530
|
|
|
2436
2531
|
if (!folderUid && !aggregateFolderType) {
|
|
2437
2532
|
// `MailShell` never renders this component at all until a mailbox is resolved (see its own
|
|
@@ -2492,8 +2587,14 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2492
2587
|
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
|
|
2493
2588
|
}}
|
|
2494
2589
|
onReportJunk={() => void moveSelectionToType("junk", "Junk Email")}
|
|
2495
|
-
onDelete={() =>
|
|
2496
|
-
|
|
2590
|
+
onDelete={() =>
|
|
2591
|
+
void (deletesPermanently
|
|
2592
|
+
? purgeChosen(selectedMessages)
|
|
2593
|
+
: // A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2594
|
+
moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m))))
|
|
2595
|
+
}
|
|
2596
|
+
deletesPermanently={deletesPermanently}
|
|
2597
|
+
busy={bulkBusy || resolvingSelection > 0 || permanent.busy}
|
|
2497
2598
|
/>
|
|
2498
2599
|
) : (
|
|
2499
2600
|
<MailListToolbar
|
|
@@ -2539,6 +2640,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2539
2640
|
)}
|
|
2540
2641
|
{!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
|
|
2541
2642
|
{isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot)}
|
|
2643
|
+
{emptiableFolder && !selectMode && (
|
|
2644
|
+
<EmptyFolderBar
|
|
2645
|
+
folderName={emptiableFolder.name}
|
|
2646
|
+
count={emptiableCount}
|
|
2647
|
+
disabled={!!emptyDisabledReason || permanent.busy || bulkBusy}
|
|
2648
|
+
disabledReason={emptyDisabledReason}
|
|
2649
|
+
onEmpty={() => void emptyListedFolder(emptiableFolder, emptiableCount)}
|
|
2650
|
+
/>
|
|
2651
|
+
)}
|
|
2652
|
+
{permanent.dialog}
|
|
2542
2653
|
{/* Two tabs, as Outlook has: Focused and Other. There is no "All" tab - the whole Inbox is
|
|
2543
2654
|
still one pick away, in the Filter menu, which is where every other named filter lives
|
|
2544
2655
|
and the only place that can show which of them is really in force. A stored `all` (or
|
|
@@ -2822,6 +2933,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2822
2933
|
// from the list rather than patched in place.
|
|
2823
2934
|
removeListedMessage(updated.uid);
|
|
2824
2935
|
}}
|
|
2936
|
+
onChanged={patchListedMessage}
|
|
2825
2937
|
labels={labels}
|
|
2826
2938
|
onLabelsChanged={patchListedMessage}
|
|
2827
2939
|
onLabelCreated={(label) =>
|
|
@@ -2844,4 +2956,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2844
2956
|
);
|
|
2845
2957
|
}
|
|
2846
2958
|
|
|
2847
|
-
export default
|
|
2959
|
+
export default InboxPage;
|
|
2960
|
+
|
|
2961
|
+
/** The tab's title: `Brand: Mail` (see `pageTitle()`). */
|
|
2962
|
+
export const title = pageTitle("Mail");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
5
|
+
import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
6
6
|
import React, { useEffect, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { Message, getMessage } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
@@ -11,7 +11,7 @@ import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHook
|
|
|
11
11
|
import { useMarkMessageRead } from "../../shared/mail/useMarkMessageRead.js";
|
|
12
12
|
import MailShell, { MailShellProps, useMailShell } from "../../shared/components/mail/layout/MailShell.js";
|
|
13
13
|
import { LazyConversationThreadPane, LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
|
|
14
|
-
import {
|
|
14
|
+
import { useSearchParams } from "@rapidrest/react/client";
|
|
15
15
|
import { ReadingPaneSkeleton } from "../../shared/components/mail/reading/MessageCard.js";
|
|
16
16
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
17
17
|
|
|
@@ -35,7 +35,7 @@ function MessageDetailContent({ uid }: { uid: string }) {
|
|
|
35
35
|
const [loading, setLoading] = useState(true);
|
|
36
36
|
const [error, setError] = useState<string | null>(null);
|
|
37
37
|
const [labels, setLabels] = useState<Label[]>([]);
|
|
38
|
-
const conversationId: string | null =
|
|
38
|
+
const conversationId: string | null = useSearchParams()[0].get("conversation");
|
|
39
39
|
|
|
40
40
|
useEffect(() => {
|
|
41
41
|
setLoading(true);
|
|
@@ -125,6 +125,7 @@ function MessageDetailContent({ uid }: { uid: string }) {
|
|
|
125
125
|
onFolderCreated={onFolderCreated}
|
|
126
126
|
onScheduledSendCanceled={setMessage}
|
|
127
127
|
onArchived={setMessage}
|
|
128
|
+
onChanged={setMessage}
|
|
128
129
|
labels={labels}
|
|
129
130
|
onLabelsChanged={setMessage}
|
|
130
131
|
onLabelCreated={(label) => setLabels((prev) => [...prev, label])}
|
|
@@ -132,4 +133,7 @@ function MessageDetailContent({ uid }: { uid: string }) {
|
|
|
132
133
|
);
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
export default
|
|
136
|
+
export default MessageDetailPage;
|
|
137
|
+
|
|
138
|
+
/** The tab's title: `Brand: Mail` (see `pageTitle()`). */
|
|
139
|
+
export const title = pageTitle("Mail");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
5
|
+
import { pageTitle } from "../../../shared/navigation/pageTitle.js";
|
|
6
6
|
import React from "react";
|
|
7
7
|
import SettingsShell, { SettingsShellProps } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
8
8
|
import AppearanceForm from "../../../shared/appearance/AppearanceForm.js";
|
|
@@ -18,4 +18,7 @@ function SettingsAppearancePage(props: SettingsAppearancePageProps) {
|
|
|
18
18
|
);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export default
|
|
21
|
+
export default SettingsAppearancePage;
|
|
22
|
+
|
|
23
|
+
/** The tab's title: `Brand: Settings` (see `pageTitle()`). */
|
|
24
|
+
export const title = pageTitle("Settings");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
5
|
+
import { pageTitle } from "../../../shared/navigation/pageTitle.js";
|
|
6
6
|
import React, { FormEvent, useState } from "react";
|
|
7
7
|
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
8
8
|
import { updateMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
@@ -130,4 +130,7 @@ function AutoReplyContent() {
|
|
|
130
130
|
);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
export default
|
|
133
|
+
export default SettingsAutoReplyPage;
|
|
134
|
+
|
|
135
|
+
/** The tab's title: `Brand: Settings` (see `pageTitle()`). */
|
|
136
|
+
export const title = pageTitle("Settings");
|