@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,83 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useRef } from "react";
|
|
6
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
7
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
8
|
+
import { TextStyleKit } from "@tiptap/extension-text-style";
|
|
9
|
+
import TextAlign from "@tiptap/extension-text-align";
|
|
10
|
+
import Highlight from "@tiptap/extension-highlight";
|
|
11
|
+
import Image from "@tiptap/extension-image";
|
|
12
|
+
import { TableKit } from "@tiptap/extension-table";
|
|
13
|
+
import Placeholder from "@tiptap/extension-placeholder";
|
|
14
|
+
import ComposeToolbar from "./ComposeToolbar.js";
|
|
15
|
+
|
|
16
|
+
export interface RichTextEditorProps {
|
|
17
|
+
value: string;
|
|
18
|
+
onChange: (value: string) => void;
|
|
19
|
+
height?: string;
|
|
20
|
+
/** When true, the editor fills its parent's height via flexbox instead of a fixed `height` — for
|
|
21
|
+
* embedding inside a flex-column container of variable height (the floating Compose window) where
|
|
22
|
+
* a literal pixel height can't be computed up front. `height` is ignored when this is set. */
|
|
23
|
+
fill?: boolean;
|
|
24
|
+
/** Passed straight through to `ComposeToolbar` — see its own doc comment on this prop. */
|
|
25
|
+
onUploadImage: (file: File) => Promise<string | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A WYSIWYG rich-text editor for the Compose window's message body — replaces `MonacoHtmlEditor` (an HTML
|
|
30
|
+
* *source* editor) with a real Outlook-Home-tab-style formatting experience, built on TipTap/ProseMirror.
|
|
31
|
+
* Used by `ComposeWindow` (the floating overlay, not a dedicated page — see `ComposeContext.tsx`'s doc
|
|
32
|
+
* comment).
|
|
33
|
+
*
|
|
34
|
+
* `value` seeds the editor's initial content only — like `MonacoHtmlEditor`, this never re-syncs from a
|
|
35
|
+
* later `value` prop change; TipTap owns its own document once created. Live edits flow out as HTML (via
|
|
36
|
+
* `editor.getHTML()`) through `onChange`.
|
|
37
|
+
*
|
|
38
|
+
* `immediatelyRender: false` is required for this framework's SSR: the page embedding `ComposeWindow` is
|
|
39
|
+
* rendered server-side under plain Node before hydration (see `ReactRoute`), and TipTap's default
|
|
40
|
+
* (`immediatelyRender: true`) tries to mount its ProseMirror view during that very first render, which
|
|
41
|
+
* both can't work without a real DOM and produces a hydration mismatch. With it `false`, `useEditor`
|
|
42
|
+
* returns `null` until the editor actually mounts client-side after hydration — `ComposeToolbar`/
|
|
43
|
+
* `EditorContent` both already handle a `null` editor by rendering their disabled/empty state, so this
|
|
44
|
+
* needs no extra loading-placeholder logic here. Unlike `MonacoHtmlEditor`, no dynamic `import()` is
|
|
45
|
+
* needed to defer loading TipTap's modules off the SSR path — `immediatelyRender: false` alone is
|
|
46
|
+
* sufficient here since TipTap (unlike Monaco) never touches the DOM at module-evaluation time, only when
|
|
47
|
+
* an editor view actually mounts.
|
|
48
|
+
*/
|
|
49
|
+
export default function RichTextEditor({ value, onChange, height = "360px", fill = false, onUploadImage }: RichTextEditorProps) {
|
|
50
|
+
const onChangeRef = useRef(onChange);
|
|
51
|
+
onChangeRef.current = onChange;
|
|
52
|
+
|
|
53
|
+
const editor = useEditor({
|
|
54
|
+
extensions: [
|
|
55
|
+
StarterKit.configure({ link: { openOnClick: false } }),
|
|
56
|
+
TextStyleKit.configure({ backgroundColor: false, lineHeight: false }),
|
|
57
|
+
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
58
|
+
Highlight,
|
|
59
|
+
Image,
|
|
60
|
+
TableKit.configure({ table: { resizable: true } }),
|
|
61
|
+
Placeholder.configure({ placeholder: "Write your message…" }),
|
|
62
|
+
],
|
|
63
|
+
content: value,
|
|
64
|
+
immediatelyRender: false,
|
|
65
|
+
onUpdate: ({ editor: updated }) => onChangeRef.current(updated.getHTML()),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={["border border-border rounded-sm overflow-hidden", fill ? "h-full flex flex-col" : ""].filter(Boolean).join(" ")}>
|
|
70
|
+
<ComposeToolbar editor={editor} onUploadImage={onUploadImage} />
|
|
71
|
+
<EditorContent
|
|
72
|
+
editor={editor}
|
|
73
|
+
style={fill ? undefined : { height }}
|
|
74
|
+
className={[
|
|
75
|
+
"overflow-y-auto px-3 py-2 text-sm [&_.tiptap]:outline-none [&_.tiptap]:h-full",
|
|
76
|
+
fill ? "flex-1 min-h-0" : "",
|
|
77
|
+
]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join(" ")}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, RefObject, useState } from "react";
|
|
6
|
+
import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
|
|
7
|
+
|
|
8
|
+
export interface ScheduleSendPickerProps {
|
|
9
|
+
anchorRef: RefObject<HTMLElement | null>;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
/** Called with the chosen instant as a UTC ISO string, already validated to be in the future. */
|
|
12
|
+
onSchedule: (scheduledSendTimeIso: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The "Send later" popover next to Compose's Send button — same `PopoverPortal` shell
|
|
17
|
+
* `EmojiPicker`/`GifPicker`/`ResourcePicker` already use, just holding a single `datetime-local` input
|
|
18
|
+
* instead of a grid/list. Validates the chosen time is genuinely in the future before calling
|
|
19
|
+
* `onSchedule` — `@rapidmx/restapi`'s own `send()` only defers when `scheduledSendTime` is strictly
|
|
20
|
+
* later than "now", so a past/immediate pick here would silently send right away instead of scheduling,
|
|
21
|
+
* which would surprise a reader expecting the picker's own choice to be honored.
|
|
22
|
+
*/
|
|
23
|
+
export default function ScheduleSendPicker({ anchorRef, onClose, onSchedule }: ScheduleSendPickerProps) {
|
|
24
|
+
const [value, setValue] = useState("");
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
function handleSubmit(e: FormEvent) {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
if (!value) {
|
|
30
|
+
setError("Pick a date and time.");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const iso = new Date(value).toISOString();
|
|
34
|
+
if (new Date(iso).getTime() <= Date.now()) {
|
|
35
|
+
setError("Pick a time in the future.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
onSchedule(iso);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<PopoverPortal anchorRef={anchorRef} onClose={onClose} width={260} height={150} aria-label="Schedule send">
|
|
43
|
+
<form onSubmit={handleSubmit} className="p-3 flex flex-col gap-2">
|
|
44
|
+
<label className="text-xs font-semibold" htmlFor="schedule-send-time">
|
|
45
|
+
Send at
|
|
46
|
+
</label>
|
|
47
|
+
<input
|
|
48
|
+
id="schedule-send-time"
|
|
49
|
+
type="datetime-local"
|
|
50
|
+
className="text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
|
|
51
|
+
value={value}
|
|
52
|
+
onChange={(e) => setValue(e.target.value)}
|
|
53
|
+
/>
|
|
54
|
+
{error && <p className="text-xs text-danger">{error}</p>}
|
|
55
|
+
<button
|
|
56
|
+
type="submit"
|
|
57
|
+
className="text-sm font-semibold py-1.5 px-3 rounded-pill bg-primary text-white hover:bg-primary-dark"
|
|
58
|
+
>
|
|
59
|
+
Send later
|
|
60
|
+
</button>
|
|
61
|
+
</form>
|
|
62
|
+
</PopoverPortal>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
9
|
+
import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
12
|
+
import AppShell, { AppShellProps } from "../../layout/AppShell.js";
|
|
13
|
+
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
14
|
+
import { useCompose } from "../compose/ComposeContext.js";
|
|
15
|
+
|
|
16
|
+
export type MailShellProps = Omit<AppShellProps, "active">;
|
|
17
|
+
|
|
18
|
+
export interface MailShellContextValue {
|
|
19
|
+
/** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
|
|
20
|
+
mailboxUid?: string;
|
|
21
|
+
/** The folder currently selected (`?folderUid=`, or the selected mailbox's Inbox). */
|
|
22
|
+
folderUid?: string;
|
|
23
|
+
mailboxes: Mailbox[];
|
|
24
|
+
folders: Folder[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MailShellContext = createContext<MailShellContextValue>({ mailboxes: [], folders: [] });
|
|
28
|
+
|
|
29
|
+
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
30
|
+
export function useMailShell(): MailShellContextValue {
|
|
31
|
+
return useContext(MailShellContext);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const FOLDER_LABELS: Record<string, string> = {
|
|
35
|
+
inbox: "Inbox",
|
|
36
|
+
sent_items: "Sent Items",
|
|
37
|
+
drafts: "Drafts",
|
|
38
|
+
outbox: "Outbox",
|
|
39
|
+
junk: "Junk Email",
|
|
40
|
+
deleted_items: "Deleted Items",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Well-known folders sort first, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
|
|
44
|
+
const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "deleted_items"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see
|
|
48
|
+
* `CalendarShell`/`ContactsShell`/`TasksShell`), not Mail — `listFolders()` returns every well-known
|
|
49
|
+
* folder for the mailbox regardless of which app owns it, so Mail's own folder tree must filter down
|
|
50
|
+
* to just the mail ones itself, or those other apps' folders leak into this sidebar.
|
|
51
|
+
*/
|
|
52
|
+
const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
|
|
53
|
+
|
|
54
|
+
function folderSortKey(folder: Folder): number {
|
|
55
|
+
const idx = FOLDER_ORDER.indexOf(folder.type);
|
|
56
|
+
return idx === -1 ? FOLDER_ORDER.length : idx;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Status = "checking" | "error" | "ready";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
|
|
63
|
+
* `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
|
|
64
|
+
* returns, i.e. a descendant of `MailShell`, not an ancestor — a hook call made directly in `MailShell`'s
|
|
65
|
+
* own function body would see only whatever context exists *above* `MailShell`, never a provider one of
|
|
66
|
+
* its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
|
|
67
|
+
* inside that subtree.
|
|
68
|
+
*/
|
|
69
|
+
function ComposeButton({ mailboxUid }: { mailboxUid: string }) {
|
|
70
|
+
const { openCompose } = useCompose();
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={() => openCompose({ mailboxUid })}
|
|
75
|
+
className="block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark"
|
|
76
|
+
>
|
|
77
|
+
Compose
|
|
78
|
+
</button>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mail's own contextual sidebar (mailbox switcher + folder tree) + content area, rendered inside the shared
|
|
84
|
+
* `AppShell` chrome (icon rail, header, impersonation banner — see that component). There is no client-side
|
|
85
|
+
* router in this framework (see `ReactRoute`'s file-convention resolver) — the selected mailbox/folder live
|
|
86
|
+
* in the URL's `?mailboxUid=`/`?folderUid=` query params, read once on mount (never during the initial
|
|
87
|
+
* render itself, matching every other query-param reader in this codebase — e.g. `apps/admin/quarantine`'s
|
|
88
|
+
* `readMailboxUid()` — so the server-rendered and just-hydrated client markup match).
|
|
89
|
+
*/
|
|
90
|
+
export default function MailShell({
|
|
91
|
+
userUid,
|
|
92
|
+
authServerUrl,
|
|
93
|
+
impersonating,
|
|
94
|
+
impersonationBaseUrl,
|
|
95
|
+
trusted,
|
|
96
|
+
children,
|
|
97
|
+
}: PropsWithChildren<MailShellProps>) {
|
|
98
|
+
const [status, setStatus] = useState<Status>("checking");
|
|
99
|
+
const [error, setError] = useState<string | null>(null);
|
|
100
|
+
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
|
101
|
+
const [folders, setFolders] = useState<Folder[]>([]);
|
|
102
|
+
const [foldersLoading, setFoldersLoading] = useState(true);
|
|
103
|
+
const [folderError, setFolderError] = useState<string | null>(null);
|
|
104
|
+
const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
|
|
105
|
+
const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
|
|
106
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const params = new URLSearchParams(window.location.search);
|
|
110
|
+
setRequestedMailboxUid(params.get("mailboxUid"));
|
|
111
|
+
setRequestedFolderUid(params.get("folderUid"));
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!userUid) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
listMailboxes({ limit: 100 })
|
|
119
|
+
.then((result) => {
|
|
120
|
+
setMailboxes(result);
|
|
121
|
+
setStatus("ready");
|
|
122
|
+
})
|
|
123
|
+
.catch((err) => {
|
|
124
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
125
|
+
setStatus("error");
|
|
126
|
+
});
|
|
127
|
+
}, [userUid]);
|
|
128
|
+
|
|
129
|
+
const mailboxUid: string | undefined =
|
|
130
|
+
(requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
131
|
+
mailboxes[0]?.uid;
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!mailboxUid) {
|
|
135
|
+
setFolders([]);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setFolderError(null);
|
|
139
|
+
setFoldersLoading(true);
|
|
140
|
+
listFolders(mailboxUid)
|
|
141
|
+
.then((result) => setFolders(result.filter((f) => MAIL_FOLDER_TYPES.has(f.type))))
|
|
142
|
+
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load folders."))
|
|
143
|
+
.finally(() => setFoldersLoading(false));
|
|
144
|
+
}, [mailboxUid]);
|
|
145
|
+
|
|
146
|
+
const folderUid: string | undefined =
|
|
147
|
+
(requestedFolderUid && folders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
|
|
148
|
+
folders.find((f) => f.type === "inbox")?.uid;
|
|
149
|
+
|
|
150
|
+
const contextValue = useMemo<MailShellContextValue>(
|
|
151
|
+
() => ({ mailboxUid, folderUid, mailboxes, folders }),
|
|
152
|
+
[mailboxUid, folderUid, mailboxes, folders],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const sortedFolders = useMemo(
|
|
156
|
+
() => [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name)),
|
|
157
|
+
[folders],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
|
|
161
|
+
// for a mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all.
|
|
162
|
+
if (userUid && status === "ready" && !mailboxUid) {
|
|
163
|
+
return <MailboxProvisioning />;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let inner: ReactNode = null;
|
|
167
|
+
if (userUid && status === "checking") {
|
|
168
|
+
// Renders immediately (no network round trip needed) so switching into Mail never shows a blank
|
|
169
|
+
// pane while `listMailboxes()` is in flight — the outline below mirrors the real sidebar's shape.
|
|
170
|
+
inner = (
|
|
171
|
+
<aside className="w-64 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4">
|
|
172
|
+
<Skeleton height="h-9" className="rounded-sm" />
|
|
173
|
+
<SkeletonList count={6} />
|
|
174
|
+
</aside>
|
|
175
|
+
);
|
|
176
|
+
} else if (userUid && status === "error") {
|
|
177
|
+
inner = (
|
|
178
|
+
<div className="flex-1 flex items-center justify-center p-8">
|
|
179
|
+
<div className="w-full max-w-md">
|
|
180
|
+
<Alert>{error}</Alert>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
} else if (userUid && status === "ready") {
|
|
185
|
+
// A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
|
|
186
|
+
// `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
|
|
187
|
+
// unmounted), so the mailbox-switcher `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct
|
|
188
|
+
// value per instance. Two elements sharing one id breaks label association (and is invalid HTML).
|
|
189
|
+
const sidebarContent = (idPrefix: string) => (
|
|
190
|
+
<>
|
|
191
|
+
<div className="p-3">
|
|
192
|
+
<ComposeButton mailboxUid={mailboxUid} />
|
|
193
|
+
</div>
|
|
194
|
+
{mailboxes.length > 1 && (
|
|
195
|
+
<div className="px-3 pb-2">
|
|
196
|
+
<label
|
|
197
|
+
className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
|
|
198
|
+
htmlFor={`${idPrefix}-mailbox-switcher`}
|
|
199
|
+
>
|
|
200
|
+
Mailbox
|
|
201
|
+
</label>
|
|
202
|
+
<select
|
|
203
|
+
id={`${idPrefix}-mailbox-switcher`}
|
|
204
|
+
className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
205
|
+
value={mailboxUid}
|
|
206
|
+
onChange={(e) => {
|
|
207
|
+
window.location.href = `/?mailboxUid=${encodeURIComponent(e.target.value)}`;
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{mailboxes.map((mb) => (
|
|
211
|
+
<option key={mb.uid} value={mb.uid}>
|
|
212
|
+
{mb.displayName}
|
|
213
|
+
{mb.ownerUserUid ? "" : " (shared)"}
|
|
214
|
+
</option>
|
|
215
|
+
))}
|
|
216
|
+
</select>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
{folderError && (
|
|
220
|
+
<div className="px-3 pb-2">
|
|
221
|
+
<Alert>{folderError}</Alert>
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
<nav className="flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-0.5">
|
|
225
|
+
{foldersLoading ? (
|
|
226
|
+
<SkeletonList count={5} className="pt-1" />
|
|
227
|
+
) : (
|
|
228
|
+
sortedFolders.map((folder) => (
|
|
229
|
+
<a
|
|
230
|
+
key={folder.uid}
|
|
231
|
+
href={`/?mailboxUid=${encodeURIComponent(mailboxUid)}&folderUid=${encodeURIComponent(folder.uid)}`}
|
|
232
|
+
className={[
|
|
233
|
+
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
234
|
+
folder.uid === folderUid
|
|
235
|
+
? "bg-primary/10 text-primary-dark font-semibold"
|
|
236
|
+
: "text-text hover:bg-surface-alt",
|
|
237
|
+
].join(" ")}
|
|
238
|
+
>
|
|
239
|
+
<span>{FOLDER_LABELS[folder.type] ?? folder.name}</span>
|
|
240
|
+
{folder.unreadCount > 0 && (
|
|
241
|
+
<span className="text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted">
|
|
242
|
+
{folder.unreadCount}
|
|
243
|
+
</span>
|
|
244
|
+
)}
|
|
245
|
+
</a>
|
|
246
|
+
))
|
|
247
|
+
)}
|
|
248
|
+
</nav>
|
|
249
|
+
</>
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
inner = (
|
|
253
|
+
<>
|
|
254
|
+
<aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">
|
|
255
|
+
{sidebarContent("desktop")}
|
|
256
|
+
</aside>
|
|
257
|
+
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders">
|
|
258
|
+
<div className="flex flex-col">{sidebarContent("mobile")}</div>
|
|
259
|
+
</Drawer>
|
|
260
|
+
<main className="flex-1 min-w-0 overflow-y-auto">
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
|
|
264
|
+
aria-label="Open folders"
|
|
265
|
+
onClick={() => setDrawerOpen(true)}
|
|
266
|
+
>
|
|
267
|
+
<HiOutlineBars3 size={20} aria-hidden="true" />
|
|
268
|
+
</button>
|
|
269
|
+
<MailShellContext.Provider value={contextValue}>{children}</MailShellContext.Provider>
|
|
270
|
+
</main>
|
|
271
|
+
</>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<AppShell
|
|
277
|
+
active="mail"
|
|
278
|
+
userUid={userUid}
|
|
279
|
+
authServerUrl={authServerUrl}
|
|
280
|
+
impersonating={impersonating}
|
|
281
|
+
impersonationBaseUrl={impersonationBaseUrl}
|
|
282
|
+
trusted={trusted}
|
|
283
|
+
>
|
|
284
|
+
{inner}
|
|
285
|
+
</AppShell>
|
|
286
|
+
);
|
|
287
|
+
}
|