@rapidmx/web-client 0.2.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/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- package/package.json +87 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import "../../styles/app.css";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
|
|
9
|
+
import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
|
|
10
|
+
import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
|
+
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
12
|
+
import ComposeProvider from "../mail/compose/ComposeContext.js";
|
|
13
|
+
import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
14
|
+
import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
|
|
15
|
+
import UserMenu from "./UserMenu.js";
|
|
16
|
+
export const APPS = [
|
|
17
|
+
{ id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
|
|
18
|
+
{ id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
|
|
19
|
+
{ id: "contacts", href: "/contacts", label: "Contacts", icon: HiOutlineUsers },
|
|
20
|
+
{ id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
|
|
21
|
+
];
|
|
22
|
+
/** The header title for every valid `active` value — a superset of `APPS`' own labels since `"settings"`
|
|
23
|
+
* has no rail icon (and so no `AppDef`) but still needs a header title. */
|
|
24
|
+
const ACTIVE_LABELS = {
|
|
25
|
+
mail: "Mail",
|
|
26
|
+
calendar: "Calendar",
|
|
27
|
+
contacts: "Contacts",
|
|
28
|
+
tasks: "Tasks",
|
|
29
|
+
settings: "Settings",
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
|
|
33
|
+
* switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
|
|
34
|
+
* client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
|
|
35
|
+
* Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
|
|
36
|
+
* the area to the right of the icon rail and below the header.
|
|
37
|
+
*/
|
|
38
|
+
export default function AppShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
|
|
39
|
+
const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
|
|
40
|
+
const { branding, iconSrc } = useBranding();
|
|
41
|
+
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
42
|
+
function handleSignOut() {
|
|
43
|
+
window.location.href = authServerUrl ?? "/";
|
|
44
|
+
}
|
|
45
|
+
async function handleStopImpersonating() {
|
|
46
|
+
setStoppingImpersonation(true);
|
|
47
|
+
try {
|
|
48
|
+
await stopImpersonating(impersonationBaseUrl ?? "");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Navigate either way: a failed call leaves the impersonator cookie (and this banner) exactly as
|
|
52
|
+
// they were, so there's nothing else useful to show — matching this app's other network-error
|
|
53
|
+
// handling, which surfaces via a full reload rather than an inline retry affordance.
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
window.location.href = "/admin";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!userUid) {
|
|
60
|
+
return _jsx("div", { className: "min-h-screen" });
|
|
61
|
+
}
|
|
62
|
+
return (_jsx(ComposeProvider, { children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), APPS.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
|
|
63
|
+
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
64
|
+
id === active
|
|
65
|
+
? "bg-primary/10 text-primary-dark"
|
|
66
|
+
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
67
|
+
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: APPS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: ACTIVE_LABELS[active] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showAdminLink: trusted, showSettingsLink: true })] }), _jsx("div", { className: "flex-1 flex min-h-0 pb-14 md:pb-0", children: children })] })] }), _jsx(BrandingFooter, { branding: branding })] }) }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`) — shared by every
|
|
4
|
+
* chrome in this project (`AppShell`, `AdminShell`, and the public `apps/book` pages) so the setting applies
|
|
5
|
+
* everywhere its own admin UI copy (`apps/admin/branding/index.tsx`) claims it does.
|
|
6
|
+
*/
|
|
7
|
+
export function BrandingHeader({ branding }) {
|
|
8
|
+
if (!branding?.headerHtml) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return _jsx("div", { dangerouslySetInnerHTML: { __html: branding.headerHtml } });
|
|
12
|
+
}
|
|
13
|
+
export function BrandingFooter({ branding }) {
|
|
14
|
+
if (!branding?.footerHtml) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return _jsx("div", { dangerouslySetInnerHTML: { __html: branding.footerHtml } });
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { autoProvisionMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
/**
|
|
12
|
+
* Rendered by each app's shell (Mail/Calendar/Contacts/Tasks) *instead of* the normal `AppShell`
|
|
13
|
+
* chrome (icon rail, header, folder tree, ...) when the caller has no mailbox — a full-screen,
|
|
14
|
+
* 404-style takeover, not a small message nested inside the rest of the client UI, since there's
|
|
15
|
+
* nothing else in the app for a mailbox-less caller to do yet. Attempts self-service
|
|
16
|
+
* auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
|
|
17
|
+
* for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
|
|
18
|
+
* (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
|
|
19
|
+
* unconditionally in that place: any failure (disabled, no registered username, the identity service
|
|
20
|
+
* unreachable) just falls back to the same plain "no mailbox" message this replaces.
|
|
21
|
+
*/
|
|
22
|
+
export default function MailboxProvisioning() {
|
|
23
|
+
const [status, setStatus] = useState("checking");
|
|
24
|
+
const [options, setOptions] = useState([]);
|
|
25
|
+
const [selected, setSelected] = useState("");
|
|
26
|
+
const [error, setError] = useState(null);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
autoProvisionMailbox()
|
|
29
|
+
.then((result) => {
|
|
30
|
+
if (result.status === "needs_selection") {
|
|
31
|
+
// `autoProvision()` only ever returns this status with a non-empty cross product of
|
|
32
|
+
// the caller's aliases and this server's configured domains (both are checked
|
|
33
|
+
// non-empty before it's built) — no fallback needed for an empty list here.
|
|
34
|
+
setOptions(result.options);
|
|
35
|
+
setSelected(result.options[0].primarySmtpAddress);
|
|
36
|
+
setStatus("needs_selection");
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// "created" or "existing" — either way the caller now has a mailbox; a full reload is
|
|
40
|
+
// this framework's own convention for picking up new server-side state (no client
|
|
41
|
+
// router to instead re-fetch just the shell's own data in place).
|
|
42
|
+
window.location.reload();
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.catch(() => setStatus("unavailable"));
|
|
46
|
+
}, []);
|
|
47
|
+
async function handleConfirm() {
|
|
48
|
+
// `selected` only ever takes a value from `options` itself (the initial default, or the
|
|
49
|
+
// <select>'s own onChange, which can only report one of its own rendered <option> values), so
|
|
50
|
+
// this is always found — no defensive fallback needed.
|
|
51
|
+
const option = options.find((o) => o.primarySmtpAddress === selected);
|
|
52
|
+
setStatus("creating");
|
|
53
|
+
setError(null);
|
|
54
|
+
try {
|
|
55
|
+
await autoProvisionMailbox({ alias: option.alias, domain: option.domain });
|
|
56
|
+
window.location.reload();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create your mailbox.");
|
|
60
|
+
setStatus("needs_selection");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let content;
|
|
64
|
+
if (status === "checking") {
|
|
65
|
+
content = _jsx("p", { className: "text-sm text-text-muted", children: "Setting up your mailbox\u2026" });
|
|
66
|
+
}
|
|
67
|
+
else if (status === "needs_selection" || status === "creating") {
|
|
68
|
+
const creating = status === "creating";
|
|
69
|
+
content = (_jsxs("div", { className: "w-full flex flex-col gap-3", children: [_jsx("p", { className: "text-sm text-text-muted text-center", children: "Choose your mailbox address to get started:" }), error && _jsx(Alert, { children: error }), _jsx("select", { "aria-label": "Mailbox address", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: selected, onChange: (e) => setSelected(e.target.value), disabled: creating, children: options.map((option) => (_jsx("option", { value: option.primarySmtpAddress, children: option.primarySmtpAddress }, option.primarySmtpAddress))) }), _jsx(Button, { type: "button", onClick: handleConfirm, loading: creating, disabled: creating, className: "!w-auto self-center", children: "Continue" })] }));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "No mailbox available" }), _jsx("p", { className: "text-sm text-text-muted", children: "Ask an administrator to create one for you." })] }));
|
|
73
|
+
}
|
|
74
|
+
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8 flex flex-col items-center gap-4 text-center", children: [_jsx("img", { src: "/images/wordmark.png", height: "128", alt: "" }), content] }) }));
|
|
75
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { formatProfileName, getMyProfile, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
|
|
8
|
+
function Avatar({ profile, initials, large }) {
|
|
9
|
+
const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
|
|
10
|
+
if (profile?.avatar) {
|
|
11
|
+
return (_jsx("img", { src: profile.avatar, alt: "", className: `${sizeClass} rounded-full object-cover shrink-0` }));
|
|
12
|
+
}
|
|
13
|
+
return (_jsx("span", { className: `${sizeClass} rounded-full bg-primary text-white font-semibold flex items-center justify-center shrink-0`, children: initials }));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The top-right "who am I" menu shared by `MailShell` and `AdminShell`: an avatar-button trigger that opens a
|
|
17
|
+
* dropdown showing the caller's avatar/name, an optional "Admin" link (trusted-role callers only), and
|
|
18
|
+
* "Sign Out". Name/avatar come from auth-server's own profile endpoint (`GET /api/profiles/me`) — this
|
|
19
|
+
* service has no local user directory (see `.claude/NOTES.md`) — and fall back to the bare uid/its first
|
|
20
|
+
* letter when unset or unreachable, which is a fully valid, expected state for most password-registered
|
|
21
|
+
* accounts (only OIDC sign-in currently populates a real avatar).
|
|
22
|
+
*/
|
|
23
|
+
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }) {
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const [profile, setProfile] = useState(undefined);
|
|
26
|
+
const containerRef = useRef(null);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!authServerUrl) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
getMyProfile(authServerUrl)
|
|
32
|
+
.then(setProfile)
|
|
33
|
+
.catch(() => undefined);
|
|
34
|
+
}, [authServerUrl]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!open) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
function handlePointerDown(e) {
|
|
40
|
+
if (containerRef.current && e.target instanceof Node && !containerRef.current.contains(e.target)) {
|
|
41
|
+
setOpen(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function handleKeyDown(e) {
|
|
45
|
+
if (e.key === "Escape") {
|
|
46
|
+
setOpen(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
document.addEventListener("mousedown", handlePointerDown);
|
|
50
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
51
|
+
return () => {
|
|
52
|
+
document.removeEventListener("mousedown", handlePointerDown);
|
|
53
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
54
|
+
};
|
|
55
|
+
}, [open]);
|
|
56
|
+
const name = formatProfileName(profile) ?? userUid;
|
|
57
|
+
const initials = profileInitials(profile, userUid);
|
|
58
|
+
return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { role: "menu", className: "absolute right-0 top-full mt-2 w-60 bg-surface border border-border rounded-md shadow-modal py-1.5 z-10", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showSettingsLink && (_jsx("a", { role: "menuitem", href: "/settings/auto-reply", className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showAdminLink && (_jsx("a", { role: "menuitem", href: "/admin", className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Admin" })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }))] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** The "By conversation" counterpart to the per-message list `apps/www/index.tsx` renders inline for
|
|
3
|
+
* "By date" — conversations are mailbox-wide (see `conversationsApi.ts`), so there's no per-folder
|
|
4
|
+
* empty/loading state to distinguish here beyond what the page itself already handles. */
|
|
5
|
+
export default function ConversationList({ conversations, selectedId, onSelect }) {
|
|
6
|
+
if (conversations.length === 0) {
|
|
7
|
+
return _jsx("p", { className: "p-4 text-sm text-text-muted", children: "No conversations in this mailbox." });
|
|
8
|
+
}
|
|
9
|
+
return (_jsx("ul", { children: conversations.map((conversation) => {
|
|
10
|
+
const unread = conversation.unreadCount > 0;
|
|
11
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onSelect(conversation), className: [
|
|
12
|
+
"w-full text-left px-4 py-3 border-b border-border",
|
|
13
|
+
conversation.conversationId === selectedId ? "bg-primary/10" : "hover:bg-surface-alt",
|
|
14
|
+
unread ? "font-semibold" : "",
|
|
15
|
+
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: conversation.participants.map((p) => p.displayName || p.address).join(", ") }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && _jsx("span", { "aria-label": "Has attachments", children: "\uD83D\uDCCE" })] })] }) }, conversation.conversationId));
|
|
16
|
+
}) }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { getMessage, listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import MessageDetailPane from "./MessageDetailPane.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
/**
|
|
12
|
+
* The merged, Gmail-style reading pane for "By conversation" mode — every message in the conversation
|
|
13
|
+
* fetched and stacked oldest-to-newest, all but the most recent collapsed to a one-line summary
|
|
14
|
+
* (sender + subject) until clicked. Reuses `MessageDetailPane` for each *expanded* message's own
|
|
15
|
+
* header/attachments/body-iframe rendering rather than re-implementing it; a collapsed row is its own
|
|
16
|
+
* lightweight summary, since mounting a full `MessageDetailPane` (and its iframe) for every message in
|
|
17
|
+
* a long thread up front would be wasteful.
|
|
18
|
+
*/
|
|
19
|
+
export default function ConversationThreadPane({ conversation, folders }) {
|
|
20
|
+
const [messages, setMessages] = useState({});
|
|
21
|
+
const [attachmentsByUid, setAttachmentsByUid] = useState({});
|
|
22
|
+
const [expandedUids, setExpandedUids] = useState(new Set());
|
|
23
|
+
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!conversation) {
|
|
27
|
+
setMessages({});
|
|
28
|
+
setExpandedUids(new Set());
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
setLoading(true);
|
|
32
|
+
setError(null);
|
|
33
|
+
Promise.all(conversation.messageUids.map((uid) => getMessage(uid)))
|
|
34
|
+
.then((loaded) => {
|
|
35
|
+
setMessages(Object.fromEntries(loaded.map((m) => [m.uid, m])));
|
|
36
|
+
// `messageUids` always has at least one entry — a `ConversationSummary` only ever
|
|
37
|
+
// exists because it was grouped from real messages (see
|
|
38
|
+
// `BaseMessageRoute.conversations()`).
|
|
39
|
+
const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
|
|
40
|
+
setExpandedUids(new Set([latestUid]));
|
|
41
|
+
})
|
|
42
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this conversation."))
|
|
43
|
+
.finally(() => setLoading(false));
|
|
44
|
+
}, [conversation?.conversationId]);
|
|
45
|
+
// Lazily loads attachments and marks-as-read only for messages the reader has actually expanded —
|
|
46
|
+
// mirrors `mailDetailHooks.ts`'s `useMessageAttachments`/`useMarkMessageRead`, reimplemented here
|
|
47
|
+
// (rather than called in a loop, which the rules of hooks don't allow) since a thread can expand
|
|
48
|
+
// more than one message at once, unlike the single-selected-message case those hooks were built for.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
for (const uid of expandedUids) {
|
|
51
|
+
const message = messages[uid];
|
|
52
|
+
if (!message)
|
|
53
|
+
continue;
|
|
54
|
+
if (message.hasAttachments && !attachmentsByUid[uid]) {
|
|
55
|
+
listAttachments(message.folderUid, uid)
|
|
56
|
+
.then((loaded) => setAttachmentsByUid((prev) => ({ ...prev, [uid]: loaded })))
|
|
57
|
+
.catch(() => {
|
|
58
|
+
// Best-effort, same as the mark-as-read catch below — `attachmentsByUid[uid] ??
|
|
59
|
+
// []` already renders no attachments while this stays unset, so there is nothing
|
|
60
|
+
// further to do on failure.
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (!message.flags.read) {
|
|
64
|
+
setMessageRead(message, true)
|
|
65
|
+
.then((updated) => setMessages((prev) => ({ ...prev, [uid]: updated })))
|
|
66
|
+
.catch(() => {
|
|
67
|
+
// Best-effort — matches `useMarkMessageRead`'s own doc comment.
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}, [expandedUids, messages]);
|
|
72
|
+
function toggleExpanded(uid) {
|
|
73
|
+
setExpandedUids((prev) => {
|
|
74
|
+
const next = new Set(prev);
|
|
75
|
+
if (next.has(uid)) {
|
|
76
|
+
next.delete(uid);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
next.add(uid);
|
|
80
|
+
}
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (!conversation) {
|
|
85
|
+
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a conversation to read it." });
|
|
86
|
+
}
|
|
87
|
+
if (loading) {
|
|
88
|
+
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading\u2026" });
|
|
89
|
+
}
|
|
90
|
+
if (error) {
|
|
91
|
+
return (_jsx("div", { className: "p-4 flex-1", children: _jsx(Alert, { children: error }) }));
|
|
92
|
+
}
|
|
93
|
+
return (_jsxs("div", { className: "flex-1 min-w-0 flex flex-col overflow-y-auto", children: [_jsxs("div", { className: "border-b border-border p-4", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight", children: conversation.subject || "(no subject)" }), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: [conversation.messageCount, " message", conversation.messageCount === 1 ? "" : "s"] })] }), conversation.messageUids.map((uid) => {
|
|
94
|
+
const message = messages[uid];
|
|
95
|
+
if (!message) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const expanded = expandedUids.has(uid);
|
|
99
|
+
if (!expanded) {
|
|
100
|
+
return (_jsxs("button", { type: "button", onClick: () => toggleExpanded(uid), className: [
|
|
101
|
+
"w-full text-left px-4 py-3 border-b border-border hover:bg-surface-alt",
|
|
102
|
+
message.flags.read ? "" : "font-semibold",
|
|
103
|
+
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleString() })] }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: message.bodyPreview })] }, uid));
|
|
104
|
+
}
|
|
105
|
+
return (_jsxs("div", { className: "border-b border-border", children: [_jsx("button", { type: "button", onClick: () => toggleExpanded(uid), className: "w-full text-left px-4 pt-2 text-xs text-text-muted hover:text-text", children: "Collapse" }), _jsx(MessageDetailPane, { message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folders.find((f) => f.uid === message.folderUid)?.type === "sent_items", onRecalled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), isOutbox: folders.find((f) => f.uid === message.folderUid)?.type === "outbox", draftsFolderUid: folders.find((f) => f.type === "drafts")?.uid,
|
|
106
|
+
// Unlike `apps/www/index.tsx`'s list-removal behavior, this patches the
|
|
107
|
+
// message in place (same as `onRecalled` above) rather than removing it from
|
|
108
|
+
// the conversation — `conversation.messageUids` is owned by the parent, not
|
|
109
|
+
// this component, so there's no membership list here to remove it from.
|
|
110
|
+
onScheduledSendCanceled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), isInbox: folders.find((f) => f.uid === message.folderUid)?.type === "inbox", onClassified: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), onReceiptHandled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })) })] }, uid));
|
|
111
|
+
})] }));
|
|
112
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { approveReceipt, attachmentContentUrl, cancelScheduledSend, classifyMessage, declineReceipt, recallMessage, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
10
|
+
import { useCompose } from "./compose/ComposeContext.js";
|
|
11
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
12
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
13
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
14
|
+
function formatBytes(bytes) {
|
|
15
|
+
if (bytes >= 1000000)
|
|
16
|
+
return `${(bytes / 1000000).toFixed(1)} MB`;
|
|
17
|
+
if (bytes >= 1000)
|
|
18
|
+
return `${(bytes / 1000).toFixed(1)} KB`;
|
|
19
|
+
return `${bytes} B`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
|
|
23
|
+
* Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
|
|
24
|
+
* the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
|
|
25
|
+
* a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
|
|
26
|
+
* site for how `message`/`attachments`/`isSentItems` are sourced.
|
|
27
|
+
*/
|
|
28
|
+
export default function MessageDetailPane({ message, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, isInbox, onClassified, onReceiptHandled, }) {
|
|
29
|
+
const { openCompose } = useCompose();
|
|
30
|
+
const [confirming, setConfirming] = useState(false);
|
|
31
|
+
const [recalling, setRecalling] = useState(false);
|
|
32
|
+
const [canceling, setCanceling] = useState(false);
|
|
33
|
+
const [error, setError] = useState(null);
|
|
34
|
+
// Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
|
|
35
|
+
// pane rather than inside a confirmation modal — the two flows never need to share one message.
|
|
36
|
+
const [cancelError, setCancelError] = useState(null);
|
|
37
|
+
const [classifying, setClassifying] = useState(false);
|
|
38
|
+
const [classifyError, setClassifyError] = useState(null);
|
|
39
|
+
const [alwaysForSender, setAlwaysForSender] = useState(false);
|
|
40
|
+
// Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
|
|
41
|
+
// `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
|
|
42
|
+
// wouldn't distinguish which row's buttons should show a loading state.
|
|
43
|
+
const [receiptBusy, setReceiptBusy] = useState(null);
|
|
44
|
+
const [receiptError, setReceiptError] = useState(null);
|
|
45
|
+
if (!message) {
|
|
46
|
+
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a message to read it." });
|
|
47
|
+
}
|
|
48
|
+
// Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
|
|
49
|
+
// once `message` is loaded (the early return above covers the only other state) — the non-null
|
|
50
|
+
// assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
|
|
51
|
+
function handleReply() {
|
|
52
|
+
openCompose({
|
|
53
|
+
mailboxUid: message.mailboxUid,
|
|
54
|
+
to: message.from.address,
|
|
55
|
+
subject: replySubject(message.subject),
|
|
56
|
+
quotedHtml: buildReplyQuote(message),
|
|
57
|
+
signatureContext: "reply_forward",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function handleReplyAll() {
|
|
61
|
+
const cc = message.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
|
|
62
|
+
openCompose({
|
|
63
|
+
mailboxUid: message.mailboxUid,
|
|
64
|
+
to: message.from.address,
|
|
65
|
+
cc: cc.join(", "),
|
|
66
|
+
subject: replySubject(message.subject),
|
|
67
|
+
quotedHtml: buildReplyQuote(message),
|
|
68
|
+
signatureContext: "reply_forward",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function handleForward() {
|
|
72
|
+
openCompose({
|
|
73
|
+
mailboxUid: message.mailboxUid,
|
|
74
|
+
subject: forwardSubject(message.subject),
|
|
75
|
+
quotedHtml: buildForwardQuote(message),
|
|
76
|
+
signatureContext: "reply_forward",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function handleRecall() {
|
|
80
|
+
// Only ever invoked from the confirmation modal below, which itself only renders once `message`
|
|
81
|
+
// is loaded (the early return above covers the only other state) — the non-null assertion
|
|
82
|
+
// reflects that real invariant, matching this codebase's established pattern for the same class
|
|
83
|
+
// of always-true-in-practice guard.
|
|
84
|
+
setRecalling(true);
|
|
85
|
+
setError(null);
|
|
86
|
+
try {
|
|
87
|
+
const updated = await recallMessage(message.uid);
|
|
88
|
+
setConfirming(false);
|
|
89
|
+
onRecalled?.(updated);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setRecalling(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Only ever invoked from the "Cancel" button below, which itself only renders once `message` is
|
|
99
|
+
// loaded and `isOutbox`/`message.scheduledSendTime` are both truthy — `draftsFolderUid` is required
|
|
100
|
+
// by that same rendering guard (see the prop's own doc comment), so the non-null assertion reflects
|
|
101
|
+
// a real invariant, matching `handleRecall`'s identical pattern just above.
|
|
102
|
+
async function handleCancelScheduledSend() {
|
|
103
|
+
setCanceling(true);
|
|
104
|
+
setCancelError(null);
|
|
105
|
+
try {
|
|
106
|
+
const updated = await cancelScheduledSend(message, draftsFolderUid);
|
|
107
|
+
onScheduledSendCanceled?.(updated);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
setCancelError(err instanceof ApiRequestError ? err.message : "Could not cancel this scheduled send.");
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
setCanceling(false);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Only ever invoked from the classification button below, which itself only renders once `message`
|
|
117
|
+
// is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
|
|
118
|
+
// `handleRecall`/`handleCancelScheduledSend` above.
|
|
119
|
+
async function handleClassify(classifyAs) {
|
|
120
|
+
setClassifying(true);
|
|
121
|
+
setClassifyError(null);
|
|
122
|
+
try {
|
|
123
|
+
const updated = await classifyMessage(message.uid, classifyAs, alwaysForSender);
|
|
124
|
+
onClassified?.(updated);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
setClassifying(false);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
|
|
134
|
+
// is loaded — same real invariant as every other handler above.
|
|
135
|
+
async function handleReceipt(type, action) {
|
|
136
|
+
setReceiptBusy(type);
|
|
137
|
+
setReceiptError(null);
|
|
138
|
+
try {
|
|
139
|
+
const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message.uid, type);
|
|
140
|
+
onReceiptHandled?.(updated);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
setReceiptBusy(null);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return (_jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("div", { className: "border-b border-border p-4", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block mb-2", children: "\u2190 Back to messages" })), _jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight", children: message.subject || "(no subject)" }), isSentItems &&
|
|
150
|
+
(message.recallRequestedAt ? (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Recall requested" })) : (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirming(true), children: "Recall this message" }))), isOutbox && message.scheduledSendTime && (_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("span", { className: "text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt", children: ["Scheduled for ", new Date(message.scheduledSendTime).toLocaleString()] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Cancel" })] }))] }), cancelError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: cancelError }) })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", message.from.displayName || message.from.address, " \u00B7", " ", new Date(message.receivedDate).toLocaleString()] }), _jsxs("p", { className: "text-sm text-text-muted", children: ["To ", message.recipients.map((r) => r.displayName || r.address).join(", ")] }), _jsxs("div", { className: "flex gap-2 mt-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleReply, children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleReplyAll, children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleForward, children: "Forward" })] }), isInbox && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2", children: [_jsx(Button, { type: "button", variant: "text", loading: classifying, disabled: classifying, onClick: () => handleClassify(message.inferenceClassification === "other" ? "focused" : "other"), children: message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other" }), _jsxs("label", { className: "flex items-center gap-1.5 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: alwaysForSender, onChange: (e) => setAlwaysForSender(e.target.checked) }), "Always for this sender"] })] })), classifyError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: classifyError }) })), ["delivery", "read"]
|
|
151
|
+
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
152
|
+
.map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [message.from.displayName || message.from.address, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: receiptError }) })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: attachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) }))] }), _jsx("iframe", { title: message.subject || "Message content", src: `/api/mail/messages/${encodeURIComponent(message.uid)}/content`, sandbox: "", className: "flex-1 w-full border-0" }, message.uid), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] })] }));
|
|
153
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { createContext, useContext, useMemo, useState } from "react";
|
|
7
|
+
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
8
|
+
import ComposeWindow from "./ComposeWindow.js";
|
|
9
|
+
const ComposeContext = createContext({ openCompose: () => undefined });
|
|
10
|
+
/** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
|
|
11
|
+
export function useCompose() {
|
|
12
|
+
return useContext(ComposeContext);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Owns every currently-open Compose window and renders them stacked bottom-right, Gmail-style — see
|
|
16
|
+
* `ComposeWindow`'s own doc comment for why this replaced the old dedicated `/compose` page. Mounted
|
|
17
|
+
* once in `AppShell`, so every webmail app (Mail/Calendar/Contacts/Tasks) shares the same instance:
|
|
18
|
+
* opening Compose from Contacts' "Email" action, for instance, overlays the window on top of whatever
|
|
19
|
+
* app is currently showing, exactly like opening it from Mail's own sidebar button.
|
|
20
|
+
*/
|
|
21
|
+
export default function ComposeProvider({ children }) {
|
|
22
|
+
const [sessions, setSessions] = useState([]);
|
|
23
|
+
const isMobile = useIsMobile();
|
|
24
|
+
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new" }) {
|
|
25
|
+
setSessions((prev) => [
|
|
26
|
+
...prev,
|
|
27
|
+
{
|
|
28
|
+
id: crypto.randomUUID(),
|
|
29
|
+
mailboxUid,
|
|
30
|
+
initialTo: to,
|
|
31
|
+
initialCc: cc,
|
|
32
|
+
initialSubject: subject,
|
|
33
|
+
initialQuotedHtml: quotedHtml,
|
|
34
|
+
signatureContext,
|
|
35
|
+
minimized: false,
|
|
36
|
+
},
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
function closeCompose(id) {
|
|
40
|
+
setSessions((prev) => prev.filter((s) => s.id !== id));
|
|
41
|
+
}
|
|
42
|
+
function toggleMinimize(id) {
|
|
43
|
+
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, minimized: !s.minimized } : s)));
|
|
44
|
+
}
|
|
45
|
+
const value = useMemo(() => ({ openCompose }), []);
|
|
46
|
+
// On mobile, a non-minimized `ComposeWindow` renders full-screen (see that component's own doc
|
|
47
|
+
// comment) — Gmail-style stacking of several full-screen overlays at once makes no sense there, so
|
|
48
|
+
// at most one non-minimized session is ever rendered: the most recently opened one. Minimized
|
|
49
|
+
// sessions are small chips regardless of device, so every one of those still renders — an earlier
|
|
50
|
+
// session becomes visible again (as its own chip, or full-screen if it's the new most-recent
|
|
51
|
+
// non-minimized one) once whatever's currently "on top" is closed or minimized.
|
|
52
|
+
const lastNonMinimizedId = isMobile ? [...sessions].reverse().find((s) => !s.minimized)?.id : undefined;
|
|
53
|
+
const visibleSessions = isMobile ? sessions.filter((s) => s.minimized || s.id === lastNonMinimizedId) : sessions;
|
|
54
|
+
return (_jsxs(ComposeContext.Provider, { value: value, children: [children, visibleSessions.length > 0 && (_jsx("div", { className: "fixed bottom-0 right-6 flex items-end gap-3 z-50", children: visibleSessions.map((session) => (_jsx(ComposeWindow, { session: session, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) }, session.id))) }))] }));
|
|
55
|
+
}
|