@handled-ai/design-system 0.20.4 → 0.20.6

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 (34) hide show
  1. package/dist/components/conversation-panel.d.ts +28 -1
  2. package/dist/components/conversation-panel.js +180 -310
  3. package/dist/components/conversation-panel.js.map +1 -1
  4. package/dist/components/email-body.d.ts +15 -0
  5. package/dist/components/email-body.js +101 -0
  6. package/dist/components/email-body.js.map +1 -0
  7. package/dist/components/email-display-helpers.d.ts +34 -0
  8. package/dist/components/email-display-helpers.js +436 -0
  9. package/dist/components/email-display-helpers.js.map +1 -0
  10. package/dist/components/email-preview-card.d.ts +7 -4
  11. package/dist/components/email-preview-card.js +48 -25
  12. package/dist/components/email-preview-card.js.map +1 -1
  13. package/dist/components/timeline-activity.js +66 -42
  14. package/dist/components/timeline-activity.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal/safe-html.d.ts +1 -1
  19. package/dist/internal/safe-html.js +64 -3
  20. package/dist/internal/safe-html.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/components/__tests__/conversation-panel.test.tsx +230 -22
  23. package/src/components/__tests__/email-body.test.tsx +83 -0
  24. package/src/components/__tests__/email-display-helpers.test.ts +91 -0
  25. package/src/components/__tests__/email-preview-card.test.tsx +36 -2
  26. package/src/components/__tests__/timeline-activity.test.tsx +53 -1
  27. package/src/components/conversation-panel.tsx +227 -369
  28. package/src/components/email-body.tsx +126 -0
  29. package/src/components/email-display-helpers.ts +557 -0
  30. package/src/components/email-preview-card.tsx +54 -29
  31. package/src/components/timeline-activity.tsx +73 -53
  32. package/src/index.ts +2 -0
  33. package/src/internal/__tests__/safe-html.test.ts +34 -2
  34. package/src/internal/safe-html.ts +79 -4
@@ -42,12 +42,13 @@ import {
42
42
  import { cn } from "../lib/utils.js";
43
43
  import { getInitials } from "../lib/user-display.js";
44
44
  import { BRAND_ICONS } from "../lib/icons.js";
45
- import { htmlToTextSnippet, sanitizeHtml } from "../internal/safe-html.js";
46
45
  import { Avatar, AvatarFallback, AvatarImage } from "./avatar.js";
47
46
  import { Button } from "./button.js";
48
47
  import { Switch } from "./switch.js";
49
48
  import { Textarea } from "./textarea.js";
50
49
  import { RichTextToolbar } from "./rich-text-toolbar.js";
50
+ import { EmailBody } from "./email-body.js";
51
+ import { decodeEmailDisplayText, emailBodySnippet, formatAddressList, normalizeEmailSender } from "./email-display-helpers.js";
51
52
  import {
52
53
  Dialog,
53
54
  DialogContent,
@@ -56,260 +57,69 @@ import {
56
57
  DialogDescription,
57
58
  DialogFooter
58
59
  } from "./dialog.js";
59
- const PROSE = cn(
60
- "text-sm leading-[1.62] text-foreground/90 break-words",
61
- "[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
62
- "[&_a]:text-[#1a73e8] [&_a]:underline-offset-2 hover:[&_a]:underline",
63
- "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
64
- "[&_img]:max-w-full [&_img]:h-auto"
65
- );
66
60
  function escapeHtml(s) {
67
61
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
68
62
  }
69
63
  function textToHtml(text) {
70
- return text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${escapeHtml(p).replace(/\n/g, "<br>")}</p>`).join("");
64
+ return decodeEmailDisplayText(text).split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${escapeHtml(p).replace(/\n/g, "<br>")}</p>`).join("");
71
65
  }
72
- const SPLITTABLE_BLOCK_TAGS = /* @__PURE__ */ new Set(["p", "div"]);
73
- const WHOLE_BLOCK_TAGS = /* @__PURE__ */ new Set(["blockquote", "table", "ul", "ol", "hr"]);
74
- const BLOCK_TAGS = /* @__PURE__ */ new Set([...SPLITTABLE_BLOCK_TAGS, ...WHOLE_BLOCK_TAGS]);
75
- const BR_TAG_RE = /<br\s*\/?>/gi;
76
- const HTML_BLOCK_START_RE = /<(p|div|blockquote|table|ul|ol|hr)\b[^>]*>/i;
77
- const SIGNATURE_DELIMITER_RE = /^--\s*$/;
78
- const SIGNOFF_RE = /^(?:thanks,|thank you,|best,|regards,|sincerely,)$/i;
79
- const DETAILS_START_RE = /^(?:confidentiality notice\b|this message and any attachments\b|this email and any attachments\b|the information contained in this message\b|this communication may contain\b|unsubscribe\b|manage your preferences\b)/i;
80
- const CONTACT_DETAIL_RE = /(?:@|https?:\/\/|www\.|\+?\d[\d\s().-]{6,}\d|\b(?:ceo|cfo|cto|coo|founder|co-founder|director|manager|vp|vice president|president|head of|sales|marketing|operations|account|customer success|success|support|engineer|consultant|partner|principal|advisor|associate)\b|\b(?:inc|llc|ltd|corp|corporation|company|co\.)\b)/i;
81
- function decodeHtmlText(value) {
82
- const withoutTags = value.replace(BR_TAG_RE, "\n").replace(/<\/(p|div|blockquote|li|tr|table|ul|ol)>/gi, "\n").replace(/<[^>]*>/g, "");
83
- if (typeof document !== "undefined") {
84
- const textarea = document.createElement("textarea");
85
- textarea.innerHTML = withoutTags;
86
- return textarea.value;
87
- }
88
- return withoutTags.replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'");
89
- }
90
- function htmlToVisibleText(html) {
91
- return decodeHtmlText(html).replace(/\u00a0/g, " ").replace(/[ \t]+/g, " ").trim();
92
- }
93
- function serializeNode(node) {
94
- const host = document.createElement("div");
95
- host.appendChild(node.cloneNode(true));
96
- return host.innerHTML;
66
+ function displayParticipant(person) {
67
+ return normalizeEmailSender({ name: person.name, email: person.email, fallbackName: person.email || person.name });
97
68
  }
98
- function wrapHtmlLike(element, innerHtml) {
99
- const clone = element.cloneNode(false);
100
- clone.innerHTML = innerHtml;
101
- return clone.outerHTML;
102
- }
103
- function hasDirectBr(nodes) {
104
- return nodes.some((node) => node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === "br");
105
- }
106
- function hasDirectBlockChild(element) {
107
- return Array.from(element.children).some((child) => {
108
- const tagName = child.tagName.toLowerCase();
109
- return tagName !== "br" && BLOCK_TAGS.has(tagName);
110
- });
111
- }
112
- function makeHtmlSegment(html) {
113
- const visibleText = htmlToVisibleText(html);
114
- if (!html.trim() || !visibleText && !/<(?:img|hr)\b/i.test(html)) return null;
115
- return { html, visibleText };
116
- }
117
- function splitInlineNodes(nodes, wrapper) {
118
- const containsBr = hasDirectBr(nodes);
119
- const chunks = [[]];
120
- nodes.forEach((node) => {
121
- var _a;
122
- if (node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === "br") {
123
- chunks.push([]);
124
- return;
125
- }
126
- (_a = chunks[chunks.length - 1]) == null ? void 0 : _a.push(serializeNode(node));
127
- });
128
- return chunks.map((chunk) => chunk.join("")).map((innerHtml) => {
129
- if (wrapper) return wrapHtmlLike(wrapper, innerHtml);
130
- return containsBr ? `<div>${innerHtml}</div>` : innerHtml;
131
- }).map(makeHtmlSegment).filter((segment) => Boolean(segment));
69
+ function firstName(name) {
70
+ return decodeEmailDisplayText(name).split(" ")[0] || decodeEmailDisplayText(name);
132
71
  }
133
- function splitElementSegment(element) {
134
- const tagName = element.tagName.toLowerCase();
135
- if (tagName === "div" && hasDirectBlockChild(element)) {
136
- const childSegments = splitHtmlNodes(Array.from(element.childNodes));
137
- return childSegments.length ? childSegments : [makeHtmlSegment(element.outerHTML)].filter(Boolean);
138
- }
139
- if (SPLITTABLE_BLOCK_TAGS.has(tagName) && hasDirectBr(Array.from(element.childNodes)) && !hasDirectBlockChild(element)) {
140
- return splitInlineNodes(Array.from(element.childNodes), element);
141
- }
142
- const segment = makeHtmlSegment(element.outerHTML);
143
- return segment ? [segment] : [];
72
+ function sameEmail(a, b) {
73
+ return Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase());
144
74
  }
145
- function splitHtmlNodes(nodes) {
146
- const segments = [];
147
- let inlineNodes = [];
148
- const flushInline = () => {
149
- if (!inlineNodes.length) return;
150
- segments.push(...splitInlineNodes(inlineNodes));
151
- inlineNodes = [];
152
- };
153
- nodes.forEach((node) => {
154
- if (node.nodeType === Node.ELEMENT_NODE) {
155
- const element = node;
156
- const tagName = element.tagName.toLowerCase();
157
- if (BLOCK_TAGS.has(tagName)) {
158
- flushInline();
159
- segments.push(...splitElementSegment(element));
160
- return;
161
- }
162
- }
163
- inlineNodes.push(node);
164
- });
165
- flushInline();
166
- return segments;
75
+ function messageBodySnippet(message, maxLength = 140) {
76
+ return emailBodySnippet({ bodyHtml: message.bodyHtml, body: message.body }, maxLength);
167
77
  }
168
- function findMatchingCloseTag(html, tagName, openTagEnd) {
169
- if (tagName === "hr") return openTagEnd + 1;
170
- const tagPattern = new RegExp(`</?${tagName}\\b[^>]*>`, "gi");
171
- tagPattern.lastIndex = openTagEnd + 1;
172
- let depth = 1;
173
- let match;
174
- while ((match = tagPattern.exec(html)) !== null) {
175
- const rawTag = match[0];
176
- if (/^<\//.test(rawTag)) depth -= 1;
177
- else if (!/\/\s*>$/.test(rawTag)) depth += 1;
178
- if (depth === 0) return tagPattern.lastIndex;
78
+ function parseMessageTimestampValue(value) {
79
+ if (value == null || value === "") return null;
80
+ if (value instanceof Date) {
81
+ const time = value.getTime();
82
+ return Number.isNaN(time) ? null : time;
179
83
  }
180
- return html.length;
181
- }
182
- function splitHtmlSegmentsFallback(html) {
183
- const segments = [];
184
- let cursor = 0;
185
- const pushInline = (inlineHtml) => {
186
- const chunks = inlineHtml.split(BR_TAG_RE);
187
- const hadBr = chunks.length > 1;
188
- chunks.forEach((chunk) => {
189
- const segment = makeHtmlSegment(hadBr ? `<div>${chunk}</div>` : chunk);
190
- if (segment) segments.push(segment);
191
- });
192
- };
193
- while (cursor < html.length) {
194
- const rest = html.slice(cursor);
195
- const match = HTML_BLOCK_START_RE.exec(rest);
196
- if (!match || match.index === void 0) {
197
- pushInline(rest);
198
- break;
199
- }
200
- if (match.index > 0) pushInline(rest.slice(0, match.index));
201
- const tagStart = cursor + match.index;
202
- const rawOpen = match[0];
203
- const tagName = match[1].toLowerCase();
204
- const openTagEnd = tagStart + rawOpen.length - 1;
205
- const segmentEnd = findMatchingCloseTag(html, tagName, openTagEnd);
206
- const blockHtml = html.slice(tagStart, segmentEnd);
207
- if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
208
- const openTag = rawOpen;
209
- const closeTag = `</${tagName}>`;
210
- const inner = blockHtml.replace(new RegExp(`^${rawOpen.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"), "").replace(new RegExp(`${closeTag}$`, "i"), "");
211
- inner.split(BR_TAG_RE).forEach((chunk) => {
212
- const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`);
213
- if (segment) segments.push(segment);
214
- });
215
- } else {
216
- const segment = makeHtmlSegment(blockHtml);
217
- if (segment) segments.push(segment);
218
- }
219
- cursor = segmentEnd;
84
+ if (typeof value === "number") {
85
+ if (!Number.isFinite(value)) return null;
86
+ return value < 1e10 ? value * 1e3 : value;
220
87
  }
221
- return segments;
222
- }
223
- function segmentHtmlMessage(html) {
224
- if (typeof document === "undefined" || typeof Node === "undefined") {
225
- return splitHtmlSegmentsFallback(html);
88
+ const trimmed = value.trim();
89
+ if (!trimmed) return null;
90
+ if (/^\d+$/.test(trimmed)) {
91
+ const numeric = Number(trimmed);
92
+ if (!Number.isFinite(numeric)) return null;
93
+ return numeric < 1e10 ? numeric * 1e3 : numeric;
226
94
  }
227
- const template = document.createElement("template");
228
- template.innerHTML = html;
229
- return splitHtmlNodes(Array.from(template.content.childNodes));
230
- }
231
- function segmentTextMessage(text) {
232
- var _a;
233
- const lines = (_a = text.match(/[^\n]*(?:\n|$)/g)) != null ? _a : [];
234
- return lines.filter((line, index) => line.length > 0 && !(index === lines.length - 1 && line === "")).map((line) => ({ text: line, visibleText: line.replace(/\u00a0/g, " ").trim() }));
235
- }
236
- function firstVisibleLine(text) {
237
- var _a, _b;
238
- return (_b = (_a = text.replace(/\u00a0/g, " ").trimStart().split(/\r?\n/).find((line) => line.trim())) == null ? void 0 : _a.trim()) != null ? _b : "";
239
- }
240
- function isLikelySenderNameLine(line) {
241
- if (!line || line.length > 60) return false;
242
- if (/[,@:;!?]|https?:\/\/|www\.|\d/.test(line)) return false;
243
- const words = line.split(/\s+/).filter(Boolean);
244
- if (words.length < 1 || words.length > 4) return false;
245
- return words.every((word) => /^[A-Z][A-Za-z'.-]*$/.test(word));
246
- }
247
- function nextVisibleSegmentText(segments, fromIndex) {
248
- var _a, _b;
249
- for (let index = fromIndex + 1; index < segments.length; index += 1) {
250
- const text = firstVisibleLine((_b = (_a = segments[index]) == null ? void 0 : _a.visibleText) != null ? _b : "");
251
- if (text) return text;
95
+ const parsed = Date.parse(trimmed);
96
+ return Number.isNaN(parsed) ? null : parsed;
97
+ }
98
+ function messageTimestamp(message) {
99
+ const directional = message.direction === "outbound" ? [message.sentAt, message.sent_at] : [message.receivedAt, message.received_at];
100
+ const candidates = [
101
+ ...directional,
102
+ message.rawTimestamp,
103
+ message.timestamp,
104
+ message.gmailInternalDate,
105
+ message.internalDate,
106
+ message.internal_date,
107
+ message.rfc822Date,
108
+ message.dateHeader
109
+ ];
110
+ for (const candidate of candidates) {
111
+ const parsed = parseMessageTimestampValue(candidate);
112
+ if (parsed !== null) return parsed;
252
113
  }
253
- return "";
114
+ return null;
254
115
  }
255
- function isFooterBoundary(segments, index) {
256
- var _a, _b;
257
- const line = firstVisibleLine((_b = (_a = segments[index]) == null ? void 0 : _a.visibleText) != null ? _b : "");
258
- if (!line) return false;
259
- if (SIGNATURE_DELIMITER_RE.test(line) || DETAILS_START_RE.test(line)) return true;
260
- const nextText = nextVisibleSegmentText(segments, index);
261
- if (SIGNOFF_RE.test(line)) return Boolean(nextText && (isLikelySenderNameLine(nextText) || CONTACT_DETAIL_RE.test(nextText)));
262
- return isLikelySenderNameLine(line) && CONTACT_DETAIL_RE.test(nextText);
263
- }
264
- function splitFooterSegments(segments) {
265
- const visibleIndexes = segments.map((segment, index) => segment.visibleText ? index : -1).filter((index) => index >= 0);
266
- const visibleCount = visibleIndexes.length;
267
- if (visibleCount < 2) return { bodySegments: segments, detailsSegments: [] };
268
- const trailingCount = Math.max(Math.ceil(visibleCount * 0.4), 8);
269
- const firstTrailingOrdinal = Math.max(1, visibleCount - trailingCount);
270
- for (let ordinal = firstTrailingOrdinal; ordinal < visibleCount; ordinal += 1) {
271
- const index = visibleIndexes[ordinal];
272
- if (ordinal > 0 && isFooterBoundary(segments, index)) {
273
- return { bodySegments: segments.slice(0, index), detailsSegments: segments.slice(index) };
116
+ function sortMessagesChronologically(messages) {
117
+ return messages.map((message, index) => ({ message, index, timestamp: messageTimestamp(message) })).sort((a, b) => {
118
+ if (a.timestamp !== null && b.timestamp !== null && a.timestamp !== b.timestamp) {
119
+ return a.timestamp - b.timestamp;
274
120
  }
275
- }
276
- return { bodySegments: segments, detailsSegments: [] };
277
- }
278
- function splitMessageHtml(rawHtml) {
279
- const sanitizedHtml = sanitizeHtml(rawHtml);
280
- const { bodySegments, detailsSegments } = splitFooterSegments(segmentHtmlMessage(sanitizedHtml));
281
- if (!detailsSegments.length) return { bodyHtml: sanitizedHtml, detailsHtml: "" };
282
- return {
283
- bodyHtml: bodySegments.map((segment) => {
284
- var _a;
285
- return (_a = segment.html) != null ? _a : "";
286
- }).join(""),
287
- detailsHtml: detailsSegments.map((segment) => {
288
- var _a;
289
- return (_a = segment.html) != null ? _a : "";
290
- }).join("")
291
- };
292
- }
293
- function splitMessageText(text) {
294
- const { bodySegments, detailsSegments } = splitFooterSegments(segmentTextMessage(text));
295
- if (!detailsSegments.length) return { bodyText: text, detailsText: "" };
296
- return {
297
- bodyText: bodySegments.map((segment) => {
298
- var _a;
299
- return (_a = segment.text) != null ? _a : "";
300
- }).join(""),
301
- detailsText: detailsSegments.map((segment) => {
302
- var _a;
303
- return (_a = segment.text) != null ? _a : "";
304
- }).join("")
305
- };
306
- }
307
- function messageBodySnippet(message, maxLength = 140) {
308
- var _a, _b, _c;
309
- if (message.bodyHtml) {
310
- return htmlToTextSnippet(splitMessageHtml(message.bodyHtml).bodyHtml, maxLength);
311
- }
312
- return (_c = (_b = splitMessageText((_a = message.body) != null ? _a : "").bodyText.split("\n").find((line) => line.trim())) == null ? void 0 : _b.trim()) != null ? _c : "";
121
+ return a.index - b.index;
122
+ }).map((entry) => entry.message);
313
123
  }
314
124
  function GmailMark({ size = 14 }) {
315
125
  return (
@@ -327,14 +137,13 @@ function GmailMark({ size = 14 }) {
327
137
  );
328
138
  }
329
139
  function PersonAvatar({ person, size = "sm" }) {
140
+ var _a;
141
+ const display = displayParticipant(person);
330
142
  return /* @__PURE__ */ jsxs(Avatar, { size, children: [
331
- person.avatarUrl ? /* @__PURE__ */ jsx(AvatarImage, { src: person.avatarUrl, alt: person.name }) : null,
332
- /* @__PURE__ */ jsx(AvatarFallback, { className: "bg-muted text-muted-foreground text-[10px] font-medium uppercase", children: getInitials({ name: person.name, email: person.email }) })
143
+ person.avatarUrl ? /* @__PURE__ */ jsx(AvatarImage, { src: person.avatarUrl, alt: display.name }) : null,
144
+ /* @__PURE__ */ jsx(AvatarFallback, { className: "bg-muted text-muted-foreground text-[10px] font-medium uppercase", children: getInitials({ name: display.name, email: (_a = display.email) != null ? _a : person.email }) })
333
145
  ] });
334
146
  }
335
- function firstName(name) {
336
- return name.split(" ")[0] || name;
337
- }
338
147
  const STATUS_PILL = {
339
148
  responded: { label: "New reply", cls: "bg-status-active-bg text-status-active-fg border-status-active-border" },
340
149
  draft: { label: "Draft", cls: "bg-background text-foreground/80 border-border" },
@@ -350,15 +159,57 @@ const STATUS_DOT = {
350
159
  function effectiveStatus(t) {
351
160
  return t.canReply === false ? "viewing" : t.status;
352
161
  }
162
+ function disabledOpenInGmailReason(thread) {
163
+ var _a, _b;
164
+ return ((_a = thread.openInGmailDisabledReason) == null ? void 0 : _a.trim()) || ((_b = thread.replyDisabledReason) == null ? void 0 : _b.trim()) || "Gmail access is not available for this thread.";
165
+ }
166
+ function canOpenInGmail(thread, onOpenInGmail) {
167
+ return thread.openInGmailDisabled !== true && Boolean(thread.openInGmailUrl || onOpenInGmail);
168
+ }
169
+ function OpenInGmailButton({
170
+ thread,
171
+ onOpenInGmail,
172
+ label = "Open in Gmail"
173
+ }) {
174
+ const hasConfiguredAction = Boolean(thread.openInGmailUrl || onOpenInGmail || thread.openInGmailDisabled || thread.openInGmailDisabledReason);
175
+ if (!hasConfiguredAction) return null;
176
+ const disabled = !canOpenInGmail(thread, onOpenInGmail);
177
+ const disabledReason = disabled ? disabledOpenInGmailReason(thread) : void 0;
178
+ if (!disabled && thread.openInGmailUrl) {
179
+ return /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", asChild: true, children: /* @__PURE__ */ jsxs("a", { href: thread.openInGmailUrl, target: "_blank", rel: "noopener noreferrer", children: [
180
+ /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
181
+ " ",
182
+ label
183
+ ] }) });
184
+ }
185
+ return /* @__PURE__ */ jsx("span", { className: "inline-flex", title: disabledReason, children: /* @__PURE__ */ jsxs(
186
+ Button,
187
+ {
188
+ type: "button",
189
+ variant: "ghost",
190
+ size: "sm",
191
+ disabled,
192
+ "aria-disabled": disabled || void 0,
193
+ "aria-label": disabledReason ? `${label}: ${disabledReason}` : label,
194
+ onClick: disabled ? void 0 : () => onOpenInGmail == null ? void 0 : onOpenInGmail(thread.threadId),
195
+ children: [
196
+ /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
197
+ " ",
198
+ label
199
+ ]
200
+ }
201
+ ) });
202
+ }
353
203
  function MessageView({
354
204
  message,
355
205
  expanded,
356
206
  onToggle,
357
207
  me
358
208
  }) {
359
- var _a, _b;
209
+ var _a;
360
210
  const [quoteOpen, setQuoteOpen] = React.useState(false);
361
- const [detailsOpen, setDetailsOpen] = React.useState(false);
211
+ const fromDisplay = displayParticipant(message.from);
212
+ const toDisplay = displayParticipant(message.to);
362
213
  if (!expanded) {
363
214
  const snippet = messageBodySnippet(message, 140);
364
215
  return /* @__PURE__ */ jsxs(
@@ -371,7 +222,7 @@ function MessageView({
371
222
  children: [
372
223
  /* @__PURE__ */ jsx(PersonAvatar, { person: message.from }),
373
224
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground min-w-0 flex-1 truncate text-[13px]", children: [
374
- /* @__PURE__ */ jsx("b", { className: "text-foreground", children: firstName(message.from.name) }),
225
+ /* @__PURE__ */ jsx("b", { className: "text-foreground", children: firstName(fromDisplay.name) }),
375
226
  " \xB7 ",
376
227
  snippet
377
228
  ] }),
@@ -381,10 +232,8 @@ function MessageView({
381
232
  }
382
233
  );
383
234
  }
384
- const toLabel = me && message.to.email === me.email ? "me" : firstName(message.to.name);
385
- const htmlParts = message.bodyHtml ? splitMessageHtml(message.bodyHtml) : null;
386
- const textParts = message.bodyHtml ? null : splitMessageText((_b = message.body) != null ? _b : "");
387
- const hasDetails = Boolean((htmlParts == null ? void 0 : htmlParts.detailsHtml) || (textParts == null ? void 0 : textParts.detailsText));
235
+ const meDisplay = me ? displayParticipant(me) : null;
236
+ const toLabel = meDisplay && sameEmail(toDisplay.email, meDisplay.email) ? "me" : firstName(toDisplay.name);
388
237
  return /* @__PURE__ */ jsxs("div", { "data-slot": "conv-message", className: "rounded-md border border-border bg-background", children: [
389
238
  /* @__PURE__ */ jsxs(
390
239
  "button",
@@ -396,12 +245,12 @@ function MessageView({
396
245
  /* @__PURE__ */ jsx(PersonAvatar, { person: message.from, size: "default" }),
397
246
  /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
398
247
  /* @__PURE__ */ jsxs("span", { className: "flex flex-wrap items-baseline gap-x-1.5", children: [
399
- /* @__PURE__ */ jsx("span", { className: "text-[13px] font-semibold", children: message.from.name }),
400
- /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/60 truncate text-xs", children: [
248
+ /* @__PURE__ */ jsx("span", { className: "text-[13px] font-semibold", children: fromDisplay.name }),
249
+ fromDisplay.email ? /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/60 truncate text-xs", children: [
401
250
  "<",
402
- message.from.email,
251
+ fromDisplay.email,
403
252
  ">"
404
- ] })
253
+ ] }) : null
405
254
  ] }),
406
255
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground block text-xs", children: [
407
256
  "to ",
@@ -420,20 +269,16 @@ function MessageView({
420
269
  }
421
270
  ),
422
271
  /* @__PURE__ */ jsxs("div", { className: "px-3 pb-2.5", children: [
423
- htmlParts ? /* @__PURE__ */ jsx("div", { "data-slot": "conv-message-body", className: PROSE, dangerouslySetInnerHTML: { __html: htmlParts.bodyHtml } }) : /* @__PURE__ */ jsx("div", { "data-slot": "conv-message-body", className: cn(PROSE, "whitespace-pre-line"), children: textParts == null ? void 0 : textParts.bodyText }),
424
- hasDetails ? /* @__PURE__ */ jsxs("div", { className: "mt-2", children: [
425
- /* @__PURE__ */ jsx(
426
- "button",
427
- {
428
- type: "button",
429
- onClick: () => setDetailsOpen((v) => !v),
430
- className: "text-muted-foreground hover:text-foreground hover:bg-muted rounded px-1.5 text-xs leading-5",
431
- "aria-expanded": detailsOpen,
432
- children: detailsOpen ? "Hide signature/details" : "Show signature/details"
433
- }
434
- ),
435
- detailsOpen ? /* @__PURE__ */ jsx("div", { className: "border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]", children: htmlParts ? /* @__PURE__ */ jsx("div", { "data-slot": "conv-message-details", className: PROSE, dangerouslySetInnerHTML: { __html: htmlParts.detailsHtml } }) : /* @__PURE__ */ jsx("div", { "data-slot": "conv-message-details", className: cn(PROSE, "whitespace-pre-line"), children: textParts == null ? void 0 : textParts.detailsText }) }) : null
436
- ] }) : null,
272
+ /* @__PURE__ */ jsx("div", { "data-slot": "conv-message-body", children: /* @__PURE__ */ jsx(
273
+ EmailBody,
274
+ {
275
+ html: message.bodyHtml,
276
+ text: message.body,
277
+ variant: "history",
278
+ collapseDetails: true,
279
+ className: "text-sm"
280
+ }
281
+ ) }),
437
282
  message.quoted ? /* @__PURE__ */ jsxs("div", { className: "mt-2", children: [
438
283
  /* @__PURE__ */ jsx(
439
284
  "button",
@@ -446,8 +291,8 @@ function MessageView({
446
291
  }
447
292
  ),
448
293
  quoteOpen ? /* @__PURE__ */ jsxs("div", { className: "border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]", children: [
449
- /* @__PURE__ */ jsx("p", { className: "mb-1", dangerouslySetInnerHTML: { __html: sanitizeHtml(message.quoted.attr) } }),
450
- /* @__PURE__ */ jsx("div", { className: PROSE, dangerouslySetInnerHTML: { __html: sanitizeHtml(message.quoted.html) } })
294
+ /* @__PURE__ */ jsx("p", { className: "mb-1", children: decodeEmailDisplayText(message.quoted.attr) }),
295
+ /* @__PURE__ */ jsx("div", { "data-slot": "conv-quoted-body", children: /* @__PURE__ */ jsx(EmailBody, { html: message.quoted.html, variant: "history", collapseDetails: false }) })
451
296
  ] }) : null
452
297
  ] }) : null
453
298
  ] })
@@ -462,9 +307,10 @@ function ReplyComposer({
462
307
  onClose,
463
308
  onSend,
464
309
  onDraft,
465
- onPreviewReply
310
+ onPreviewReply,
311
+ draftDisabledReason
466
312
  }) {
467
- var _a, _b, _c, _d;
313
+ var _a, _b, _c, _d, _e, _f;
468
314
  const [body, setBody] = React.useState((_a = thread.draft) != null ? _a : "");
469
315
  const [sig, setSig] = React.useState(true);
470
316
  const [preview, setPreview] = React.useState(false);
@@ -473,13 +319,14 @@ function ReplyComposer({
473
319
  const [sendError, setSendError] = React.useState(null);
474
320
  const ccList = replyAll ? (_b = thread.cc) != null ? _b : [] : [];
475
321
  const subject = /^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`;
322
+ const draftDisabled = Boolean(draftDisabledReason);
476
323
  const localPreviewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "");
477
324
  const openPreview = async () => {
478
325
  var _a2, _b2;
479
326
  setPreview(true);
480
327
  setSendError(null);
481
328
  if (!onPreviewReply) {
482
- setPreviewState({ loading: false, html: sanitizeHtml(localPreviewHtml), confirmationToken: null, error: null, local: true });
329
+ setPreviewState({ loading: false, html: localPreviewHtml, confirmationToken: null, error: null, local: true });
483
330
  return;
484
331
  }
485
332
  setPreviewState({ loading: true, html: null, confirmationToken: null, error: null, local: false });
@@ -487,7 +334,7 @@ function ReplyComposer({
487
334
  const result = await onPreviewReply({ threadId: thread.threadId, body, includeSignature: sig, replyAll });
488
335
  setPreviewState({
489
336
  loading: false,
490
- html: sanitizeHtml((_a2 = result.htmlBody) != null ? _a2 : ""),
337
+ html: (_a2 = result.htmlBody) != null ? _a2 : "",
491
338
  confirmationToken: (_b2 = result.confirmationToken) != null ? _b2 : null,
492
339
  error: null,
493
340
  local: false
@@ -516,6 +363,7 @@ function ReplyComposer({
516
363
  }
517
364
  };
518
365
  const handleDraft = async () => {
366
+ if (draftDisabled) return;
519
367
  setSending(true);
520
368
  setSendError(null);
521
369
  try {
@@ -541,7 +389,7 @@ function ReplyComposer({
541
389
  ] })
542
390
  ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
543
391
  "Reply to ",
544
- /* @__PURE__ */ jsx("b", { children: firstName(thread.contact.name) })
392
+ /* @__PURE__ */ jsx("b", { children: firstName(displayParticipant(thread.contact).name) })
545
393
  ] }) }),
546
394
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground inline-flex items-center gap-1 text-[11px]", children: [
547
395
  /* @__PURE__ */ jsx(GitMerge, { size: 11 }),
@@ -552,12 +400,15 @@ function ReplyComposer({
552
400
  /* @__PURE__ */ jsxs("div", { className: "border-border mb-2 space-y-1 border-b pb-2 text-[13px]", children: [
553
401
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
554
402
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground w-12 shrink-0 text-[11px] font-medium", children: "To" }),
555
- /* @__PURE__ */ jsx("span", { className: "font-medium", children: thread.contact.name }),
556
- /* @__PURE__ */ jsx("span", { className: "text-muted-foreground/60 truncate text-xs", children: thread.contact.email })
403
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: displayParticipant(thread.contact).name }),
404
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground/60 truncate text-xs", children: (_c = displayParticipant(thread.contact).email) != null ? _c : thread.contact.email })
557
405
  ] }),
558
406
  replyAll && ccList.length ? /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-1.5", children: [
559
407
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground w-12 shrink-0 text-[11px] font-medium", children: "Cc" }),
560
- /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: ccList.map((c) => c.name).join(", ") })
408
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: formatAddressList(ccList.map((c) => {
409
+ var _a2;
410
+ return `${displayParticipant(c).name} <${(_a2 = displayParticipant(c).email) != null ? _a2 : c.email}>`;
411
+ })) })
561
412
  ] }) : null,
562
413
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
563
414
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground w-12 shrink-0 text-[11px] font-medium", children: "Subject" }),
@@ -623,17 +474,20 @@ function ReplyComposer({
623
474
  /* @__PURE__ */ jsxs("div", { className: "border-border space-y-1 rounded-md border p-3 text-[13px]", children: [
624
475
  /* @__PURE__ */ jsxs("div", { children: [
625
476
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "To " }),
626
- /* @__PURE__ */ jsx("b", { children: thread.contact.name }),
477
+ /* @__PURE__ */ jsx("b", { children: displayParticipant(thread.contact).name }),
627
478
  " ",
628
479
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/60", children: [
629
480
  "<",
630
- thread.contact.email,
481
+ (_d = displayParticipant(thread.contact).email) != null ? _d : thread.contact.email,
631
482
  ">"
632
483
  ] })
633
484
  ] }),
634
485
  replyAll && ccList.length ? /* @__PURE__ */ jsxs("div", { className: "text-muted-foreground", children: [
635
486
  "Cc ",
636
- ccList.map((c) => c.name).join(", ")
487
+ formatAddressList(ccList.map((c) => {
488
+ var _a2;
489
+ return `${displayParticipant(c).name} <${(_a2 = displayParticipant(c).email) != null ? _a2 : c.email}>`;
490
+ }))
637
491
  ] }) : null,
638
492
  /* @__PURE__ */ jsxs("div", { children: [
639
493
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "Subject " }),
@@ -647,26 +501,27 @@ function ReplyComposer({
647
501
  "div",
648
502
  {
649
503
  "data-slot": "conv-preview-body",
650
- "data-confirmation-token": (_c = previewState.confirmationToken) != null ? _c : void 0,
651
- className: cn(PROSE, "max-h-72 overflow-auto"),
652
- dangerouslySetInnerHTML: { __html: (_d = previewState.html) != null ? _d : "" }
504
+ "data-confirmation-token": (_e = previewState.confirmationToken) != null ? _e : void 0,
505
+ className: "max-h-72 overflow-auto",
506
+ children: /* @__PURE__ */ jsx(EmailBody, { html: (_f = previewState.html) != null ? _f : "", variant: "preview", collapseDetails: false, defaultDetailsOpen: true })
653
507
  }
654
508
  ),
655
509
  sendError ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-destructive text-sm", children: sendError }) : null,
656
510
  /* @__PURE__ */ jsxs(DialogFooter, { className: "sm:justify-between", children: [
657
- /* @__PURE__ */ jsxs(
511
+ /* @__PURE__ */ jsx("span", { className: "inline-flex", title: draftDisabledReason != null ? draftDisabledReason : void 0, children: /* @__PURE__ */ jsxs(
658
512
  "button",
659
513
  {
660
514
  type: "button",
661
- disabled: sending || previewState.loading,
515
+ disabled: sending || previewState.loading || draftDisabled,
662
516
  onClick: handleDraft,
517
+ "aria-label": draftDisabledReason ? `Open draft in Gmail: ${draftDisabledReason}` : "Open draft in Gmail",
663
518
  className: "text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50",
664
519
  children: [
665
520
  /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
666
521
  " Open draft in Gmail"
667
522
  ]
668
523
  }
669
- ),
524
+ ) }),
670
525
  /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
671
526
  /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", disabled: sending, onClick: () => {
672
527
  setPreview(false);
@@ -701,17 +556,30 @@ function ThreadBody({
701
556
  onPreviewReply,
702
557
  onOpenInGmail
703
558
  }) {
559
+ var _a;
704
560
  const canReply = thread.canReply !== false;
561
+ const replyDisabledReason = ((_a = thread.replyDisabledReason) == null ? void 0 : _a.trim()) || "You are not a participant on this thread, so replying is disabled here.";
562
+ const draftDisabledReason = onCreateGmailDraft ? null : "Gmail draft creation is not available for this thread.";
705
563
  const hasCc = !!(thread.cc && thread.cc.length);
564
+ const sortedMessages = React.useMemo(() => sortMessagesChronologically(thread.messages), [thread.messages]);
706
565
  const [mode, setMode] = React.useState("idle");
707
566
  const [replyAll, setReplyAll] = React.useState(false);
708
567
  const [expanded, setExpanded] = React.useState(() => {
709
568
  const o = {};
710
- thread.messages.forEach((m, i) => {
711
- o[m.id] = i === thread.messages.length - 1;
569
+ sortedMessages.forEach((m, i) => {
570
+ o[m.id] = i === sortedMessages.length - 1;
712
571
  });
713
572
  return o;
714
573
  });
574
+ React.useEffect(() => {
575
+ setExpanded((current) => {
576
+ const next = __spreadValues({}, current);
577
+ sortedMessages.forEach((m, i) => {
578
+ if (next[m.id] === void 0) next[m.id] = i === sortedMessages.length - 1;
579
+ });
580
+ return next;
581
+ });
582
+ }, [sortedMessages]);
715
583
  const toggle = (id) => setExpanded((e) => __spreadProps(__spreadValues({}, e), { [id]: !e[id] }));
716
584
  return /* @__PURE__ */ jsxs("div", { "data-slot": "conv-thread-body", className: "space-y-2", children: [
717
585
  canReply && thread.paused ? /* @__PURE__ */ jsxs("div", { className: "border-status-pending-border bg-status-pending-bg text-status-pending-fg flex items-start gap-2 rounded-md border p-2.5 text-[12px]", children: [
@@ -725,13 +593,15 @@ function ThreadBody({
725
593
  " or Gmail."
726
594
  ] })
727
595
  ] }) : null,
728
- /* @__PURE__ */ jsx("div", { className: "space-y-1", children: thread.messages.map((m) => /* @__PURE__ */ jsx(MessageView, { message: m, expanded: !!expanded[m.id], onToggle: () => toggle(m.id), me }, m.id)) }),
729
- !canReply ? /* @__PURE__ */ jsxs("div", { className: "border-border bg-muted/30 text-muted-foreground flex items-start gap-2 rounded-md border p-2.5 text-[12px]", children: [
596
+ /* @__PURE__ */ jsx("div", { className: "space-y-1", children: sortedMessages.map((m) => /* @__PURE__ */ jsx(MessageView, { message: m, expanded: !!expanded[m.id], onToggle: () => toggle(m.id), me }, m.id)) }),
597
+ !canReply ? /* @__PURE__ */ jsxs("div", { className: "border-border bg-muted/30 text-muted-foreground flex flex-wrap items-start gap-2 rounded-md border p-2.5 text-[12px]", children: [
730
598
  /* @__PURE__ */ jsx(Eye, { size: 14, className: "mt-0.5 shrink-0" }),
731
- /* @__PURE__ */ jsxs("span", { children: [
599
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
732
600
  /* @__PURE__ */ jsx("b", { children: "Viewing only." }),
733
- " You\u2019re not a participant on this thread, so replying is disabled here."
734
- ] })
601
+ " ",
602
+ replyDisabledReason
603
+ ] }),
604
+ /* @__PURE__ */ jsx(OpenInGmailButton, { thread, onOpenInGmail })
735
605
  ] }) : null,
736
606
  canReply && mode === "idle" ? /* @__PURE__ */ jsxs("div", { "data-slot": "conv-action-row", className: "border-border/70 mt-1 flex flex-wrap items-center gap-x-3 gap-y-2 border-t pt-3", children: [
737
607
  /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", onClick: () => {
@@ -748,10 +618,7 @@ function ThreadBody({
748
618
  /* @__PURE__ */ jsx(ReplyAll, { size: 14 }),
749
619
  " Reply all"
750
620
  ] }) : null,
751
- /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onOpenInGmail == null ? void 0 : onOpenInGmail(thread.threadId), children: [
752
- /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
753
- " Open in Gmail"
754
- ] }),
621
+ /* @__PURE__ */ jsx(OpenInGmailButton, { thread, onOpenInGmail }),
755
622
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/70 ml-auto inline-flex items-center gap-1.5 text-[12px]", children: [
756
623
  /* @__PURE__ */ jsx(GitMerge, { size: 13 }),
757
624
  " Stays on this thread"
@@ -771,9 +638,11 @@ function ThreadBody({
771
638
  setMode("sent");
772
639
  },
773
640
  onDraft: async (body, includeSignature) => {
774
- await (onCreateGmailDraft == null ? void 0 : onCreateGmailDraft({ threadId: thread.threadId, body, includeSignature, replyAll }));
641
+ if (!onCreateGmailDraft) return;
642
+ await onCreateGmailDraft({ threadId: thread.threadId, body, includeSignature, replyAll });
775
643
  setMode("draft");
776
- }
644
+ },
645
+ draftDisabledReason
777
646
  }
778
647
  ) : null,
779
648
  canReply && mode === "sent" ? /* @__PURE__ */ jsxs("div", { className: "border-status-active-border bg-status-active-bg flex items-center gap-2 rounded-md border p-3 text-[13px]", children: [
@@ -782,7 +651,7 @@ function ThreadBody({
782
651
  /* @__PURE__ */ jsx("b", { children: replyAll ? "Reply all sent" : "Reply sent" }),
783
652
  " \xB7 added to the thread. Delivered to",
784
653
  " ",
785
- /* @__PURE__ */ jsx("b", { children: thread.contact.name }),
654
+ /* @__PURE__ */ jsx("b", { children: displayParticipant(thread.contact).name }),
786
655
  ". This action stays ",
787
656
  /* @__PURE__ */ jsx("b", { children: "Pending" }),
788
657
  "; playbooks remain stopped."
@@ -800,10 +669,7 @@ function ThreadBody({
800
669
  ] }),
801
670
  " thread; open it there to finish. Nothing was sent."
802
671
  ] }),
803
- /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onOpenInGmail == null ? void 0 : onOpenInGmail(thread.threadId), children: [
804
- /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
805
- " Open in Gmail"
806
- ] }),
672
+ /* @__PURE__ */ jsx(OpenInGmailButton, { thread, onOpenInGmail }),
807
673
  /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setMode("idle"), children: "Done" })
808
674
  ] }) : null
809
675
  ] });
@@ -819,9 +685,13 @@ function ThreadRow({
819
685
  onPreviewReply,
820
686
  onOpenInGmail
821
687
  }) {
688
+ var _a;
822
689
  const status = effectiveStatus(thread);
823
- const last = thread.messages[thread.messages.length - 1];
824
- const who = (last == null ? void 0 : last.direction) === "inbound" ? firstName(last.from.name) : "You";
690
+ const sortedMessages = React.useMemo(() => sortMessagesChronologically(thread.messages), [thread.messages]);
691
+ const last = sortedMessages[sortedMessages.length - 1];
692
+ const lastSender = last ? displayParticipant(last.from) : null;
693
+ const meDisplay = me ? displayParticipant(me) : null;
694
+ const who = (last == null ? void 0 : last.direction) === "outbound" && sameEmail(lastSender == null ? void 0 : lastSender.email, meDisplay == null ? void 0 : meDisplay.email) ? "You" : firstName((_a = lastSender == null ? void 0 : lastSender.name) != null ? _a : "");
825
695
  const lastSnippet = last ? messageBodySnippet(last, 120) : "";
826
696
  const pill = STATUS_PILL[status];
827
697
  return /* @__PURE__ */ jsxs("div", { "data-slot": "conv-thread", "data-open": open ? "true" : void 0, className: "border-border border-b last:border-b-0", children: [
@@ -840,7 +710,7 @@ function ThreadRow({
840
710
  /* @__PURE__ */ jsx("span", { className: cn("shrink-0 rounded-md border px-1.5 py-px text-[10px] font-medium leading-4", pill.cls), children: pill.label })
841
711
  ] }),
842
712
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground block truncate text-xs", children: [
843
- /* @__PURE__ */ jsx("b", { className: "text-foreground/80", children: thread.contact.name }),
713
+ /* @__PURE__ */ jsx("b", { className: "text-foreground/80", children: displayParticipant(thread.contact).name }),
844
714
  " \xB7 ",
845
715
  who,
846
716
  ": ",