@nocobase/portal-template-default 1.0.16 → 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.
- package/AGENTS.md +8 -0
- package/README.MD +4 -0
- package/dist/assets/{LineDraw-vPRTWiQ5.js → LineDraw-BBB5hwuJ.js} +1 -1
- package/dist/assets/{SymbolDraw-DOl_Tss3.js → SymbolDraw-CKS3uUb_.js} +1 -1
- package/dist/assets/{VisualMapping-j08W3KBm.js → VisualMapping-BCwaW3TD.js} +1 -1
- package/dist/assets/auth-method-demo-BdCCvEEz.js +1 -0
- package/dist/assets/{auto-login-provider-Bukw7zEe.js → auto-login-provider-DO5Il6Dp.js} +2 -2
- package/dist/assets/auto-login-provider-TQ08B6-i.js +1 -0
- package/dist/assets/auto-redirect-provider-ButXp0A3.js +1 -0
- package/dist/assets/auto-redirect-provider-DnGGP2Vn.js +1 -0
- package/dist/assets/auto-redirect-provider-ja-vFPaF.js +1 -0
- package/dist/assets/calendar-C7xnRgOT.js +1 -0
- package/dist/assets/{cas-sign-in-button-BQn3Mttx.js → cas-sign-in-button-BZCa4lR6.js} +1 -1
- package/dist/assets/{components-BT5mL9gW.js → components-_g-GjQeV.js} +3 -3
- package/dist/assets/{configuration-gate-BJoadoA6.js → configuration-gate-B4XVz7tS.js} +1 -1
- package/dist/assets/{createClipPathFromCoordSys-m8IbnJ1z.js → createClipPathFromCoordSys-z9Kmh-tu.js} +1 -1
- package/dist/assets/createSeriesDataSimply-CPqysF0K.js +1 -0
- package/dist/assets/{customGraphicKeyframeAnimation-CYLPurwK.js → customGraphicKeyframeAnimation-DMU5crAu.js} +1 -1
- package/dist/assets/demo-BPlae2bp.js +1 -0
- package/dist/assets/demo-Bj_Y0RrW.js +1 -0
- package/dist/assets/demo-CKPAQOib.js +1 -0
- package/dist/assets/demo-CXRTPvIx.js +1 -0
- package/dist/assets/demo-DJuVDu1n.js +1 -0
- package/dist/assets/demo-DUmWU7ik.js +1 -0
- package/dist/assets/demo-NhIzFRE1.js +1 -0
- package/dist/assets/{dingtalk-sign-in-button-DSluFaP9.js → dingtalk-sign-in-button-DMPgE5kT.js} +1 -1
- package/dist/assets/{echarts-preview-mRXoEciY.js → echarts-preview-R96Pbqmz.js} +1 -1
- package/dist/assets/{echarts-runtime-CvLr4Slh.js → echarts-runtime-Ce_z2eX_.js} +3 -3
- package/dist/assets/{echarts-runtime-advanced-BC1tvfQ0.js → echarts-runtime-advanced-Dr80KHJR.js} +1 -1
- package/dist/assets/{echarts-runtime-common-DhNaV5B8.js → echarts-runtime-common-DB95Yfc1.js} +1 -1
- package/dist/assets/{echarts-runtime-components-BiioS8t1.js → echarts-runtime-components-DCJEaTJk.js} +1 -1
- package/dist/assets/{echarts-runtime-hierarchy-CAWqYvFh.js → echarts-runtime-hierarchy-CezgZs9O.js} +1 -1
- package/dist/assets/{floating-RVbHvIiD.js → floating-O9WmiDXk.js} +2 -2
- package/dist/assets/index-BBKV6O5d.css +1 -0
- package/dist/assets/{index-Dw20Lzpp.js → index-CPzehPGe.js} +3 -3
- package/dist/assets/index-Cc6awvdg.js +1064 -0
- package/dist/assets/index-ChA0z9ke.js +38 -0
- package/dist/assets/{index-BysibBZu.js → index-DgB7uTMs.js} +2 -2
- package/dist/assets/{index-C-tdHirk.js → index-DroUAK80.js} +1 -1
- package/dist/assets/index-WKvygTBp.js +59 -0
- package/dist/assets/{labelHelper-DbITmOUW.js → labelHelper-Dk5jkiD-.js} +1 -1
- package/dist/assets/ldap-sign-in-form-CKN4l7qM.js +1 -0
- package/dist/assets/{oidc-sign-in-button-Cgpa8Qzj.js → oidc-sign-in-button-BIRonrpC.js} +1 -1
- package/dist/assets/{page-context-AU6RU0Qi.js → page-context-DAiW_dId.js} +4 -4
- package/dist/assets/prompt-card-rGZqpqkF.js +1 -0
- package/dist/assets/prompt-output-B4h3uLWf.js +1 -0
- package/dist/assets/saml-sign-in-button-B4ugaLIb.js +1 -0
- package/dist/assets/{sectorHelper-C6L782Ue.js → sectorHelper-BuxrrJrA.js} +1 -1
- package/dist/assets/shortcut-BhKXxJLs.js +47 -0
- package/dist/assets/{sms-sign-in-form-Bo2WLZuP.js → sms-sign-in-form-DKpxRDdZ.js} +1 -1
- package/dist/assets/{static.browser-CoPxsICt.js → static.browser-kzzpH0Xt.js} +1 -1
- package/dist/assets/switch-BdVxPIVU.js +1 -0
- package/dist/assets/{tool-cards-ipgJ_3Ec.js → tool-cards-CpczIke-.js} +3 -3
- package/dist/assets/{use-authenticator-sign-in-BzFeh7qe.js → use-authenticator-sign-in-CIj210ry.js} +1 -1
- package/dist/assets/use-dingtalk-sign-in-BW4kkXD3.js +1 -0
- package/dist/assets/{use-wecom-sign-in-PPIMLvGa.js → use-wecom-sign-in-dIqMtcN9.js} +1 -1
- package/dist/assets/{wecom-sign-in-button-rUq9RW8R.js → wecom-sign-in-button-Dn_2zDab.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.raw.html +2 -2
- package/package.json +9 -1
- package/src/App.tsx +5 -2
- package/src/app/extension.ts +2 -0
- package/src/app/extensions.tsx +18 -6
- package/src/app/route-access-guard.tsx +22 -0
- package/src/app/route-runtime.ts +173 -0
- package/src/app/routes.tsx +2 -2
- package/src/components/app-shell/header.tsx +56 -1
- package/src/components/app-shell/sidebar.tsx +14 -2
- package/src/extensions/nocobase-acl/README.md +28 -14
- package/src/extensions/nocobase-acl/demo/index.tsx +87 -15
- package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +1 -0
- package/src/extensions/nocobase-mail/README.md +74 -0
- package/src/extensions/nocobase-mail/components/index.ts +79 -0
- package/src/extensions/nocobase-mail/components/mail-api.ts +423 -0
- package/src/extensions/nocobase-mail/components/mail-attachment-list.tsx +67 -0
- package/src/extensions/nocobase-mail/components/mail-compose-attachments.tsx +166 -0
- package/src/extensions/nocobase-mail/components/mail-compose.tsx +1071 -0
- package/src/extensions/nocobase-mail/components/mail-detail.tsx +653 -0
- package/src/extensions/nocobase-mail/components/mail-draft-autosave.ts +26 -0
- package/src/extensions/nocobase-mail/components/mail-empty.tsx +48 -0
- package/src/extensions/nocobase-mail/components/mail-filters.tsx +277 -0
- package/src/extensions/nocobase-mail/components/mail-inbox.tsx +544 -0
- package/src/extensions/nocobase-mail/components/mail-inline-images.ts +69 -0
- package/src/extensions/nocobase-mail/components/mail-label-badge.tsx +25 -0
- package/src/extensions/nocobase-mail/components/mail-label-manager.tsx +271 -0
- package/src/extensions/nocobase-mail/components/mail-labels-editor.tsx +267 -0
- package/src/extensions/nocobase-mail/components/mail-mass-tracking.tsx +271 -0
- package/src/extensions/nocobase-mail/components/mail-note-editor.tsx +99 -0
- package/src/extensions/nocobase-mail/components/mail-recipient-input.tsx +260 -0
- package/src/extensions/nocobase-mail/components/mail-reply-quote.ts +28 -0
- package/src/extensions/nocobase-mail/components/mail-rich-editor.tsx +287 -0
- package/src/extensions/nocobase-mail/components/mail-send-actions.tsx +238 -0
- package/src/extensions/nocobase-mail/components/mail-senders.ts +61 -0
- package/src/extensions/nocobase-mail/components/mail-settings-drawer.tsx +708 -0
- package/src/extensions/nocobase-mail/components/mail-showcase-layout.tsx +64 -0
- package/src/extensions/nocobase-mail/components/mail-signature-manager.tsx +258 -0
- package/src/extensions/nocobase-mail/components/mail-signatures.ts +25 -0
- package/src/extensions/nocobase-mail/components/mail-table.tsx +476 -0
- package/src/extensions/nocobase-mail/components/mail-template-manager.tsx +204 -0
- package/src/extensions/nocobase-mail/components/mail-toolbar.tsx +155 -0
- package/src/extensions/nocobase-mail/components/mail-unread.tsx +111 -0
- package/src/extensions/nocobase-mail/components/types.ts +297 -0
- package/src/extensions/nocobase-mail/components/use-mail-compose.tsx +262 -0
- package/src/extensions/nocobase-mail/components/use-mail-mass-messages.ts +54 -0
- package/src/extensions/nocobase-mail/components/use-mail-messages.ts +113 -0
- package/src/extensions/nocobase-mail/components/use-mail-signatures.ts +122 -0
- package/src/extensions/nocobase-mail/components/use-mail-templates.ts +54 -0
- package/src/extensions/nocobase-mail/extension.tsx +105 -0
- package/src/extensions/nocobase-mail/index.ts +1 -0
- package/src/extensions/nocobase-mail/mail-demo-pages.tsx +503 -0
- package/src/extensions/nocobase-mail/mail-pages.tsx +201 -0
- package/src/extensions/nocobase-mail/tests/mail-quote-html-regression.mjs +51 -0
- package/src/extensions/nocobase-mail/tests/mail-regression.mjs +649 -0
- package/src/extensions/nocobase-users-example/app-routes.tsx +121 -0
- package/src/extensions/nocobase-users-example/extension.tsx +2 -100
- package/src/extensions/nocobase-users-example/index.ts +1 -0
- package/src/lib/nocobase/acl/menu.ts +17 -3
- package/src/lib/nocobase/acl/types.ts +4 -0
- package/src/locales/en-US.ts +2 -1
- package/src/locales/zh-CN.ts +2 -1
- package/src/providers/runtime-config.ts +18 -0
- package/src/routes.tsx +10 -0
- package/src/vite-env.d.ts +3 -0
- package/vite.config.ts +8 -0
- package/dist/assets/auth-method-demo-B2qcpY8z.js +0 -1
- package/dist/assets/auto-login-provider-BMtzvdxG.js +0 -1
- package/dist/assets/auto-redirect-provider-C6GNPg5r.js +0 -1
- package/dist/assets/auto-redirect-provider-D5c6kN2R.js +0 -1
- package/dist/assets/auto-redirect-provider-iTzGEDpU.js +0 -1
- package/dist/assets/calendar-Dv2RNnsr.js +0 -1
- package/dist/assets/card-C9Hpeq1c.js +0 -1
- package/dist/assets/createSeriesDataSimply-DKUV5b5e.js +0 -1
- package/dist/assets/demo-BWGOwqtJ.js +0 -1
- package/dist/assets/demo-BWbcvkya.js +0 -1
- package/dist/assets/demo-DIASSPgn.js +0 -1
- package/dist/assets/demo-DuZ6asUU.js +0 -1
- package/dist/assets/demo-Dxcy4bDE.js +0 -1
- package/dist/assets/demo-YpEC76uw.js +0 -1
- package/dist/assets/demo-wzkoR8T2.js +0 -1
- package/dist/assets/index-CBZbj45X.js +0 -58
- package/dist/assets/index-CgkdZ6Oc.js +0 -686
- package/dist/assets/index-NH3aqqQz.css +0 -1
- package/dist/assets/index-hSBzW-VA.js +0 -43
- package/dist/assets/ldap-sign-in-form-BOMNDTkK.js +0 -1
- package/dist/assets/prompt-card-FWugRgi-.js +0 -1
- package/dist/assets/prompt-output-DMTuTNab.js +0 -1
- package/dist/assets/saml-sign-in-button-qt8bMeoQ.js +0 -1
- package/dist/assets/shortcut-Bu7jT_-w.js +0 -47
- package/dist/assets/switch-DQkkIE7G.js +0 -1
- package/dist/assets/use-dingtalk-sign-in-mrq6K5Yx.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
|
+
}
|