@rapidmx/web-client 0.6.0 → 0.7.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.
Files changed (79) hide show
  1. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  2. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  3. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
  4. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  5. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  6. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  7. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  8. package/apps/shared/components/layout/AppShell.tsx +288 -288
  9. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  11. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  12. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  13. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  14. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  15. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  20. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  21. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  22. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
  24. package/apps/shared/components/mail/listPreferences.ts +228 -0
  25. package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
  26. package/apps/shared/styles/app.css +28 -5
  27. package/apps/www/index.tsx +1854 -1314
  28. package/apps/www/messages/[uid].tsx +5 -4
  29. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
  30. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  31. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
  32. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  33. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +4 -1
  34. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
  35. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
  36. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  37. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
  38. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  39. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  40. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  41. package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
  42. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  43. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  45. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  46. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  47. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  48. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  49. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  50. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  51. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
  52. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  53. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  54. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  55. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
  56. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  57. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  58. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  59. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  60. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
  61. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  62. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  63. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  64. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  65. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  66. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  67. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  68. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
  69. package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
  70. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  71. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  72. package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
  73. package/dist/apps/shared/styles/app.css +28 -5
  74. package/dist/apps/www/index.js +506 -112
  75. package/dist/apps/www/messages/[uid].js +2 -3
  76. package/package.json +2 -2
  77. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  78. package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
  79. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -0,0 +1,278 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { HiOutlineTag } from "react-icons/hi2";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { Label, createLabel } from "@rapidmx/react-shared/mail/labelsApi.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
13
+ import MenuButton, { MenuSectionSpec } from "./MenuButton.js";
14
+
15
+ /**
16
+ * The one multi-select label list this app uses everywhere labels are picked: the Filter menu's "Labels"
17
+ * submenu, the bulk Apply label action in select mode, and the reading pane's own Labels button. All three
18
+ * tick several labels with the menu staying open and commit them with one command, so the keyboard
19
+ * handling, the checkmarks and the partially-applied state behave identically wherever they appear.
20
+ */
21
+
22
+ /** What the reader has ticked so far, before committing. */
23
+ export interface LabelDraft {
24
+ /** Labels currently ticked. */
25
+ draft: string[];
26
+ /** Labels still showing as partially applied - some of the messages being acted on carry them and some
27
+ * don't, and the reader hasn't touched the row, so committing leaves each message as it is. */
28
+ mixed: string[];
29
+ toggle: (labelUid: string) => void;
30
+ /** Unticks everything, including anything still partially applied. */
31
+ clear: () => void;
32
+ /** `true` once the draft differs from what it started as - what the commit row is enabled by. */
33
+ dirty: boolean;
34
+ }
35
+
36
+ /** `a` and `b` hold the same uids, order ignored. */
37
+ function sameUids(a: string[], b: string[]): boolean {
38
+ return a.length === b.length && a.every((uid) => b.includes(uid));
39
+ }
40
+
41
+ /**
42
+ * Holds the ticked/partially-applied state of a label list while its menu is open, starting again from
43
+ * `applied`/`partial` every time the menu is reopened - so dismissing a menu (Escape, a click outside)
44
+ * discards the draft rather than silently keeping half a change.
45
+ */
46
+ export function useLabelDraft(applied: string[], partial: string[], open: boolean): LabelDraft {
47
+ const [state, setState] = useState<{ draft: string[]; mixed: string[] } | null>(null);
48
+ const [wasOpen, setWasOpen] = useState(open);
49
+ if (wasOpen !== open) {
50
+ setWasOpen(open);
51
+ setState(open ? { draft: applied, mixed: partial } : null);
52
+ }
53
+ const current = state ?? { draft: applied, mixed: partial };
54
+ return {
55
+ draft: current.draft,
56
+ mixed: current.mixed,
57
+ toggle: (labelUid) =>
58
+ setState({
59
+ // Touching a partially-applied row settles it: it becomes a plain tick (apply to all).
60
+ draft: current.draft.includes(labelUid)
61
+ ? current.draft.filter((uid) => uid !== labelUid)
62
+ : [...current.draft, labelUid],
63
+ mixed: current.mixed.filter((uid) => uid !== labelUid),
64
+ }),
65
+ clear: () => setState({ draft: [], mixed: [] }),
66
+ dirty: !sameUids(current.draft, applied) || !sameUids(current.mixed, partial),
67
+ };
68
+ }
69
+
70
+ export interface LabelSectionsOptions {
71
+ labels: Label[];
72
+ state: LabelDraft;
73
+ /** The row that commits the draft. */
74
+ commit: { label: string; onSelect: () => void; disabled?: boolean };
75
+ /** The row that unticks everything. `keepOpen` false lets a caller (the filter) apply it straight away. */
76
+ clear: { label: string; disabled?: boolean; keepOpen?: boolean; onSelect?: () => void };
77
+ /** What to say instead of the list when this mailbox has no labels. */
78
+ emptyNote: string;
79
+ /** Adds a "New label" row. A `role="menu"` can't hold the text field that needs, so the row closes the
80
+ * menu and opens `NewLabelDialog` - see `LabelMenuButton` and the Filter menu's own use of it. */
81
+ onCreate?: () => void;
82
+ /** Where labels are renamed, recoloured and deleted. */
83
+ managePath?: string;
84
+ /** A line under the list explaining what committing will do. */
85
+ note?: string;
86
+ /** Holds every row while a request is in flight. */
87
+ busy?: boolean;
88
+ }
89
+
90
+ /**
91
+ * The label rows plus their commit/clear rows, as `MenuButton` sections - so the same list can be a menu of
92
+ * its own (`LabelMenuButton` below) or a submenu of another menu (the Filter menu's "Labels" row).
93
+ */
94
+ export function labelSections({
95
+ labels,
96
+ state,
97
+ commit,
98
+ clear,
99
+ emptyNote,
100
+ note,
101
+ busy,
102
+ onCreate,
103
+ managePath = "/settings/labels",
104
+ }: LabelSectionsOptions): MenuSectionSpec[] {
105
+ const manageItem = {
106
+ key: "manage",
107
+ label: "Manage labels…",
108
+ onSelect: () => {
109
+ window.location.href = managePath;
110
+ },
111
+ };
112
+ const tailItems = [...(onCreate ? [{ key: "create", label: "New label…", onSelect: onCreate }] : []), manageItem];
113
+ if (labels.length === 0) {
114
+ return [{ key: "labels", label: "Labels", note: emptyNote, items: tailItems }];
115
+ }
116
+ return [
117
+ {
118
+ key: "labels",
119
+ label: "Labels",
120
+ note,
121
+ items: labels.map((label) => ({
122
+ key: label.uid,
123
+ label: label.name,
124
+ role: "menuitemcheckbox" as const,
125
+ checked: state.mixed.includes(label.uid) ? ("mixed" as const) : state.draft.includes(label.uid),
126
+ swatchColor: label.color ?? "#6366f1",
127
+ disabled: busy,
128
+ // Several labels are ticked before anything is committed, so a tick never closes the menu.
129
+ keepOpen: true,
130
+ onSelect: () => state.toggle(label.uid),
131
+ })),
132
+ },
133
+ {
134
+ key: "commands",
135
+ items: [
136
+ {
137
+ key: "clear",
138
+ label: clear.label,
139
+ disabled: busy || clear.disabled || (state.draft.length === 0 && state.mixed.length === 0),
140
+ keepOpen: clear.keepOpen ?? true,
141
+ onSelect: () => (clear.onSelect ?? state.clear)(),
142
+ },
143
+ { key: "commit", label: commit.label, disabled: busy || commit.disabled, onSelect: commit.onSelect },
144
+ ...tailItems,
145
+ ],
146
+ },
147
+ ];
148
+ }
149
+
150
+ export interface NewLabelDialogProps {
151
+ open: boolean;
152
+ onClose: () => void;
153
+ mailboxUid: string;
154
+ /** Handed the label the server created, for the caller to add to its own list. */
155
+ onCreated: (label: Label) => void;
156
+ }
157
+
158
+ /** The "New label" form the menus open - a dialog rather than more menu rows, because a `role="menu"`
159
+ * has no place for a text field. Colour is left to Settings > Labels; this is the quick path from a
160
+ * message, so it only asks for the name. */
161
+ export function NewLabelDialog({ open, onClose, mailboxUid, onCreated }: NewLabelDialogProps) {
162
+ const [name, setName] = useState("");
163
+ const [saving, setSaving] = useState(false);
164
+ const [error, setError] = useState<string | null>(null);
165
+
166
+ async function save() {
167
+ setSaving(true);
168
+ setError(null);
169
+ try {
170
+ onCreated(await createLabel({ mailboxUid, name: name.trim() }));
171
+ setName("");
172
+ onClose();
173
+ } catch (err) {
174
+ setError(err instanceof ApiRequestError ? err.message : "Could not create this label.");
175
+ } finally {
176
+ setSaving(false);
177
+ }
178
+ }
179
+
180
+ return (
181
+ <Modal open={open} onClose={onClose} title="New label">
182
+ {error && <Alert>{error}</Alert>}
183
+ <FormField label="Name" htmlFor="new-label-name">
184
+ <input
185
+ id="new-label-name"
186
+ type="text"
187
+ value={name}
188
+ onChange={(e) => setName(e.target.value)}
189
+ className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface"
190
+ />
191
+ </FormField>
192
+ <div className="flex justify-end gap-2 mt-2">
193
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
194
+ Cancel
195
+ </Button>
196
+ <Button type="button" className="!w-auto" loading={saving} disabled={saving || !name.trim()} onClick={() => void save()}>
197
+ Create
198
+ </Button>
199
+ </div>
200
+ </Modal>
201
+ );
202
+ }
203
+
204
+ export interface LabelMenuButtonProps extends Omit<LabelSectionsOptions, "state" | "commit"> {
205
+ /** The commit row - this component supplies its action (`onCommit` below) and disables it until the
206
+ * draft actually differs from what it started as. */
207
+ commit: { label: string; disabled?: boolean };
208
+ /** The labels every target already carries. */
209
+ applied: string[];
210
+ /** The labels only some of them carry. */
211
+ partial?: string[];
212
+ /** Called with the labels to end up with, and the ones left partially applied (each target keeps those
213
+ * exactly as they are). */
214
+ onCommit: (labelUids: string[], keepPartial: string[]) => void;
215
+ label: string;
216
+ "aria-label": string;
217
+ disabled?: boolean;
218
+ title?: string;
219
+ className?: string;
220
+ /** With `onLabelCreated`, adds a "New label" row that opens this component's own dialog. */
221
+ mailboxUid?: string;
222
+ onLabelCreated?: (label: Label) => void;
223
+ }
224
+
225
+ /** The label list as a menu button of its own - the bulk Apply label action and the reading pane's Labels. */
226
+ export default function LabelMenuButton({
227
+ applied,
228
+ partial = [],
229
+ onCommit,
230
+ labels,
231
+ commit,
232
+ clear,
233
+ emptyNote,
234
+ note,
235
+ busy,
236
+ label,
237
+ "aria-label": ariaLabel,
238
+ disabled,
239
+ title,
240
+ className,
241
+ mailboxUid,
242
+ onLabelCreated,
243
+ }: LabelMenuButtonProps) {
244
+ const [open, setOpen] = useState(false);
245
+ const [creating, setCreating] = useState(false);
246
+ const state = useLabelDraft(applied, partial, open);
247
+ return (
248
+ <>
249
+ <MenuButton
250
+ aria-label={ariaLabel}
251
+ label={label}
252
+ icon={<HiOutlineTag size={16} aria-hidden="true" className="shrink-0 text-text-muted" />}
253
+ disabled={disabled}
254
+ title={title}
255
+ className={className}
256
+ onOpenChange={setOpen}
257
+ sections={labelSections({
258
+ labels,
259
+ state,
260
+ commit: { ...commit, disabled: commit.disabled || !state.dirty, onSelect: () => onCommit(state.draft, state.mixed) },
261
+ clear,
262
+ emptyNote,
263
+ note,
264
+ busy,
265
+ onCreate: mailboxUid && onLabelCreated ? () => setCreating(true) : undefined,
266
+ })}
267
+ />
268
+ {mailboxUid && onLabelCreated && (
269
+ <NewLabelDialog
270
+ open={creating}
271
+ onClose={() => setCreating(false)}
272
+ mailboxUid={mailboxUid}
273
+ onCreated={onLabelCreated}
274
+ />
275
+ )}
276
+ </>
277
+ );
278
+ }