@nocobase/portal-template-default 1.0.15 → 1.0.17

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 (153) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.MD +4 -0
  3. package/dist/assets/{LineDraw-iG-ADes-.js → LineDraw-BBB5hwuJ.js} +1 -1
  4. package/dist/assets/{SymbolDraw-DXoSw1tM.js → SymbolDraw-CKS3uUb_.js} +1 -1
  5. package/dist/assets/{VisualMapping-C_ee4Eht.js → VisualMapping-BCwaW3TD.js} +1 -1
  6. package/dist/assets/auth-method-demo-BdCCvEEz.js +1 -0
  7. package/dist/assets/{auto-login-provider-5Uxza9Ms.js → auto-login-provider-DO5Il6Dp.js} +2 -2
  8. package/dist/assets/auto-login-provider-TQ08B6-i.js +1 -0
  9. package/dist/assets/auto-redirect-provider-ButXp0A3.js +1 -0
  10. package/dist/assets/auto-redirect-provider-DnGGP2Vn.js +1 -0
  11. package/dist/assets/auto-redirect-provider-ja-vFPaF.js +1 -0
  12. package/dist/assets/calendar-C7xnRgOT.js +1 -0
  13. package/dist/assets/{cas-sign-in-button-CyqG1QME.js → cas-sign-in-button-BZCa4lR6.js} +1 -1
  14. package/dist/assets/{components-D-ptYjX_.js → components-_g-GjQeV.js} +3 -3
  15. package/dist/assets/{configuration-gate-C08mkkiX.js → configuration-gate-B4XVz7tS.js} +1 -1
  16. package/dist/assets/{createClipPathFromCoordSys-wj34-b1B.js → createClipPathFromCoordSys-z9Kmh-tu.js} +1 -1
  17. package/dist/assets/createSeriesDataSimply-CPqysF0K.js +1 -0
  18. package/dist/assets/{customGraphicKeyframeAnimation-DWrhrHL_.js → customGraphicKeyframeAnimation-DMU5crAu.js} +1 -1
  19. package/dist/assets/demo-BPlae2bp.js +1 -0
  20. package/dist/assets/demo-Bj_Y0RrW.js +1 -0
  21. package/dist/assets/demo-CKPAQOib.js +1 -0
  22. package/dist/assets/demo-CXRTPvIx.js +1 -0
  23. package/dist/assets/demo-DJuVDu1n.js +1 -0
  24. package/dist/assets/demo-DUmWU7ik.js +1 -0
  25. package/dist/assets/demo-NhIzFRE1.js +1 -0
  26. package/dist/assets/{dingtalk-sign-in-button-DSWsSruv.js → dingtalk-sign-in-button-DMPgE5kT.js} +1 -1
  27. package/dist/assets/{echarts-preview-DxHNfq1C.js → echarts-preview-R96Pbqmz.js} +1 -1
  28. package/dist/assets/{echarts-runtime-D8kXn6ME.js → echarts-runtime-Ce_z2eX_.js} +3 -3
  29. package/dist/assets/{echarts-runtime-advanced-QY8v95qK.js → echarts-runtime-advanced-Dr80KHJR.js} +1 -1
  30. package/dist/assets/{echarts-runtime-common-D-yqAK-r.js → echarts-runtime-common-DB95Yfc1.js} +1 -1
  31. package/dist/assets/{echarts-runtime-components-P4-i8jPU.js → echarts-runtime-components-DCJEaTJk.js} +1 -1
  32. package/dist/assets/{echarts-runtime-hierarchy-ByQZ-DbP.js → echarts-runtime-hierarchy-CezgZs9O.js} +1 -1
  33. package/dist/assets/{floating-BFVEyPEp.js → floating-O9WmiDXk.js} +2 -2
  34. package/dist/assets/index-BBKV6O5d.css +1 -0
  35. package/dist/assets/{index-Btk35Nx-.js → index-CPzehPGe.js} +3 -3
  36. package/dist/assets/index-Cc6awvdg.js +1064 -0
  37. package/dist/assets/index-ChA0z9ke.js +38 -0
  38. package/dist/assets/{index-um5hdKdZ.js → index-DgB7uTMs.js} +2 -2
  39. package/dist/assets/{index-CwNxd66J.js → index-DroUAK80.js} +1 -1
  40. package/dist/assets/index-WKvygTBp.js +59 -0
  41. package/dist/assets/{labelHelper-DQqRy3a_.js → labelHelper-Dk5jkiD-.js} +1 -1
  42. package/dist/assets/ldap-sign-in-form-CKN4l7qM.js +1 -0
  43. package/dist/assets/{oidc-sign-in-button-CQdRcR_p.js → oidc-sign-in-button-BIRonrpC.js} +1 -1
  44. package/dist/assets/{page-context-CnQ4GYM7.js → page-context-DAiW_dId.js} +3 -3
  45. package/dist/assets/prompt-card-rGZqpqkF.js +1 -0
  46. package/dist/assets/prompt-output-B4h3uLWf.js +1 -0
  47. package/dist/assets/saml-sign-in-button-B4ugaLIb.js +1 -0
  48. package/dist/assets/{sectorHelper-DN_Vv6OL.js → sectorHelper-BuxrrJrA.js} +1 -1
  49. package/dist/assets/shortcut-BhKXxJLs.js +47 -0
  50. package/dist/assets/{sms-sign-in-form-DTX9GV2I.js → sms-sign-in-form-DKpxRDdZ.js} +1 -1
  51. package/dist/assets/{static.browser-jI1psK0d.js → static.browser-kzzpH0Xt.js} +8 -8
  52. package/dist/assets/switch-BdVxPIVU.js +1 -0
  53. package/dist/assets/{tool-cards-DFttLAfo.js → tool-cards-CpczIke-.js} +3 -3
  54. package/dist/assets/{use-authenticator-sign-in-CRGjzXju.js → use-authenticator-sign-in-CIj210ry.js} +1 -1
  55. package/dist/assets/use-dingtalk-sign-in-BW4kkXD3.js +1 -0
  56. package/dist/assets/{use-wecom-sign-in-C9j8wfCD.js → use-wecom-sign-in-dIqMtcN9.js} +1 -1
  57. package/dist/assets/wecom-sign-in-button-Dn_2zDab.js +1 -0
  58. package/dist/index.html +2 -2
  59. package/dist/index.raw.html +2 -2
  60. package/package.json +9 -1
  61. package/src/App.tsx +5 -2
  62. package/src/app/extension.ts +2 -0
  63. package/src/app/extensions.tsx +18 -6
  64. package/src/app/route-access-guard.tsx +22 -0
  65. package/src/app/route-runtime.ts +173 -0
  66. package/src/app/routes.tsx +2 -2
  67. package/src/components/app-shell/header.tsx +56 -1
  68. package/src/components/app-shell/sidebar.tsx +14 -2
  69. package/src/components/auth/sign-up-form.tsx +287 -59
  70. package/src/components/auth/types.ts +23 -1
  71. package/src/extensions/nocobase-acl/README.md +28 -14
  72. package/src/extensions/nocobase-acl/demo/index.tsx +87 -15
  73. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +1 -0
  74. package/src/extensions/nocobase-mail/README.md +74 -0
  75. package/src/extensions/nocobase-mail/components/index.ts +79 -0
  76. package/src/extensions/nocobase-mail/components/mail-api.ts +423 -0
  77. package/src/extensions/nocobase-mail/components/mail-attachment-list.tsx +67 -0
  78. package/src/extensions/nocobase-mail/components/mail-compose-attachments.tsx +166 -0
  79. package/src/extensions/nocobase-mail/components/mail-compose.tsx +1071 -0
  80. package/src/extensions/nocobase-mail/components/mail-detail.tsx +653 -0
  81. package/src/extensions/nocobase-mail/components/mail-draft-autosave.ts +26 -0
  82. package/src/extensions/nocobase-mail/components/mail-empty.tsx +48 -0
  83. package/src/extensions/nocobase-mail/components/mail-filters.tsx +277 -0
  84. package/src/extensions/nocobase-mail/components/mail-inbox.tsx +544 -0
  85. package/src/extensions/nocobase-mail/components/mail-inline-images.ts +69 -0
  86. package/src/extensions/nocobase-mail/components/mail-label-badge.tsx +25 -0
  87. package/src/extensions/nocobase-mail/components/mail-label-manager.tsx +271 -0
  88. package/src/extensions/nocobase-mail/components/mail-labels-editor.tsx +267 -0
  89. package/src/extensions/nocobase-mail/components/mail-mass-tracking.tsx +271 -0
  90. package/src/extensions/nocobase-mail/components/mail-note-editor.tsx +99 -0
  91. package/src/extensions/nocobase-mail/components/mail-recipient-input.tsx +260 -0
  92. package/src/extensions/nocobase-mail/components/mail-reply-quote.ts +28 -0
  93. package/src/extensions/nocobase-mail/components/mail-rich-editor.tsx +287 -0
  94. package/src/extensions/nocobase-mail/components/mail-send-actions.tsx +238 -0
  95. package/src/extensions/nocobase-mail/components/mail-senders.ts +61 -0
  96. package/src/extensions/nocobase-mail/components/mail-settings-drawer.tsx +708 -0
  97. package/src/extensions/nocobase-mail/components/mail-showcase-layout.tsx +64 -0
  98. package/src/extensions/nocobase-mail/components/mail-signature-manager.tsx +258 -0
  99. package/src/extensions/nocobase-mail/components/mail-signatures.ts +25 -0
  100. package/src/extensions/nocobase-mail/components/mail-table.tsx +476 -0
  101. package/src/extensions/nocobase-mail/components/mail-template-manager.tsx +204 -0
  102. package/src/extensions/nocobase-mail/components/mail-toolbar.tsx +155 -0
  103. package/src/extensions/nocobase-mail/components/mail-unread.tsx +111 -0
  104. package/src/extensions/nocobase-mail/components/types.ts +297 -0
  105. package/src/extensions/nocobase-mail/components/use-mail-compose.tsx +262 -0
  106. package/src/extensions/nocobase-mail/components/use-mail-mass-messages.ts +54 -0
  107. package/src/extensions/nocobase-mail/components/use-mail-messages.ts +113 -0
  108. package/src/extensions/nocobase-mail/components/use-mail-signatures.ts +122 -0
  109. package/src/extensions/nocobase-mail/components/use-mail-templates.ts +54 -0
  110. package/src/extensions/nocobase-mail/extension.tsx +105 -0
  111. package/src/extensions/nocobase-mail/index.ts +1 -0
  112. package/src/extensions/nocobase-mail/mail-demo-pages.tsx +503 -0
  113. package/src/extensions/nocobase-mail/mail-pages.tsx +201 -0
  114. package/src/extensions/nocobase-mail/tests/mail-quote-html-regression.mjs +51 -0
  115. package/src/extensions/nocobase-mail/tests/mail-regression.mjs +649 -0
  116. package/src/extensions/nocobase-users-example/app-routes.tsx +121 -0
  117. package/src/extensions/nocobase-users-example/extension.tsx +2 -100
  118. package/src/extensions/nocobase-users-example/index.ts +1 -0
  119. package/src/lib/nocobase/acl/menu.ts +17 -3
  120. package/src/lib/nocobase/acl/types.ts +4 -0
  121. package/src/locales/en-US.ts +2 -1
  122. package/src/locales/zh-CN.ts +2 -1
  123. package/src/providers/runtime-config.ts +18 -0
  124. package/src/routes.tsx +10 -0
  125. package/src/vite-env.d.ts +3 -0
  126. package/vite.config.ts +8 -0
  127. package/dist/assets/auth-method-demo-DaOCuvsq.js +0 -1
  128. package/dist/assets/auto-login-provider-DyaPGK2D.js +0 -1
  129. package/dist/assets/auto-redirect-provider-CfwRvYOI.js +0 -1
  130. package/dist/assets/auto-redirect-provider-Cr3jmYts.js +0 -1
  131. package/dist/assets/auto-redirect-provider-QkJQRBbG.js +0 -1
  132. package/dist/assets/calendar-DjDRfD5t.js +0 -1
  133. package/dist/assets/card-DuQBOS6A.js +0 -1
  134. package/dist/assets/createSeriesDataSimply-D0A6SAVQ.js +0 -1
  135. package/dist/assets/demo-BYWHeKUe.js +0 -1
  136. package/dist/assets/demo-CPWoNtx8.js +0 -1
  137. package/dist/assets/demo-CyvJk5zV.js +0 -1
  138. package/dist/assets/demo-D4OiKplc.js +0 -1
  139. package/dist/assets/demo-DBueJg0m.js +0 -1
  140. package/dist/assets/demo-DkIhyiRK.js +0 -1
  141. package/dist/assets/demo-tpTjO0sC.js +0 -1
  142. package/dist/assets/index-BgCbGDMc.js +0 -43
  143. package/dist/assets/index-MB-jfQld.js +0 -686
  144. package/dist/assets/index-NH3aqqQz.css +0 -1
  145. package/dist/assets/index-gFtCXH3o.js +0 -58
  146. package/dist/assets/ldap-sign-in-form--6Gt3yGq.js +0 -1
  147. package/dist/assets/prompt-card-B2RS4nt2.js +0 -1
  148. package/dist/assets/prompt-output-DZHRM2ft.js +0 -1
  149. package/dist/assets/saml-sign-in-button-BHL5a0JV.js +0 -1
  150. package/dist/assets/shortcut-C5x5LQWU.js +0 -47
  151. package/dist/assets/switch-CRL8PmRI.js +0 -1
  152. package/dist/assets/use-dingtalk-sign-in-8WYzJ2jf.js +0 -1
  153. package/dist/assets/wecom-sign-in-button-mIS1zGSU.js +0 -1
@@ -0,0 +1,653 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { format, parseISO } from "date-fns";
3
+ import {
4
+ ChevronDown,
5
+ Circle,
6
+ CircleCheckBig,
7
+ Clock3,
8
+ CornerUpLeft,
9
+ CornerUpRight,
10
+ Forward,
11
+ MessagesSquare,
12
+ MailX,
13
+ Paperclip,
14
+ Trash2,
15
+ Undo2,
16
+ } from "lucide-react";
17
+ import {
18
+ isLocalMailDraft,
19
+ type MailLabel,
20
+ type MailMessage,
21
+ type MailNote,
22
+ } from "./types";
23
+ import { MailAttachmentList } from "./mail-attachment-list";
24
+ import { MailLabelsEditor } from "./mail-labels-editor";
25
+ import { MailNoteEditor } from "./mail-note-editor";
26
+ import {
27
+ collectInlineContentIds,
28
+ filterInlineAttachments,
29
+ replaceInlineImageSources,
30
+ } from "./mail-inline-images";
31
+ import { mailApi } from "./mail-api";
32
+ import { canReplyAll } from "./use-mail-compose";
33
+ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
34
+ import { Button } from "@/components/ui/button";
35
+ import { Separator } from "@/components/ui/separator";
36
+ import { Skeleton } from "@/components/ui/skeleton";
37
+ import { cn } from "@/lib/utils";
38
+
39
+ function formatFullDate(dateStr?: string) {
40
+ if (!dateStr) return "";
41
+ try {
42
+ return format(parseISO(dateStr), "MMM d, yyyy · HH:mm");
43
+ } catch {
44
+ return dateStr;
45
+ }
46
+ }
47
+
48
+ function displayName(message: MailMessage) {
49
+ return message.fromUser?.name || message.from || "Unknown";
50
+ }
51
+
52
+ function formatAddressList(users?: { name?: string; address: string }[], raw?: string) {
53
+ if (users?.length) {
54
+ return users.map((u) => u.name || u.address).join(", ");
55
+ }
56
+ return raw || "";
57
+ }
58
+
59
+ function initials(name: string) {
60
+ return name
61
+ .split(/[\s@.]+/)
62
+ .filter(Boolean)
63
+ .slice(0, 2)
64
+ .map((part) => part[0]?.toUpperCase())
65
+ .join("");
66
+ }
67
+
68
+ const MAIL_QUOTE_SELECTORS = [
69
+ '[data-role="reply-quote"]',
70
+ ".gmail_quote",
71
+ 'blockquote[type="cite"]',
72
+ // 163 Mail
73
+ "#divNeteaseMailCard",
74
+ ".nui-mail-quote",
75
+ // QQ Mail and Foxmail
76
+ "#isForwardContent",
77
+ "#isReplyContent",
78
+ "#foxmail_quote",
79
+ // Outlook and Hotmail
80
+ "#divRplyFwdMsg",
81
+ ".OutlookMessageHeader",
82
+ // Yahoo Mail
83
+ "#yahoo_quoted",
84
+ // Common quote and reply containers
85
+ "blockquote",
86
+ '[class*="quote"]',
87
+ '[id*="quote"]',
88
+ '[class*="reply"]',
89
+ '[id*="reply"]',
90
+ ];
91
+
92
+ function collapseQuotedContent(doc: Document, onHeightChange: () => void) {
93
+ if (doc.querySelector(".mail-quote")) return;
94
+
95
+ const nodes = Array.from(
96
+ doc.querySelectorAll(MAIL_QUOTE_SELECTORS.join(","))
97
+ );
98
+ const topLevelNodes = nodes.filter(
99
+ (node) =>
100
+ !nodes.some(
101
+ (otherNode) => otherNode !== node && otherNode.contains(node)
102
+ )
103
+ );
104
+ const quotedContent = topLevelNodes[0];
105
+ if (!quotedContent?.parentNode) return;
106
+
107
+ const style = doc.createElement("style");
108
+ style.textContent = `
109
+ img { max-width: 100%; height: auto; }
110
+ .mail-quote { margin-top: 12px; }
111
+ .mail-quote-toggle {
112
+ appearance: none;
113
+ background: transparent;
114
+ border: 0;
115
+ padding: 0;
116
+ cursor: pointer;
117
+ color: #1677ff;
118
+ font: inherit;
119
+ font-size: 12px;
120
+ margin-bottom: 6px;
121
+ user-select: none;
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 4px;
125
+ }
126
+ .mail-quote-toggle .toggle-icon {
127
+ display: inline-block;
128
+ transition: transform 0.2s ease;
129
+ font-size: 10px;
130
+ margin-top: 1px;
131
+ }
132
+ .mail-quote.is-collapsed .toggle-icon { transform: rotate(-90deg); }
133
+ .mail-quote.is-collapsed > *:not(.mail-quote-toggle) { display: none; }
134
+ `;
135
+ doc.head.appendChild(style);
136
+
137
+ const wrapper = doc.createElement("div");
138
+ wrapper.className = "mail-quote is-collapsed";
139
+ const toggle = doc.createElement("button");
140
+ toggle.type = "button";
141
+ toggle.className = "mail-quote-toggle";
142
+ toggle.setAttribute("aria-expanded", "false");
143
+ toggle.title = "Show quoted content";
144
+ toggle.append("Replied message");
145
+ const icon = doc.createElement("span");
146
+ icon.className = "toggle-icon";
147
+ icon.setAttribute("aria-hidden", "true");
148
+ icon.textContent = "▼";
149
+ toggle.appendChild(icon);
150
+
151
+ quotedContent.parentNode.insertBefore(wrapper, quotedContent);
152
+ wrapper.append(toggle, quotedContent);
153
+ toggle.addEventListener("click", () => {
154
+ const collapsed = wrapper.classList.toggle("is-collapsed");
155
+ toggle.setAttribute("aria-expanded", String(!collapsed));
156
+ toggle.title = collapsed ? "Show quoted content" : "Hide quoted content";
157
+ requestAnimationFrame(onHeightChange);
158
+ });
159
+ }
160
+
161
+ function MailHtmlBody({
162
+ html,
163
+ messageId,
164
+ }: {
165
+ html: string;
166
+ messageId: number | string;
167
+ }) {
168
+ const frameRef = useRef<HTMLIFrameElement>(null);
169
+ const [resolvedHtml, setResolvedHtml] = useState(html);
170
+
171
+ useEffect(() => {
172
+ const contentIds = collectInlineContentIds(html);
173
+ if (!contentIds.length) {
174
+ setResolvedHtml(html);
175
+ return;
176
+ }
177
+
178
+ let active = true;
179
+ const objectUrls: string[] = [];
180
+ void Promise.all(
181
+ contentIds.map(async (contentId) => {
182
+ try {
183
+ const blob = await mailApi.fetchInlineImage(messageId, contentId);
184
+ const objectUrl = URL.createObjectURL(blob);
185
+ if (!active) {
186
+ URL.revokeObjectURL(objectUrl);
187
+ return undefined;
188
+ }
189
+ objectUrls.push(objectUrl);
190
+ return [contentId, objectUrl] as const;
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ })
195
+ ).then((sources) => {
196
+ if (!active) return;
197
+ const sourceMap = new Map(
198
+ sources.filter(
199
+ (source): source is readonly [string, string] => Boolean(source)
200
+ )
201
+ );
202
+ setResolvedHtml(replaceInlineImageSources(html, sourceMap));
203
+ });
204
+
205
+ return () => {
206
+ active = false;
207
+ objectUrls.forEach((objectUrl) => URL.revokeObjectURL(objectUrl));
208
+ };
209
+ }, [html, messageId]);
210
+
211
+ useEffect(() => {
212
+ const frame = frameRef.current;
213
+ if (!frame) return;
214
+
215
+ let observer: ResizeObserver | undefined;
216
+
217
+ const resize = () => {
218
+ try {
219
+ const doc = frame.contentDocument;
220
+ if (!doc?.body) return;
221
+ collapseQuotedContent(doc, resize);
222
+ frame.style.height = `${doc.body.scrollHeight + 32}px`;
223
+ if (!observer && typeof ResizeObserver !== "undefined") {
224
+ observer = new ResizeObserver(() => {
225
+ try {
226
+ frame.style.height = `${doc.body.scrollHeight + 32}px`;
227
+ } catch {
228
+ /* frame torn down */
229
+ }
230
+ });
231
+ observer.observe(doc.body);
232
+ }
233
+ } catch {
234
+ /* not measurable */
235
+ }
236
+ };
237
+
238
+ frame.addEventListener("load", resize);
239
+ const fallback = setTimeout(resize, 150);
240
+
241
+ return () => {
242
+ frame.removeEventListener("load", resize);
243
+ clearTimeout(fallback);
244
+ observer?.disconnect();
245
+ };
246
+ }, [resolvedHtml]);
247
+
248
+ return (
249
+ <iframe
250
+ ref={frameRef}
251
+ title="Email content"
252
+ sandbox="allow-same-origin"
253
+ srcDoc={resolvedHtml}
254
+ className="w-full border-0 bg-white transition-[height] duration-200"
255
+ style={{ minHeight: 48 }}
256
+ />
257
+ );
258
+ }
259
+
260
+ function buildThread(message: MailMessage): MailMessage[] {
261
+ const children = message.children?.length
262
+ ? message.children
263
+ : (message.childrenMessages ?? []);
264
+ const seen = new Set<number>();
265
+ const all = [message, ...children].filter((m) => {
266
+ if (m.id == null || seen.has(m.id)) return false;
267
+ seen.add(m.id);
268
+ return true;
269
+ });
270
+ return all.sort(
271
+ (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
272
+ );
273
+ }
274
+
275
+ function formatShortDate(dateStr?: string) {
276
+ if (!dateStr) return "";
277
+ try {
278
+ return format(parseISO(dateStr), "MMM d");
279
+ } catch {
280
+ return "";
281
+ }
282
+ }
283
+
284
+ function MailThreadMessage({
285
+ message,
286
+ expanded,
287
+ onToggle,
288
+ collapsible = true,
289
+ onReply,
290
+ onReplyAll,
291
+ onForward,
292
+ }: {
293
+ message: MailMessage;
294
+ expanded: boolean;
295
+ onToggle: () => void;
296
+ collapsible?: boolean;
297
+ onReply?: (message: MailMessage) => void;
298
+ onReplyAll?: (message: MailMessage) => void;
299
+ onForward?: (message: MailMessage) => void;
300
+ }) {
301
+ const name = displayName(message);
302
+ const body = message.bodyHtml || message.bodyText || "";
303
+ const visibleAttachments = filterInlineAttachments(
304
+ message.attachments ?? [],
305
+ message.bodyHtml || ""
306
+ );
307
+ const isPlainText = !message.bodyHtml && Boolean(message.bodyText);
308
+ const snippet = (message.bodyText || "").replace(/\s+/g, " ").trim().slice(0, 90);
309
+ const isExpanded = collapsible ? expanded : true;
310
+
311
+ const headerContent = (
312
+ <>
313
+ <Avatar size="sm">
314
+ <AvatarFallback className="bg-primary/15 text-[10px] font-semibold text-primary">
315
+ {initials(name)}
316
+ </AvatarFallback>
317
+ </Avatar>
318
+ <span className="min-w-0 flex-1">
319
+ <span className="flex items-baseline gap-2">
320
+ <span className="shrink-0 text-sm font-semibold text-foreground">
321
+ {name}
322
+ </span>
323
+ {!isExpanded && snippet && (
324
+ <span className="truncate text-xs text-muted-foreground">
325
+ {snippet}
326
+ </span>
327
+ )}
328
+ </span>
329
+ {isExpanded && (
330
+ <span className="mt-0.5 block truncate text-xs text-muted-foreground">
331
+ <span className="font-medium text-foreground/80">{message.from}</span>
332
+ <span> · to {formatAddressList(message.toUsers, message.to)}</span>
333
+ {message.cc && (
334
+ <span> · cc {formatAddressList(message.ccUsers, message.cc)}</span>
335
+ )}
336
+ </span>
337
+ )}
338
+ </span>
339
+ {visibleAttachments.length ? (
340
+ <Paperclip className="size-3.5 shrink-0 text-muted-foreground" />
341
+ ) : null}
342
+ <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
343
+ {isExpanded ? formatFullDate(message.date) : formatShortDate(message.date)}
344
+ </span>
345
+ {collapsible && (
346
+ <ChevronDown
347
+ className={cn(
348
+ "size-4 shrink-0 text-muted-foreground transition-transform duration-200",
349
+ isExpanded && "rotate-180"
350
+ )}
351
+ />
352
+ )}
353
+ </>
354
+ );
355
+
356
+ return (
357
+ <div className="border-b border-border/60 last:border-0">
358
+ {collapsible ? (
359
+ <button
360
+ type="button"
361
+ onClick={onToggle}
362
+ aria-expanded={isExpanded}
363
+ className={cn(
364
+ "flex w-full items-center gap-3 px-6 py-3.5 text-left transition-colors hover:bg-muted/40",
365
+ isExpanded && "bg-muted/25"
366
+ )}
367
+ >
368
+ {headerContent}
369
+ </button>
370
+ ) : (
371
+ <div className="flex w-full items-center gap-3 px-6 py-3.5 text-left">
372
+ {headerContent}
373
+ </div>
374
+ )}
375
+
376
+ {isExpanded && (
377
+ <div className="px-6 pb-5">
378
+ {body ? (
379
+ isPlainText ? (
380
+ <pre className="font-sans text-sm leading-6 whitespace-pre-wrap text-foreground">
381
+ {message.bodyText}
382
+ </pre>
383
+ ) : (
384
+ <MailHtmlBody html={body} messageId={message.id} />
385
+ )
386
+ ) : (
387
+ <p className="py-2 text-sm text-muted-foreground">
388
+ This message has no content.
389
+ </p>
390
+ )}
391
+
392
+ {visibleAttachments.length ? (
393
+ <MailAttachmentList
394
+ messageId={message.id}
395
+ attachments={visibleAttachments}
396
+ className="mt-4"
397
+ />
398
+ ) : null}
399
+
400
+ {(onReply || onReplyAll || onForward) && (
401
+ <div className="mt-5 flex flex-wrap items-center gap-2">
402
+ {onReply && (
403
+ <Button variant="outline" size="sm" onClick={() => onReply(message)}>
404
+ <CornerUpLeft /> Reply
405
+ </Button>
406
+ )}
407
+ {onReplyAll && canReplyAll(message) && (
408
+ <Button variant="outline" size="sm" onClick={() => onReplyAll(message)}>
409
+ <CornerUpRight /> Reply all
410
+ </Button>
411
+ )}
412
+ {onForward && (
413
+ <Button variant="outline" size="sm" onClick={() => onForward(message)}>
414
+ <Forward /> Forward
415
+ </Button>
416
+ )}
417
+ </div>
418
+ )}
419
+ </div>
420
+ )}
421
+ </div>
422
+ );
423
+ }
424
+
425
+ export function MailDetail({
426
+ message,
427
+ loading,
428
+ onReply,
429
+ onReplyAll,
430
+ onForward,
431
+ onToggleTodo,
432
+ onMarkUnread,
433
+ onTrash,
434
+ onRestore,
435
+ onDeleteForever,
436
+ onCancelScheduled,
437
+ onLabelsChange,
438
+ onNoteChange,
439
+ className,
440
+ }: {
441
+ message?: MailMessage;
442
+ loading: boolean;
443
+ onReply?: (message: MailMessage) => void;
444
+ onReplyAll?: (message: MailMessage) => void;
445
+ onForward?: (message: MailMessage) => void;
446
+ onToggleTodo?: (message: MailMessage) => void;
447
+ onMarkUnread?: (message: MailMessage) => void;
448
+ onTrash?: (message: MailMessage) => void;
449
+ onRestore?: (message: MailMessage) => void;
450
+ onDeleteForever?: (message: MailMessage) => void;
451
+ onCancelScheduled?: (message: MailMessage) => void;
452
+ onLabelsChange?: (message: MailMessage, labels: MailLabel[]) => void;
453
+ onNoteChange?: (message: MailMessage, note: MailNote | undefined) => void;
454
+ className?: string;
455
+ }) {
456
+ const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
457
+
458
+ useEffect(() => {
459
+ if (!message) {
460
+ setExpandedIds(new Set());
461
+ return;
462
+ }
463
+ const thread = buildThread(message);
464
+ const latest = thread[thread.length - 1];
465
+ setExpandedIds(latest ? new Set([latest.id]) : new Set());
466
+ }, [message]);
467
+
468
+ const toggleExpanded = (id: number) => {
469
+ setExpandedIds((prev) => {
470
+ const next = new Set(prev);
471
+ if (next.has(id)) next.delete(id);
472
+ else next.add(id);
473
+ return next;
474
+ });
475
+ };
476
+
477
+ if (loading) {
478
+ return (
479
+ <div className={cn("flex flex-col gap-4 p-6", className)}>
480
+ <Skeleton className="h-7 w-2/3" />
481
+ <div className="flex items-center gap-3">
482
+ <Skeleton className="size-9 rounded-full" />
483
+ <div className="space-y-1.5">
484
+ <Skeleton className="h-3.5 w-40" />
485
+ <Skeleton className="h-3 w-56" />
486
+ </div>
487
+ </div>
488
+ <Separator />
489
+ <Skeleton className="h-3 w-full" />
490
+ <Skeleton className="h-3 w-5/6" />
491
+ <Skeleton className="h-3 w-4/6" />
492
+ <Skeleton className="h-3 w-3/6" />
493
+ </div>
494
+ );
495
+ }
496
+
497
+ if (!message) return null;
498
+
499
+ const thread = buildThread(message);
500
+ const isTrash = message.boxType === "trash";
501
+ const isScheduled = Boolean(message.scheduleSendAt);
502
+ const isProviderDraft = message.isDraft && !isLocalMailDraft(message);
503
+
504
+ return (
505
+ <div className={cn("flex flex-col", className)}>
506
+ {isScheduled && (
507
+ <div className="flex items-center gap-3 border-b border-amber-500/20 bg-amber-500/10 px-6 py-3 text-sm">
508
+ <Clock3 className="size-4 text-amber-600" />
509
+ <span className="flex-1">
510
+ Scheduled for {formatFullDate(message.scheduleSendAt)}
511
+ </span>
512
+ {!message.mailId ? (
513
+ <Button variant="outline" size="sm" onClick={() => onCancelScheduled?.(message)}>
514
+ Cancel and edit
515
+ </Button>
516
+ ) : (
517
+ <span className="text-xs text-muted-foreground">
518
+ Cancel this message in the original mail provider.
519
+ </span>
520
+ )}
521
+ </div>
522
+ )}
523
+ {isProviderDraft && (
524
+ <div className="border-b border-blue-500/20 bg-blue-500/10 px-6 py-3 text-sm">
525
+ This draft is stored by the original mail provider and is read-only here.
526
+ Edit or delete it in that mailbox to preserve provider attachments and state.
527
+ </div>
528
+ )}
529
+ <div className="border-b border-border/60 py-5 pl-6 pr-14">
530
+ <div className="flex items-start justify-between gap-4">
531
+ <div className="min-w-0">
532
+ <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
533
+ <h2 className="min-w-0 text-xl font-semibold tracking-[-0.02em] text-foreground">
534
+ {message.subject || "(no subject)"}
535
+ </h2>
536
+ {thread.length > 1 && (
537
+ <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
538
+ <MessagesSquare className="size-3" />
539
+ {thread.length} messages
540
+ </span>
541
+ )}
542
+ </div>
543
+ <div className="mt-2 flex flex-wrap items-center gap-1.5">
544
+ <MailLabelsEditor
545
+ message={message}
546
+ onChange={(labels) => onLabelsChange?.(message, labels)}
547
+ />
548
+ <MailNoteEditor
549
+ message={message}
550
+ onChange={(note) => onNoteChange?.(message, note)}
551
+ />
552
+ </div>
553
+ </div>
554
+
555
+ {!isScheduled && !isProviderDraft && <div className="flex shrink-0 items-center gap-1">
556
+ <Button
557
+ variant="ghost"
558
+ size="icon-sm"
559
+ title="Reply"
560
+ onClick={() => onReply?.(message)}
561
+ >
562
+ <CornerUpLeft />
563
+ </Button>
564
+ {canReplyAll(message) && (
565
+ <Button
566
+ variant="ghost"
567
+ size="icon-sm"
568
+ title="Reply all"
569
+ onClick={() => onReplyAll?.(message)}
570
+ >
571
+ <CornerUpRight />
572
+ </Button>
573
+ )}
574
+ <Button
575
+ variant="ghost"
576
+ size="icon-sm"
577
+ title="Forward"
578
+ onClick={() => onForward?.(message)}
579
+ >
580
+ <Forward />
581
+ </Button>
582
+ <Button
583
+ variant="ghost"
584
+ size="icon-sm"
585
+ aria-pressed={Boolean(message.isTodo)}
586
+ title={message.isTodo ? "Remove from todo" : "Add to todo"}
587
+ onClick={() => onToggleTodo?.(message)}
588
+ >
589
+ {message.isTodo ? (
590
+ <CircleCheckBig className="text-primary" />
591
+ ) : (
592
+ <Circle />
593
+ )}
594
+ </Button>
595
+ <Button
596
+ variant="ghost"
597
+ size="icon-sm"
598
+ title="Mark as unread"
599
+ onClick={() => onMarkUnread?.(message)}
600
+ >
601
+ <MailX />
602
+ </Button>
603
+ {isTrash ? (
604
+ <>
605
+ <Button
606
+ variant="ghost"
607
+ size="icon-sm"
608
+ title="Put back"
609
+ onClick={() => onRestore?.(message)}
610
+ >
611
+ <Undo2 />
612
+ </Button>
613
+ <Button
614
+ variant="ghost"
615
+ size="icon-sm"
616
+ title="Permanently delete"
617
+ className="text-destructive hover:text-destructive"
618
+ onClick={() => onDeleteForever?.(message)}
619
+ >
620
+ <Trash2 />
621
+ </Button>
622
+ </>
623
+ ) : (
624
+ <Button
625
+ variant="ghost"
626
+ size="icon-sm"
627
+ title="Move to trash"
628
+ onClick={() => onTrash?.(message)}
629
+ >
630
+ <Trash2 />
631
+ </Button>
632
+ )}
633
+ </div>}
634
+ </div>
635
+ </div>
636
+
637
+ <div className="flex flex-col">
638
+ {thread.map((threadMessage) => (
639
+ <MailThreadMessage
640
+ key={threadMessage.id}
641
+ message={threadMessage}
642
+ expanded={expandedIds.has(threadMessage.id)}
643
+ onToggle={() => toggleExpanded(threadMessage.id)}
644
+ collapsible={thread.length > 1}
645
+ onReply={isScheduled || isProviderDraft ? undefined : onReply}
646
+ onReplyAll={isScheduled || isProviderDraft ? undefined : onReplyAll}
647
+ onForward={isScheduled || isProviderDraft ? undefined : onForward}
648
+ />
649
+ ))}
650
+ </div>
651
+ </div>
652
+ );
653
+ }
@@ -0,0 +1,26 @@
1
+ export interface DebouncedDraftSaver<T> {
2
+ schedule(value: T): void;
3
+ cancel(): void;
4
+ }
5
+
6
+ /** Collapse rapid edits into one write while always saving the latest snapshot. */
7
+ export function createDebouncedDraftSaver<T>(
8
+ save: (value: T) => void | Promise<void>,
9
+ delayMs = 1_500
10
+ ): DebouncedDraftSaver<T> {
11
+ let timer: ReturnType<typeof setTimeout> | undefined;
12
+
13
+ return {
14
+ schedule(value) {
15
+ if (timer) clearTimeout(timer);
16
+ timer = setTimeout(() => {
17
+ timer = undefined;
18
+ void save(value);
19
+ }, delayMs);
20
+ },
21
+ cancel() {
22
+ if (timer) clearTimeout(timer);
23
+ timer = undefined;
24
+ },
25
+ };
26
+ }