@handled-ai/design-system 0.20.5 → 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 +19 -0
  2. package/dist/components/conversation-panel.js +116 -292
  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 +182 -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 +136 -350
  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
@@ -34,6 +34,25 @@ interface ConvMessage {
34
34
  to: ConvParticipant;
35
35
  /** Absolute timestamp label, e.g. "Jun 1, 2026, 9:12 AM". */
36
36
  date: string;
37
+ /**
38
+ * Raw chronological timestamp for deterministic thread ordering. Prefer
39
+ * `sentAt` for outbound messages and `receivedAt` for inbound messages.
40
+ * Accepts ISO/RFC822 strings, Date objects, epoch milliseconds, or Gmail
41
+ * internalDate values as strings/numbers. Display-only `date` / `ago` labels
42
+ * are never parsed for ordering.
43
+ */
44
+ timestamp?: string | number | Date | null;
45
+ rawTimestamp?: string | number | Date | null;
46
+ sentAt?: string | number | Date | null;
47
+ receivedAt?: string | number | Date | null;
48
+ /** Compatibility with data contracts that pass through source field names. */
49
+ sent_at?: string | number | Date | null;
50
+ received_at?: string | number | Date | null;
51
+ internalDate?: string | number | Date | null;
52
+ gmailInternalDate?: string | number | Date | null;
53
+ internal_date?: string | number | Date | null;
54
+ rfc822Date?: string | number | Date | null;
55
+ dateHeader?: string | number | Date | null;
37
56
  /** Relative label, e.g. "2 days ago". */
38
57
  ago?: string;
39
58
  receipt?: {
@@ -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;
97
- }
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 };
66
+ function displayParticipant(person) {
67
+ return normalizeEmailSender({ name: person.name, email: person.email, fallbackName: person.email || person.name });
116
68
  }
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 "";
254
- }
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);
114
+ return null;
263
115
  }
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" },
@@ -397,9 +206,10 @@ function MessageView({
397
206
  onToggle,
398
207
  me
399
208
  }) {
400
- var _a, _b;
209
+ var _a;
401
210
  const [quoteOpen, setQuoteOpen] = React.useState(false);
402
- const [detailsOpen, setDetailsOpen] = React.useState(false);
211
+ const fromDisplay = displayParticipant(message.from);
212
+ const toDisplay = displayParticipant(message.to);
403
213
  if (!expanded) {
404
214
  const snippet = messageBodySnippet(message, 140);
405
215
  return /* @__PURE__ */ jsxs(
@@ -412,7 +222,7 @@ function MessageView({
412
222
  children: [
413
223
  /* @__PURE__ */ jsx(PersonAvatar, { person: message.from }),
414
224
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground min-w-0 flex-1 truncate text-[13px]", children: [
415
- /* @__PURE__ */ jsx("b", { className: "text-foreground", children: firstName(message.from.name) }),
225
+ /* @__PURE__ */ jsx("b", { className: "text-foreground", children: firstName(fromDisplay.name) }),
416
226
  " \xB7 ",
417
227
  snippet
418
228
  ] }),
@@ -422,10 +232,8 @@ function MessageView({
422
232
  }
423
233
  );
424
234
  }
425
- const toLabel = me && message.to.email === me.email ? "me" : firstName(message.to.name);
426
- const htmlParts = message.bodyHtml ? splitMessageHtml(message.bodyHtml) : null;
427
- const textParts = message.bodyHtml ? null : splitMessageText((_b = message.body) != null ? _b : "");
428
- 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);
429
237
  return /* @__PURE__ */ jsxs("div", { "data-slot": "conv-message", className: "rounded-md border border-border bg-background", children: [
430
238
  /* @__PURE__ */ jsxs(
431
239
  "button",
@@ -437,12 +245,12 @@ function MessageView({
437
245
  /* @__PURE__ */ jsx(PersonAvatar, { person: message.from, size: "default" }),
438
246
  /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
439
247
  /* @__PURE__ */ jsxs("span", { className: "flex flex-wrap items-baseline gap-x-1.5", children: [
440
- /* @__PURE__ */ jsx("span", { className: "text-[13px] font-semibold", children: message.from.name }),
441
- /* @__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: [
442
250
  "<",
443
- message.from.email,
251
+ fromDisplay.email,
444
252
  ">"
445
- ] })
253
+ ] }) : null
446
254
  ] }),
447
255
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground block text-xs", children: [
448
256
  "to ",
@@ -461,20 +269,16 @@ function MessageView({
461
269
  }
462
270
  ),
463
271
  /* @__PURE__ */ jsxs("div", { className: "px-3 pb-2.5", children: [
464
- 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 }),
465
- hasDetails ? /* @__PURE__ */ jsxs("div", { className: "mt-2", children: [
466
- /* @__PURE__ */ jsx(
467
- "button",
468
- {
469
- type: "button",
470
- onClick: () => setDetailsOpen((v) => !v),
471
- className: "text-muted-foreground hover:text-foreground hover:bg-muted rounded px-1.5 text-xs leading-5",
472
- "aria-expanded": detailsOpen,
473
- children: detailsOpen ? "Hide signature/details" : "Show signature/details"
474
- }
475
- ),
476
- 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
477
- ] }) : 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
+ ) }),
478
282
  message.quoted ? /* @__PURE__ */ jsxs("div", { className: "mt-2", children: [
479
283
  /* @__PURE__ */ jsx(
480
284
  "button",
@@ -487,8 +291,8 @@ function MessageView({
487
291
  }
488
292
  ),
489
293
  quoteOpen ? /* @__PURE__ */ jsxs("div", { className: "border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]", children: [
490
- /* @__PURE__ */ jsx("p", { className: "mb-1", dangerouslySetInnerHTML: { __html: sanitizeHtml(message.quoted.attr) } }),
491
- /* @__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 }) })
492
296
  ] }) : null
493
297
  ] }) : null
494
298
  ] })
@@ -506,7 +310,7 @@ function ReplyComposer({
506
310
  onPreviewReply,
507
311
  draftDisabledReason
508
312
  }) {
509
- var _a, _b, _c, _d;
313
+ var _a, _b, _c, _d, _e, _f;
510
314
  const [body, setBody] = React.useState((_a = thread.draft) != null ? _a : "");
511
315
  const [sig, setSig] = React.useState(true);
512
316
  const [preview, setPreview] = React.useState(false);
@@ -522,7 +326,7 @@ function ReplyComposer({
522
326
  setPreview(true);
523
327
  setSendError(null);
524
328
  if (!onPreviewReply) {
525
- setPreviewState({ loading: false, html: sanitizeHtml(localPreviewHtml), confirmationToken: null, error: null, local: true });
329
+ setPreviewState({ loading: false, html: localPreviewHtml, confirmationToken: null, error: null, local: true });
526
330
  return;
527
331
  }
528
332
  setPreviewState({ loading: true, html: null, confirmationToken: null, error: null, local: false });
@@ -530,7 +334,7 @@ function ReplyComposer({
530
334
  const result = await onPreviewReply({ threadId: thread.threadId, body, includeSignature: sig, replyAll });
531
335
  setPreviewState({
532
336
  loading: false,
533
- html: sanitizeHtml((_a2 = result.htmlBody) != null ? _a2 : ""),
337
+ html: (_a2 = result.htmlBody) != null ? _a2 : "",
534
338
  confirmationToken: (_b2 = result.confirmationToken) != null ? _b2 : null,
535
339
  error: null,
536
340
  local: false
@@ -585,7 +389,7 @@ function ReplyComposer({
585
389
  ] })
586
390
  ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
587
391
  "Reply to ",
588
- /* @__PURE__ */ jsx("b", { children: firstName(thread.contact.name) })
392
+ /* @__PURE__ */ jsx("b", { children: firstName(displayParticipant(thread.contact).name) })
589
393
  ] }) }),
590
394
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground inline-flex items-center gap-1 text-[11px]", children: [
591
395
  /* @__PURE__ */ jsx(GitMerge, { size: 11 }),
@@ -596,12 +400,15 @@ function ReplyComposer({
596
400
  /* @__PURE__ */ jsxs("div", { className: "border-border mb-2 space-y-1 border-b pb-2 text-[13px]", children: [
597
401
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
598
402
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground w-12 shrink-0 text-[11px] font-medium", children: "To" }),
599
- /* @__PURE__ */ jsx("span", { className: "font-medium", children: thread.contact.name }),
600
- /* @__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 })
601
405
  ] }),
602
406
  replyAll && ccList.length ? /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-1.5", children: [
603
407
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground w-12 shrink-0 text-[11px] font-medium", children: "Cc" }),
604
- /* @__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
+ })) })
605
412
  ] }) : null,
606
413
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
607
414
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground w-12 shrink-0 text-[11px] font-medium", children: "Subject" }),
@@ -667,17 +474,20 @@ function ReplyComposer({
667
474
  /* @__PURE__ */ jsxs("div", { className: "border-border space-y-1 rounded-md border p-3 text-[13px]", children: [
668
475
  /* @__PURE__ */ jsxs("div", { children: [
669
476
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "To " }),
670
- /* @__PURE__ */ jsx("b", { children: thread.contact.name }),
477
+ /* @__PURE__ */ jsx("b", { children: displayParticipant(thread.contact).name }),
671
478
  " ",
672
479
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/60", children: [
673
480
  "<",
674
- thread.contact.email,
481
+ (_d = displayParticipant(thread.contact).email) != null ? _d : thread.contact.email,
675
482
  ">"
676
483
  ] })
677
484
  ] }),
678
485
  replyAll && ccList.length ? /* @__PURE__ */ jsxs("div", { className: "text-muted-foreground", children: [
679
486
  "Cc ",
680
- 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
+ }))
681
491
  ] }) : null,
682
492
  /* @__PURE__ */ jsxs("div", { children: [
683
493
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "Subject " }),
@@ -691,9 +501,9 @@ function ReplyComposer({
691
501
  "div",
692
502
  {
693
503
  "data-slot": "conv-preview-body",
694
- "data-confirmation-token": (_c = previewState.confirmationToken) != null ? _c : void 0,
695
- className: cn(PROSE, "max-h-72 overflow-auto"),
696
- 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 })
697
507
  }
698
508
  ),
699
509
  sendError ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-destructive text-sm", children: sendError }) : null,
@@ -751,15 +561,25 @@ function ThreadBody({
751
561
  const replyDisabledReason = ((_a = thread.replyDisabledReason) == null ? void 0 : _a.trim()) || "You are not a participant on this thread, so replying is disabled here.";
752
562
  const draftDisabledReason = onCreateGmailDraft ? null : "Gmail draft creation is not available for this thread.";
753
563
  const hasCc = !!(thread.cc && thread.cc.length);
564
+ const sortedMessages = React.useMemo(() => sortMessagesChronologically(thread.messages), [thread.messages]);
754
565
  const [mode, setMode] = React.useState("idle");
755
566
  const [replyAll, setReplyAll] = React.useState(false);
756
567
  const [expanded, setExpanded] = React.useState(() => {
757
568
  const o = {};
758
- thread.messages.forEach((m, i) => {
759
- o[m.id] = i === thread.messages.length - 1;
569
+ sortedMessages.forEach((m, i) => {
570
+ o[m.id] = i === sortedMessages.length - 1;
760
571
  });
761
572
  return o;
762
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]);
763
583
  const toggle = (id) => setExpanded((e) => __spreadProps(__spreadValues({}, e), { [id]: !e[id] }));
764
584
  return /* @__PURE__ */ jsxs("div", { "data-slot": "conv-thread-body", className: "space-y-2", children: [
765
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: [
@@ -773,7 +593,7 @@ function ThreadBody({
773
593
  " or Gmail."
774
594
  ] })
775
595
  ] }) : null,
776
- /* @__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)) }),
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)) }),
777
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: [
778
598
  /* @__PURE__ */ jsx(Eye, { size: 14, className: "mt-0.5 shrink-0" }),
779
599
  /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
@@ -831,7 +651,7 @@ function ThreadBody({
831
651
  /* @__PURE__ */ jsx("b", { children: replyAll ? "Reply all sent" : "Reply sent" }),
832
652
  " \xB7 added to the thread. Delivered to",
833
653
  " ",
834
- /* @__PURE__ */ jsx("b", { children: thread.contact.name }),
654
+ /* @__PURE__ */ jsx("b", { children: displayParticipant(thread.contact).name }),
835
655
  ". This action stays ",
836
656
  /* @__PURE__ */ jsx("b", { children: "Pending" }),
837
657
  "; playbooks remain stopped."
@@ -865,9 +685,13 @@ function ThreadRow({
865
685
  onPreviewReply,
866
686
  onOpenInGmail
867
687
  }) {
688
+ var _a;
868
689
  const status = effectiveStatus(thread);
869
- const last = thread.messages[thread.messages.length - 1];
870
- 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 : "");
871
695
  const lastSnippet = last ? messageBodySnippet(last, 120) : "";
872
696
  const pill = STATUS_PILL[status];
873
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: [
@@ -886,7 +710,7 @@ function ThreadRow({
886
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 })
887
711
  ] }),
888
712
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground block truncate text-xs", children: [
889
- /* @__PURE__ */ jsx("b", { className: "text-foreground/80", children: thread.contact.name }),
713
+ /* @__PURE__ */ jsx("b", { className: "text-foreground/80", children: displayParticipant(thread.contact).name }),
890
714
  " \xB7 ",
891
715
  who,
892
716
  ": ",