@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
@@ -43,12 +43,13 @@ import {
43
43
  import { cn } from "../lib/utils"
44
44
  import { getInitials } from "../lib/user-display"
45
45
  import { BRAND_ICONS } from "../lib/icons"
46
- import { htmlToTextSnippet, sanitizeHtml } from "../internal/safe-html"
47
46
  import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
48
47
  import { Button } from "./button"
49
48
  import { Switch } from "./switch"
50
49
  import { Textarea } from "./textarea"
51
50
  import { RichTextToolbar } from "./rich-text-toolbar"
51
+ import { EmailBody } from "./email-body"
52
+ import { decodeEmailDisplayText, emailBodySnippet, formatAddressList, normalizeEmailSender } from "./email-display-helpers"
52
53
  import {
53
54
  Dialog,
54
55
  DialogContent,
@@ -74,6 +75,25 @@ export interface ConvMessage {
74
75
  to: ConvParticipant
75
76
  /** Absolute timestamp label, e.g. "Jun 1, 2026, 9:12 AM". */
76
77
  date: string
78
+ /**
79
+ * Raw chronological timestamp for deterministic thread ordering. Prefer
80
+ * `sentAt` for outbound messages and `receivedAt` for inbound messages.
81
+ * Accepts ISO/RFC822 strings, Date objects, epoch milliseconds, or Gmail
82
+ * internalDate values as strings/numbers. Display-only `date` / `ago` labels
83
+ * are never parsed for ordering.
84
+ */
85
+ timestamp?: string | number | Date | null
86
+ rawTimestamp?: string | number | Date | null
87
+ sentAt?: string | number | Date | null
88
+ receivedAt?: string | number | Date | null
89
+ /** Compatibility with data contracts that pass through source field names. */
90
+ sent_at?: string | number | Date | null
91
+ received_at?: string | number | Date | null
92
+ internalDate?: string | number | Date | null
93
+ gmailInternalDate?: string | number | Date | null
94
+ internal_date?: string | number | Date | null
95
+ rfc822Date?: string | number | Date | null
96
+ dateHeader?: string | number | Date | null
77
97
  /** Relative label, e.g. "2 days ago". */
78
98
  ago?: string
79
99
  receipt?: { kind: "new" | "read" | "opened" | "sent" | "draft"; label: string }
@@ -156,22 +176,13 @@ export interface ConversationPanelProps {
156
176
 
157
177
  /* ── Shared helpers ──────────────────────────────────────────────────────── */
158
178
 
159
- /** Gmail-like reading-pane typography (mirrors timeline-activity's email mode). */
160
- const PROSE = cn(
161
- "text-sm leading-[1.62] text-foreground/90 break-words",
162
- "[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
163
- "[&_a]:text-[#1a73e8] [&_a]:underline-offset-2 hover:[&_a]:underline",
164
- "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
165
- "[&_img]:max-w-full [&_img]:h-auto"
166
- )
167
-
168
179
  function escapeHtml(s: string): string {
169
180
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
170
181
  }
171
182
 
172
183
  /** Plain-text -> simple paragraph HTML for the Preview / sent-message body. */
173
184
  function textToHtml(text: string): string {
174
- return text
185
+ return decodeEmailDisplayText(text)
175
186
  .split(/\n{2,}/)
176
187
  .map((p) => p.trim())
177
188
  .filter(Boolean)
@@ -179,308 +190,85 @@ function textToHtml(text: string): string {
179
190
  .join("")
180
191
  }
181
192
 
182
- type CollapsedHtmlMessage = { bodyHtml: string; detailsHtml: string }
183
- type CollapsedTextMessage = { bodyText: string; detailsText: string }
184
- type MessageSegment = { html?: string; text?: string; visibleText: string }
185
-
186
- const SPLITTABLE_BLOCK_TAGS = new Set(["p", "div"])
187
- const WHOLE_BLOCK_TAGS = new Set(["blockquote", "table", "ul", "ol", "hr"])
188
- const BLOCK_TAGS = new Set([...SPLITTABLE_BLOCK_TAGS, ...WHOLE_BLOCK_TAGS])
189
- const BR_TAG_RE = /<br\s*\/?>/gi
190
- const HTML_BLOCK_START_RE = /<(p|div|blockquote|table|ul|ol|hr)\b[^>]*>/i
191
-
192
- const SIGNATURE_DELIMITER_RE = /^--\s*$/
193
- const SIGNOFF_RE = /^(?:thanks,|thank you,|best,|regards,|sincerely,)$/i
194
- 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
195
- 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
196
-
197
- function decodeHtmlText(value: string): string {
198
- const withoutTags = value
199
- .replace(BR_TAG_RE, "\n")
200
- .replace(/<\/(p|div|blockquote|li|tr|table|ul|ol)>/gi, "\n")
201
- .replace(/<[^>]*>/g, "")
202
-
203
- if (typeof document !== "undefined") {
204
- const textarea = document.createElement("textarea")
205
- textarea.innerHTML = withoutTags
206
- return textarea.value
207
- }
208
-
209
- return withoutTags
210
- .replace(/&nbsp;/gi, " ")
211
- .replace(/&amp;/gi, "&")
212
- .replace(/&lt;/gi, "<")
213
- .replace(/&gt;/gi, ">")
214
- .replace(/&quot;/gi, '"')
215
- .replace(/&#39;|&apos;/gi, "'")
216
- }
217
-
218
- function htmlToVisibleText(html: string): string {
219
- return decodeHtmlText(html).replace(/\u00a0/g, " ").replace(/[ \t]+/g, " ").trim()
193
+ function displayParticipant(person: ConvParticipant) {
194
+ return normalizeEmailSender({ name: person.name, email: person.email, fallbackName: person.email || person.name })
220
195
  }
221
196
 
222
- function serializeNode(node: Node): string {
223
- const host = document.createElement("div")
224
- host.appendChild(node.cloneNode(true))
225
- return host.innerHTML
197
+ function firstName(name: string): string {
198
+ return decodeEmailDisplayText(name).split(" ")[0] || decodeEmailDisplayText(name)
226
199
  }
227
200
 
228
- function wrapHtmlLike(element: Element, innerHtml: string): string {
229
- const clone = element.cloneNode(false) as HTMLElement
230
- clone.innerHTML = innerHtml
231
- return clone.outerHTML
201
+ function sameEmail(a?: string | null, b?: string | null): boolean {
202
+ return Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase())
232
203
  }
233
204
 
234
- function hasDirectBr(nodes: readonly Node[]): boolean {
235
- return nodes.some((node) => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "br")
205
+ function messageBodySnippet(message: Pick<ConvMessage, "body" | "bodyHtml">, maxLength = 140): string {
206
+ return emailBodySnippet({ bodyHtml: message.bodyHtml, body: message.body }, maxLength)
236
207
  }
237
208
 
238
- function hasDirectBlockChild(element: Element): boolean {
239
- return Array.from(element.children).some((child) => {
240
- const tagName = child.tagName.toLowerCase()
241
- return tagName !== "br" && BLOCK_TAGS.has(tagName)
242
- })
243
- }
244
209
 
245
- function makeHtmlSegment(html: string): MessageSegment | null {
246
- const visibleText = htmlToVisibleText(html)
247
- if (!html.trim() || (!visibleText && !/<(?:img|hr)\b/i.test(html))) return null
248
- return { html, visibleText }
249
- }
210
+ type MessageTimestampValue = string | number | Date | null | undefined
250
211
 
251
- function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSegment[] {
252
- const containsBr = hasDirectBr(nodes)
253
- const chunks: string[][] = [[]]
212
+ function parseMessageTimestampValue(value: MessageTimestampValue): number | null {
213
+ if (value == null || value === "") return null
254
214
 
255
- nodes.forEach((node) => {
256
- if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "br") {
257
- chunks.push([])
258
- return
259
- }
260
-
261
- chunks[chunks.length - 1]?.push(serializeNode(node))
262
- })
263
-
264
- return chunks
265
- .map((chunk) => chunk.join(""))
266
- .map((innerHtml) => {
267
- if (wrapper) return wrapHtmlLike(wrapper, innerHtml)
268
- return containsBr ? `<div>${innerHtml}</div>` : innerHtml
269
- })
270
- .map(makeHtmlSegment)
271
- .filter((segment): segment is MessageSegment => Boolean(segment))
272
- }
273
-
274
- function splitElementSegment(element: Element): MessageSegment[] {
275
- const tagName = element.tagName.toLowerCase()
276
-
277
- if (tagName === "div" && hasDirectBlockChild(element)) {
278
- const childSegments = splitHtmlNodes(Array.from(element.childNodes))
279
- return childSegments.length ? childSegments : [makeHtmlSegment(element.outerHTML)].filter(Boolean) as MessageSegment[]
215
+ if (value instanceof Date) {
216
+ const time = value.getTime()
217
+ return Number.isNaN(time) ? null : time
280
218
  }
281
219
 
282
- if (SPLITTABLE_BLOCK_TAGS.has(tagName) && hasDirectBr(Array.from(element.childNodes)) && !hasDirectBlockChild(element)) {
283
- return splitInlineNodes(Array.from(element.childNodes), element)
220
+ if (typeof value === "number") {
221
+ if (!Number.isFinite(value)) return null
222
+ return value < 10_000_000_000 ? value * 1000 : value
284
223
  }
285
224
 
286
- const segment = makeHtmlSegment(element.outerHTML)
287
- return segment ? [segment] : []
288
- }
289
-
290
- function splitHtmlNodes(nodes: readonly Node[]): MessageSegment[] {
291
- const segments: MessageSegment[] = []
292
- let inlineNodes: Node[] = []
225
+ const trimmed = value.trim()
226
+ if (!trimmed) return null
293
227
 
294
- const flushInline = () => {
295
- if (!inlineNodes.length) return
296
- segments.push(...splitInlineNodes(inlineNodes))
297
- inlineNodes = []
228
+ if (/^\d+$/.test(trimmed)) {
229
+ const numeric = Number(trimmed)
230
+ if (!Number.isFinite(numeric)) return null
231
+ return numeric < 10_000_000_000 ? numeric * 1000 : numeric
298
232
  }
299
233
 
300
- nodes.forEach((node) => {
301
- if (node.nodeType === Node.ELEMENT_NODE) {
302
- const element = node as Element
303
- const tagName = element.tagName.toLowerCase()
304
- if (BLOCK_TAGS.has(tagName)) {
305
- flushInline()
306
- segments.push(...splitElementSegment(element))
307
- return
308
- }
309
- }
310
-
311
- inlineNodes.push(node)
312
- })
313
-
314
- flushInline()
315
- return segments
234
+ const parsed = Date.parse(trimmed)
235
+ return Number.isNaN(parsed) ? null : parsed
316
236
  }
317
237
 
318
- function findMatchingCloseTag(html: string, tagName: string, openTagEnd: number): number {
319
- if (tagName === "hr") return openTagEnd + 1
320
-
321
- const tagPattern = new RegExp(`</?${tagName}\\b[^>]*>`, "gi")
322
- tagPattern.lastIndex = openTagEnd + 1
323
- let depth = 1
324
- let match: RegExpExecArray | null
325
-
326
- while ((match = tagPattern.exec(html)) !== null) {
327
- const rawTag = match[0]
328
- if (/^<\//.test(rawTag)) depth -= 1
329
- else if (!/\/\s*>$/.test(rawTag)) depth += 1
330
- if (depth === 0) return tagPattern.lastIndex
238
+ function messageTimestamp(message: ConvMessage): number | null {
239
+ const directional = message.direction === "outbound"
240
+ ? [message.sentAt, message.sent_at]
241
+ : [message.receivedAt, message.received_at]
242
+
243
+ const candidates: MessageTimestampValue[] = [
244
+ ...directional,
245
+ message.rawTimestamp,
246
+ message.timestamp,
247
+ message.gmailInternalDate,
248
+ message.internalDate,
249
+ message.internal_date,
250
+ message.rfc822Date,
251
+ message.dateHeader,
252
+ ]
253
+
254
+ for (const candidate of candidates) {
255
+ const parsed = parseMessageTimestampValue(candidate)
256
+ if (parsed !== null) return parsed
331
257
  }
332
258
 
333
- return html.length
259
+ return null
334
260
  }
335
261
 
336
- function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
337
- const segments: MessageSegment[] = []
338
- let cursor = 0
339
-
340
- const pushInline = (inlineHtml: string) => {
341
- const chunks = inlineHtml.split(BR_TAG_RE)
342
- const hadBr = chunks.length > 1
343
- chunks.forEach((chunk) => {
344
- const segment = makeHtmlSegment(hadBr ? `<div>${chunk}</div>` : chunk)
345
- if (segment) segments.push(segment)
262
+ function sortMessagesChronologically(messages: ConvMessage[]): ConvMessage[] {
263
+ return messages
264
+ .map((message, index) => ({ message, index, timestamp: messageTimestamp(message) }))
265
+ .sort((a, b) => {
266
+ if (a.timestamp !== null && b.timestamp !== null && a.timestamp !== b.timestamp) {
267
+ return a.timestamp - b.timestamp
268
+ }
269
+ return a.index - b.index
346
270
  })
347
- }
348
-
349
- while (cursor < html.length) {
350
- const rest = html.slice(cursor)
351
- const match = HTML_BLOCK_START_RE.exec(rest)
352
- if (!match || match.index === undefined) {
353
- pushInline(rest)
354
- break
355
- }
356
-
357
- if (match.index > 0) pushInline(rest.slice(0, match.index))
358
-
359
- const tagStart = cursor + match.index
360
- const rawOpen = match[0]
361
- const tagName = match[1].toLowerCase()
362
- const openTagEnd = tagStart + rawOpen.length - 1
363
- const segmentEnd = findMatchingCloseTag(html, tagName, openTagEnd)
364
- const blockHtml = html.slice(tagStart, segmentEnd)
365
-
366
- if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
367
- const openTag = rawOpen
368
- const closeTag = `</${tagName}>`
369
- const inner = blockHtml.replace(new RegExp(`^${rawOpen.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"), "").replace(new RegExp(`${closeTag}$`, "i"), "")
370
- inner.split(BR_TAG_RE).forEach((chunk) => {
371
- const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`)
372
- if (segment) segments.push(segment)
373
- })
374
- } else {
375
- const segment = makeHtmlSegment(blockHtml)
376
- if (segment) segments.push(segment)
377
- }
378
-
379
- cursor = segmentEnd
380
- }
381
-
382
- return segments
383
- }
384
-
385
- function segmentHtmlMessage(html: string): MessageSegment[] {
386
- if (typeof document === "undefined" || typeof Node === "undefined") {
387
- return splitHtmlSegmentsFallback(html)
388
- }
389
-
390
- const template = document.createElement("template")
391
- template.innerHTML = html
392
- return splitHtmlNodes(Array.from(template.content.childNodes))
393
- }
394
-
395
- function segmentTextMessage(text: string): MessageSegment[] {
396
- const lines = text.match(/[^\n]*(?:\n|$)/g) ?? []
397
- return lines
398
- .filter((line, index) => line.length > 0 && !(index === lines.length - 1 && line === ""))
399
- .map((line) => ({ text: line, visibleText: line.replace(/\u00a0/g, " ").trim() }))
400
- }
401
-
402
- function firstVisibleLine(text: string): string {
403
- return text.replace(/\u00a0/g, " ").trimStart().split(/\r?\n/).find((line) => line.trim())?.trim() ?? ""
404
- }
405
-
406
- function isLikelySenderNameLine(line: string): boolean {
407
- if (!line || line.length > 60) return false
408
- if (/[,@:;!?]|https?:\/\/|www\.|\d/.test(line)) return false
409
-
410
- const words = line.split(/\s+/).filter(Boolean)
411
- if (words.length < 1 || words.length > 4) return false
412
-
413
- return words.every((word) => /^[A-Z][A-Za-z'.-]*$/.test(word))
414
- }
415
-
416
- function nextVisibleSegmentText(segments: MessageSegment[], fromIndex: number): string {
417
- for (let index = fromIndex + 1; index < segments.length; index += 1) {
418
- const text = firstVisibleLine(segments[index]?.visibleText ?? "")
419
- if (text) return text
420
- }
421
- return ""
422
- }
423
-
424
- function isFooterBoundary(segments: MessageSegment[], index: number): boolean {
425
- const line = firstVisibleLine(segments[index]?.visibleText ?? "")
426
- if (!line) return false
427
- if (SIGNATURE_DELIMITER_RE.test(line) || DETAILS_START_RE.test(line)) return true
428
-
429
- const nextText = nextVisibleSegmentText(segments, index)
430
- if (SIGNOFF_RE.test(line)) return Boolean(nextText && (isLikelySenderNameLine(nextText) || CONTACT_DETAIL_RE.test(nextText)))
431
-
432
- return isLikelySenderNameLine(line) && CONTACT_DETAIL_RE.test(nextText)
433
- }
434
-
435
- function splitFooterSegments(segments: MessageSegment[]): { bodySegments: MessageSegment[]; detailsSegments: MessageSegment[] } {
436
- const visibleIndexes = segments
437
- .map((segment, index) => (segment.visibleText ? index : -1))
438
- .filter((index) => index >= 0)
439
- const visibleCount = visibleIndexes.length
440
- if (visibleCount < 2) return { bodySegments: segments, detailsSegments: [] }
441
-
442
- const trailingCount = Math.max(Math.ceil(visibleCount * 0.4), 8)
443
- const firstTrailingOrdinal = Math.max(1, visibleCount - trailingCount)
444
-
445
- for (let ordinal = firstTrailingOrdinal; ordinal < visibleCount; ordinal += 1) {
446
- const index = visibleIndexes[ordinal]
447
- if (ordinal > 0 && isFooterBoundary(segments, index)) {
448
- return { bodySegments: segments.slice(0, index), detailsSegments: segments.slice(index) }
449
- }
450
- }
451
-
452
- return { bodySegments: segments, detailsSegments: [] }
453
- }
454
-
455
- function splitMessageHtml(rawHtml: string): CollapsedHtmlMessage {
456
- const sanitizedHtml = sanitizeHtml(rawHtml)
457
- const { bodySegments, detailsSegments } = splitFooterSegments(segmentHtmlMessage(sanitizedHtml))
458
-
459
- if (!detailsSegments.length) return { bodyHtml: sanitizedHtml, detailsHtml: "" }
460
-
461
- return {
462
- bodyHtml: bodySegments.map((segment) => segment.html ?? "").join(""),
463
- detailsHtml: detailsSegments.map((segment) => segment.html ?? "").join(""),
464
- }
465
- }
466
-
467
- function splitMessageText(text: string): CollapsedTextMessage {
468
- const { bodySegments, detailsSegments } = splitFooterSegments(segmentTextMessage(text))
469
-
470
- if (!detailsSegments.length) return { bodyText: text, detailsText: "" }
471
-
472
- return {
473
- bodyText: bodySegments.map((segment) => segment.text ?? "").join(""),
474
- detailsText: detailsSegments.map((segment) => segment.text ?? "").join(""),
475
- }
476
- }
477
-
478
- function messageBodySnippet(message: Pick<ConvMessage, "body" | "bodyHtml">, maxLength = 140): string {
479
- if (message.bodyHtml) {
480
- return htmlToTextSnippet(splitMessageHtml(message.bodyHtml).bodyHtml, maxLength)
481
- }
482
-
483
- return splitMessageText(message.body ?? "").bodyText.split("\n").find((line) => line.trim())?.trim() ?? ""
271
+ .map((entry) => entry.message)
484
272
  }
485
273
 
486
274
  function GmailMark({ size = 14 }: { size?: number }) {
@@ -497,20 +285,18 @@ function GmailMark({ size = 14 }: { size?: number }) {
497
285
  }
498
286
 
499
287
  function PersonAvatar({ person, size = "sm" }: { person: ConvParticipant; size?: "sm" | "default" }) {
288
+ const display = displayParticipant(person)
289
+
500
290
  return (
501
291
  <Avatar size={size}>
502
- {person.avatarUrl ? <AvatarImage src={person.avatarUrl} alt={person.name} /> : null}
292
+ {person.avatarUrl ? <AvatarImage src={person.avatarUrl} alt={display.name} /> : null}
503
293
  <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
504
- {getInitials({ name: person.name, email: person.email })}
294
+ {getInitials({ name: display.name, email: display.email ?? person.email })}
505
295
  </AvatarFallback>
506
296
  </Avatar>
507
297
  )
508
298
  }
509
299
 
510
- function firstName(name: string): string {
511
- return name.split(" ")[0] || name
512
- }
513
-
514
300
  const STATUS_PILL: Record<ConvStatus, { label: string; cls: string }> = {
515
301
  responded: { label: "New reply", cls: "bg-status-active-bg text-status-active-fg border-status-active-border" },
516
302
  draft: { label: "Draft", cls: "bg-background text-foreground/80 border-border" },
@@ -599,7 +385,8 @@ function MessageView({
599
385
  me?: ConvParticipant
600
386
  }) {
601
387
  const [quoteOpen, setQuoteOpen] = React.useState(false)
602
- const [detailsOpen, setDetailsOpen] = React.useState(false)
388
+ const fromDisplay = displayParticipant(message.from)
389
+ const toDisplay = displayParticipant(message.to)
603
390
 
604
391
  if (!expanded) {
605
392
  const snippet = messageBodySnippet(message, 140)
@@ -613,7 +400,7 @@ function MessageView({
613
400
  >
614
401
  <PersonAvatar person={message.from} />
615
402
  <span className="text-muted-foreground min-w-0 flex-1 truncate text-[13px]">
616
- <b className="text-foreground">{firstName(message.from.name)}</b> · {snippet}
403
+ <b className="text-foreground">{firstName(fromDisplay.name)}</b> · {snippet}
617
404
  </span>
618
405
  <span className="text-muted-foreground/60 shrink-0 text-xs">{message.ago ?? message.date}</span>
619
406
  <ChevronDown size={13} className="text-muted-foreground shrink-0" />
@@ -621,10 +408,8 @@ function MessageView({
621
408
  )
622
409
  }
623
410
 
624
- const toLabel = me && message.to.email === me.email ? "me" : firstName(message.to.name)
625
- const htmlParts = message.bodyHtml ? splitMessageHtml(message.bodyHtml) : null
626
- const textParts = message.bodyHtml ? null : splitMessageText(message.body ?? "")
627
- const hasDetails = Boolean(htmlParts?.detailsHtml || textParts?.detailsText)
411
+ const meDisplay = me ? displayParticipant(me) : null
412
+ const toLabel = meDisplay && sameEmail(toDisplay.email, meDisplay.email) ? "me" : firstName(toDisplay.name)
628
413
 
629
414
  return (
630
415
  <div data-slot="conv-message" className="rounded-md border border-border bg-background">
@@ -636,8 +421,10 @@ function MessageView({
636
421
  <PersonAvatar person={message.from} size="default" />
637
422
  <span className="min-w-0 flex-1">
638
423
  <span className="flex flex-wrap items-baseline gap-x-1.5">
639
- <span className="text-[13px] font-semibold">{message.from.name}</span>
640
- <span className="text-muted-foreground/60 truncate text-xs">&lt;{message.from.email}&gt;</span>
424
+ <span className="text-[13px] font-semibold">{fromDisplay.name}</span>
425
+ {fromDisplay.email ? (
426
+ <span className="text-muted-foreground/60 truncate text-xs">&lt;{fromDisplay.email}&gt;</span>
427
+ ) : null}
641
428
  </span>
642
429
  <span className="text-muted-foreground block text-xs">
643
430
  to <b>{toLabel}</b>
@@ -664,33 +451,15 @@ function MessageView({
664
451
  </button>
665
452
 
666
453
  <div className="px-3 pb-2.5">
667
- {htmlParts ? (
668
- <div data-slot="conv-message-body" className={PROSE} dangerouslySetInnerHTML={{ __html: htmlParts.bodyHtml }} />
669
- ) : (
670
- <div data-slot="conv-message-body" className={cn(PROSE, "whitespace-pre-line")}>{textParts?.bodyText}</div>
671
- )}
672
-
673
- {hasDetails ? (
674
- <div className="mt-2">
675
- <button
676
- type="button"
677
- onClick={() => setDetailsOpen((v) => !v)}
678
- className="text-muted-foreground hover:text-foreground hover:bg-muted rounded px-1.5 text-xs leading-5"
679
- aria-expanded={detailsOpen}
680
- >
681
- {detailsOpen ? "Hide signature/details" : "Show signature/details"}
682
- </button>
683
- {detailsOpen ? (
684
- <div className="border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]">
685
- {htmlParts ? (
686
- <div data-slot="conv-message-details" className={PROSE} dangerouslySetInnerHTML={{ __html: htmlParts.detailsHtml }} />
687
- ) : (
688
- <div data-slot="conv-message-details" className={cn(PROSE, "whitespace-pre-line")}>{textParts?.detailsText}</div>
689
- )}
690
- </div>
691
- ) : null}
692
- </div>
693
- ) : null}
454
+ <div data-slot="conv-message-body">
455
+ <EmailBody
456
+ html={message.bodyHtml}
457
+ text={message.body}
458
+ variant="history"
459
+ collapseDetails={true}
460
+ className="text-sm"
461
+ />
462
+ </div>
694
463
 
695
464
  {message.quoted ? (
696
465
  <div className="mt-2">
@@ -704,8 +473,10 @@ function MessageView({
704
473
  </button>
705
474
  {quoteOpen ? (
706
475
  <div className="border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]">
707
- <p className="mb-1" dangerouslySetInnerHTML={{ __html: sanitizeHtml(message.quoted.attr) }} />
708
- <div className={PROSE} dangerouslySetInnerHTML={{ __html: sanitizeHtml(message.quoted.html) }} />
476
+ <p className="mb-1">{decodeEmailDisplayText(message.quoted.attr)}</p>
477
+ <div data-slot="conv-quoted-body">
478
+ <EmailBody html={message.quoted.html} variant="history" collapseDetails={false} />
479
+ </div>
709
480
  </div>
710
481
  ) : null}
711
482
  </div>
@@ -769,7 +540,7 @@ function ReplyComposer({
769
540
 
770
541
  if (!onPreviewReply) {
771
542
  // No server preview contract: render a sanitized local draft preview only.
772
- setPreviewState({ loading: false, html: sanitizeHtml(localPreviewHtml), confirmationToken: null, error: null, local: true })
543
+ setPreviewState({ loading: false, html: localPreviewHtml, confirmationToken: null, error: null, local: true })
773
544
  return
774
545
  }
775
546
 
@@ -778,7 +549,7 @@ function ReplyComposer({
778
549
  const result = await onPreviewReply({ threadId: thread.threadId, body, includeSignature: sig, replyAll })
779
550
  setPreviewState({
780
551
  loading: false,
781
- html: sanitizeHtml(result.htmlBody ?? ""),
552
+ html: result.htmlBody ?? "",
782
553
  confirmationToken: result.confirmationToken ?? null,
783
554
  error: null,
784
555
  local: false,
@@ -835,7 +606,7 @@ function ReplyComposer({
835
606
  </>
836
607
  ) : (
837
608
  <>
838
- Reply to <b>{firstName(thread.contact.name)}</b>
609
+ Reply to <b>{firstName(displayParticipant(thread.contact).name)}</b>
839
610
  </>
840
611
  )}
841
612
  </span>
@@ -850,14 +621,14 @@ function ReplyComposer({
850
621
  <div className="border-border mb-2 space-y-1 border-b pb-2 text-[13px]">
851
622
  <div className="flex items-center gap-1.5">
852
623
  <span className="text-muted-foreground w-12 shrink-0 text-[11px] font-medium">To</span>
853
- <span className="font-medium">{thread.contact.name}</span>
854
- <span className="text-muted-foreground/60 truncate text-xs">{thread.contact.email}</span>
624
+ <span className="font-medium">{displayParticipant(thread.contact).name}</span>
625
+ <span className="text-muted-foreground/60 truncate text-xs">{displayParticipant(thread.contact).email ?? thread.contact.email}</span>
855
626
  </div>
856
627
  {replyAll && ccList.length ? (
857
628
  <div className="flex items-start gap-1.5">
858
629
  <span className="text-muted-foreground w-12 shrink-0 text-[11px] font-medium">Cc</span>
859
630
  <span className="text-muted-foreground text-xs">
860
- {ccList.map((c) => c.name).join(", ")}
631
+ {formatAddressList(ccList.map((c) => `${displayParticipant(c).name} <${displayParticipant(c).email ?? c.email}>`))}
861
632
  </span>
862
633
  </div>
863
634
  ) : null}
@@ -919,11 +690,11 @@ function ReplyComposer({
919
690
  <div className="border-border space-y-1 rounded-md border p-3 text-[13px]">
920
691
  <div>
921
692
  <span className="text-muted-foreground">To </span>
922
- <b>{thread.contact.name}</b>{" "}
923
- <span className="text-muted-foreground/60">&lt;{thread.contact.email}&gt;</span>
693
+ <b>{displayParticipant(thread.contact).name}</b>{" "}
694
+ <span className="text-muted-foreground/60">&lt;{displayParticipant(thread.contact).email ?? thread.contact.email}&gt;</span>
924
695
  </div>
925
696
  {replyAll && ccList.length ? (
926
- <div className="text-muted-foreground">Cc {ccList.map((c) => c.name).join(", ")}</div>
697
+ <div className="text-muted-foreground">Cc {formatAddressList(ccList.map((c) => `${displayParticipant(c).name} <${displayParticipant(c).email ?? c.email}>`))}</div>
927
698
  ) : null}
928
699
  <div>
929
700
  <span className="text-muted-foreground">Subject </span>
@@ -943,9 +714,10 @@ function ReplyComposer({
943
714
  <div
944
715
  data-slot="conv-preview-body"
945
716
  data-confirmation-token={previewState.confirmationToken ?? undefined}
946
- className={cn(PROSE, "max-h-72 overflow-auto")}
947
- dangerouslySetInnerHTML={{ __html: previewState.html ?? "" }}
948
- />
717
+ className="max-h-72 overflow-auto"
718
+ >
719
+ <EmailBody html={previewState.html ?? ""} variant="preview" collapseDetails={false} defaultDetailsOpen />
720
+ </div>
949
721
  )}
950
722
  {sendError ? (
951
723
  <p role="alert" className="text-destructive text-sm">
@@ -1013,16 +785,27 @@ function ThreadBody({
1013
785
  const replyDisabledReason = thread.replyDisabledReason?.trim() || "You are not a participant on this thread, so replying is disabled here."
1014
786
  const draftDisabledReason = onCreateGmailDraft ? null : "Gmail draft creation is not available for this thread."
1015
787
  const hasCc = !!(thread.cc && thread.cc.length)
788
+ const sortedMessages = React.useMemo(() => sortMessagesChronologically(thread.messages), [thread.messages])
1016
789
  const [mode, setMode] = React.useState<ThreadMode>("idle")
1017
790
  const [replyAll, setReplyAll] = React.useState(false)
1018
791
  const [expanded, setExpanded] = React.useState<Record<string, boolean>>(() => {
1019
792
  const o: Record<string, boolean> = {}
1020
- thread.messages.forEach((m, i) => {
1021
- o[m.id] = i === thread.messages.length - 1
793
+ sortedMessages.forEach((m, i) => {
794
+ o[m.id] = i === sortedMessages.length - 1
1022
795
  })
1023
796
  return o
1024
797
  })
1025
798
 
799
+ React.useEffect(() => {
800
+ setExpanded((current) => {
801
+ const next = { ...current }
802
+ sortedMessages.forEach((m, i) => {
803
+ if (next[m.id] === undefined) next[m.id] = i === sortedMessages.length - 1
804
+ })
805
+ return next
806
+ })
807
+ }, [sortedMessages])
808
+
1026
809
  const toggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }))
1027
810
 
1028
811
  return (
@@ -1038,7 +821,7 @@ function ThreadBody({
1038
821
  ) : null}
1039
822
 
1040
823
  <div className="space-y-1">
1041
- {thread.messages.map((m) => (
824
+ {sortedMessages.map((m) => (
1042
825
  <MessageView key={m.id} message={m} expanded={!!expanded[m.id]} onToggle={() => toggle(m.id)} me={me} />
1043
826
  ))}
1044
827
  </div>
@@ -1096,7 +879,7 @@ function ThreadBody({
1096
879
  <Check size={16} className="text-status-active-fg shrink-0" />
1097
880
  <span className="flex-1">
1098
881
  <b>{replyAll ? "Reply all sent" : "Reply sent"}</b> · added to the thread. Delivered to{" "}
1099
- <b>{thread.contact.name}</b>. This action stays <b>Pending</b>; playbooks remain stopped.
882
+ <b>{displayParticipant(thread.contact).name}</b>. This action stays <b>Pending</b>; playbooks remain stopped.
1100
883
  </span>
1101
884
  <Button type="button" variant="ghost" size="sm" onClick={() => setMode("idle")}>
1102
885
  Done
@@ -1138,8 +921,11 @@ function ThreadRow({
1138
921
  onToggleOpen: () => void
1139
922
  } & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onPreviewReply" | "onOpenInGmail">) {
1140
923
  const status = effectiveStatus(thread)
1141
- const last = thread.messages[thread.messages.length - 1]
1142
- const who = last?.direction === "inbound" ? firstName(last.from.name) : "You"
924
+ const sortedMessages = React.useMemo(() => sortMessagesChronologically(thread.messages), [thread.messages])
925
+ const last = sortedMessages[sortedMessages.length - 1]
926
+ const lastSender = last ? displayParticipant(last.from) : null
927
+ const meDisplay = me ? displayParticipant(me) : null
928
+ const who = last?.direction === "outbound" && sameEmail(lastSender?.email, meDisplay?.email) ? "You" : firstName(lastSender?.name ?? "")
1143
929
  const lastSnippet = last ? messageBodySnippet(last, 120) : ""
1144
930
  const pill = STATUS_PILL[status]
1145
931
 
@@ -1160,7 +946,7 @@ function ThreadRow({
1160
946
  </span>
1161
947
  </span>
1162
948
  <span className="text-muted-foreground block truncate text-xs">
1163
- <b className="text-foreground/80">{thread.contact.name}</b> · {who}: {lastSnippet}
949
+ <b className="text-foreground/80">{displayParticipant(thread.contact).name}</b> · {who}: {lastSnippet}
1164
950
  </span>
1165
951
  </span>
1166
952
  <span className="text-muted-foreground/60 shrink-0 text-xs">{thread.lastWhen}</span>