@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
@@ -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 }
@@ -97,8 +117,16 @@ export interface ConversationThread {
97
117
  cc?: ConvParticipant[]
98
118
  /** Set when this thread's reply halted a playbook (terminal). */
99
119
  paused?: { playbook: string } | null
100
- /** false => operator is not a participant; reply disabled (read-only). */
120
+ /** false => operator cannot reply or create drafts from this thread. */
101
121
  canReply?: boolean
122
+ /** Explains why reply and draft creation are disabled. */
123
+ replyDisabledReason?: string
124
+ /** Existing Gmail draft or thread URL. Rendered as a new-tab link when present. */
125
+ openInGmailUrl?: string | null
126
+ /** Forces the Open in Gmail action into a disabled state. */
127
+ openInGmailDisabled?: boolean
128
+ /** Tooltip/read-only copy for a disabled Open in Gmail action. */
129
+ openInGmailDisabledReason?: string | null
102
130
  messages: ConvMessage[]
103
131
  /** Prefilled reply draft body. */
104
132
  draft?: string
@@ -148,22 +176,13 @@ export interface ConversationPanelProps {
148
176
 
149
177
  /* ── Shared helpers ──────────────────────────────────────────────────────── */
150
178
 
151
- /** Gmail-like reading-pane typography (mirrors timeline-activity's email mode). */
152
- const PROSE = cn(
153
- "text-sm leading-[1.62] text-foreground/90 break-words",
154
- "[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
155
- "[&_a]:text-[#1a73e8] [&_a]:underline-offset-2 hover:[&_a]:underline",
156
- "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
157
- "[&_img]:max-w-full [&_img]:h-auto"
158
- )
159
-
160
179
  function escapeHtml(s: string): string {
161
180
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
162
181
  }
163
182
 
164
183
  /** Plain-text -> simple paragraph HTML for the Preview / sent-message body. */
165
184
  function textToHtml(text: string): string {
166
- return text
185
+ return decodeEmailDisplayText(text)
167
186
  .split(/\n{2,}/)
168
187
  .map((p) => p.trim())
169
188
  .filter(Boolean)
@@ -171,308 +190,85 @@ function textToHtml(text: string): string {
171
190
  .join("")
172
191
  }
173
192
 
174
- type CollapsedHtmlMessage = { bodyHtml: string; detailsHtml: string }
175
- type CollapsedTextMessage = { bodyText: string; detailsText: string }
176
- type MessageSegment = { html?: string; text?: string; visibleText: string }
177
-
178
- const SPLITTABLE_BLOCK_TAGS = new Set(["p", "div"])
179
- const WHOLE_BLOCK_TAGS = new Set(["blockquote", "table", "ul", "ol", "hr"])
180
- const BLOCK_TAGS = new Set([...SPLITTABLE_BLOCK_TAGS, ...WHOLE_BLOCK_TAGS])
181
- const BR_TAG_RE = /<br\s*\/?>/gi
182
- const HTML_BLOCK_START_RE = /<(p|div|blockquote|table|ul|ol|hr)\b[^>]*>/i
183
-
184
- const SIGNATURE_DELIMITER_RE = /^--\s*$/
185
- const SIGNOFF_RE = /^(?:thanks,|thank you,|best,|regards,|sincerely,)$/i
186
- 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
187
- 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
188
-
189
- function decodeHtmlText(value: string): string {
190
- const withoutTags = value
191
- .replace(BR_TAG_RE, "\n")
192
- .replace(/<\/(p|div|blockquote|li|tr|table|ul|ol)>/gi, "\n")
193
- .replace(/<[^>]*>/g, "")
194
-
195
- if (typeof document !== "undefined") {
196
- const textarea = document.createElement("textarea")
197
- textarea.innerHTML = withoutTags
198
- return textarea.value
199
- }
200
-
201
- return withoutTags
202
- .replace(/&nbsp;/gi, " ")
203
- .replace(/&amp;/gi, "&")
204
- .replace(/&lt;/gi, "<")
205
- .replace(/&gt;/gi, ">")
206
- .replace(/&quot;/gi, '"')
207
- .replace(/&#39;|&apos;/gi, "'")
208
- }
209
-
210
- function htmlToVisibleText(html: string): string {
211
- return decodeHtmlText(html).replace(/\u00a0/g, " ").replace(/[ \t]+/g, " ").trim()
212
- }
213
-
214
- function serializeNode(node: Node): string {
215
- const host = document.createElement("div")
216
- host.appendChild(node.cloneNode(true))
217
- return host.innerHTML
193
+ function displayParticipant(person: ConvParticipant) {
194
+ return normalizeEmailSender({ name: person.name, email: person.email, fallbackName: person.email || person.name })
218
195
  }
219
196
 
220
- function wrapHtmlLike(element: Element, innerHtml: string): string {
221
- const clone = element.cloneNode(false) as HTMLElement
222
- clone.innerHTML = innerHtml
223
- return clone.outerHTML
197
+ function firstName(name: string): string {
198
+ return decodeEmailDisplayText(name).split(" ")[0] || decodeEmailDisplayText(name)
224
199
  }
225
200
 
226
- function hasDirectBr(nodes: readonly Node[]): boolean {
227
- return nodes.some((node) => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "br")
201
+ function sameEmail(a?: string | null, b?: string | null): boolean {
202
+ return Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase())
228
203
  }
229
204
 
230
- function hasDirectBlockChild(element: Element): boolean {
231
- return Array.from(element.children).some((child) => {
232
- const tagName = child.tagName.toLowerCase()
233
- return tagName !== "br" && BLOCK_TAGS.has(tagName)
234
- })
235
- }
236
-
237
- function makeHtmlSegment(html: string): MessageSegment | null {
238
- const visibleText = htmlToVisibleText(html)
239
- if (!html.trim() || (!visibleText && !/<(?:img|hr)\b/i.test(html))) return null
240
- return { html, visibleText }
205
+ function messageBodySnippet(message: Pick<ConvMessage, "body" | "bodyHtml">, maxLength = 140): string {
206
+ return emailBodySnippet({ bodyHtml: message.bodyHtml, body: message.body }, maxLength)
241
207
  }
242
208
 
243
- function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSegment[] {
244
- const containsBr = hasDirectBr(nodes)
245
- const chunks: string[][] = [[]]
246
-
247
- nodes.forEach((node) => {
248
- if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "br") {
249
- chunks.push([])
250
- return
251
- }
252
-
253
- chunks[chunks.length - 1]?.push(serializeNode(node))
254
- })
255
209
 
256
- return chunks
257
- .map((chunk) => chunk.join(""))
258
- .map((innerHtml) => {
259
- if (wrapper) return wrapHtmlLike(wrapper, innerHtml)
260
- return containsBr ? `<div>${innerHtml}</div>` : innerHtml
261
- })
262
- .map(makeHtmlSegment)
263
- .filter((segment): segment is MessageSegment => Boolean(segment))
264
- }
210
+ type MessageTimestampValue = string | number | Date | null | undefined
265
211
 
266
- function splitElementSegment(element: Element): MessageSegment[] {
267
- const tagName = element.tagName.toLowerCase()
212
+ function parseMessageTimestampValue(value: MessageTimestampValue): number | null {
213
+ if (value == null || value === "") return null
268
214
 
269
- if (tagName === "div" && hasDirectBlockChild(element)) {
270
- const childSegments = splitHtmlNodes(Array.from(element.childNodes))
271
- 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
272
218
  }
273
219
 
274
- if (SPLITTABLE_BLOCK_TAGS.has(tagName) && hasDirectBr(Array.from(element.childNodes)) && !hasDirectBlockChild(element)) {
275
- 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
276
223
  }
277
224
 
278
- const segment = makeHtmlSegment(element.outerHTML)
279
- return segment ? [segment] : []
280
- }
281
-
282
- function splitHtmlNodes(nodes: readonly Node[]): MessageSegment[] {
283
- const segments: MessageSegment[] = []
284
- let inlineNodes: Node[] = []
225
+ const trimmed = value.trim()
226
+ if (!trimmed) return null
285
227
 
286
- const flushInline = () => {
287
- if (!inlineNodes.length) return
288
- segments.push(...splitInlineNodes(inlineNodes))
289
- 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
290
232
  }
291
233
 
292
- nodes.forEach((node) => {
293
- if (node.nodeType === Node.ELEMENT_NODE) {
294
- const element = node as Element
295
- const tagName = element.tagName.toLowerCase()
296
- if (BLOCK_TAGS.has(tagName)) {
297
- flushInline()
298
- segments.push(...splitElementSegment(element))
299
- return
300
- }
301
- }
302
-
303
- inlineNodes.push(node)
304
- })
305
-
306
- flushInline()
307
- return segments
234
+ const parsed = Date.parse(trimmed)
235
+ return Number.isNaN(parsed) ? null : parsed
308
236
  }
309
237
 
310
- function findMatchingCloseTag(html: string, tagName: string, openTagEnd: number): number {
311
- if (tagName === "hr") return openTagEnd + 1
312
-
313
- const tagPattern = new RegExp(`</?${tagName}\\b[^>]*>`, "gi")
314
- tagPattern.lastIndex = openTagEnd + 1
315
- let depth = 1
316
- let match: RegExpExecArray | null
317
-
318
- while ((match = tagPattern.exec(html)) !== null) {
319
- const rawTag = match[0]
320
- if (/^<\//.test(rawTag)) depth -= 1
321
- else if (!/\/\s*>$/.test(rawTag)) depth += 1
322
- 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
323
257
  }
324
258
 
325
- return html.length
259
+ return null
326
260
  }
327
261
 
328
- function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
329
- const segments: MessageSegment[] = []
330
- let cursor = 0
331
-
332
- const pushInline = (inlineHtml: string) => {
333
- const chunks = inlineHtml.split(BR_TAG_RE)
334
- const hadBr = chunks.length > 1
335
- chunks.forEach((chunk) => {
336
- const segment = makeHtmlSegment(hadBr ? `<div>${chunk}</div>` : chunk)
337
- 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
338
270
  })
339
- }
340
-
341
- while (cursor < html.length) {
342
- const rest = html.slice(cursor)
343
- const match = HTML_BLOCK_START_RE.exec(rest)
344
- if (!match || match.index === undefined) {
345
- pushInline(rest)
346
- break
347
- }
348
-
349
- if (match.index > 0) pushInline(rest.slice(0, match.index))
350
-
351
- const tagStart = cursor + match.index
352
- const rawOpen = match[0]
353
- const tagName = match[1].toLowerCase()
354
- const openTagEnd = tagStart + rawOpen.length - 1
355
- const segmentEnd = findMatchingCloseTag(html, tagName, openTagEnd)
356
- const blockHtml = html.slice(tagStart, segmentEnd)
357
-
358
- if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
359
- const openTag = rawOpen
360
- const closeTag = `</${tagName}>`
361
- const inner = blockHtml.replace(new RegExp(`^${rawOpen.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"), "").replace(new RegExp(`${closeTag}$`, "i"), "")
362
- inner.split(BR_TAG_RE).forEach((chunk) => {
363
- const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`)
364
- if (segment) segments.push(segment)
365
- })
366
- } else {
367
- const segment = makeHtmlSegment(blockHtml)
368
- if (segment) segments.push(segment)
369
- }
370
-
371
- cursor = segmentEnd
372
- }
373
-
374
- return segments
375
- }
376
-
377
- function segmentHtmlMessage(html: string): MessageSegment[] {
378
- if (typeof document === "undefined" || typeof Node === "undefined") {
379
- return splitHtmlSegmentsFallback(html)
380
- }
381
-
382
- const template = document.createElement("template")
383
- template.innerHTML = html
384
- return splitHtmlNodes(Array.from(template.content.childNodes))
385
- }
386
-
387
- function segmentTextMessage(text: string): MessageSegment[] {
388
- const lines = text.match(/[^\n]*(?:\n|$)/g) ?? []
389
- return lines
390
- .filter((line, index) => line.length > 0 && !(index === lines.length - 1 && line === ""))
391
- .map((line) => ({ text: line, visibleText: line.replace(/\u00a0/g, " ").trim() }))
392
- }
393
-
394
- function firstVisibleLine(text: string): string {
395
- return text.replace(/\u00a0/g, " ").trimStart().split(/\r?\n/).find((line) => line.trim())?.trim() ?? ""
396
- }
397
-
398
- function isLikelySenderNameLine(line: string): boolean {
399
- if (!line || line.length > 60) return false
400
- if (/[,@:;!?]|https?:\/\/|www\.|\d/.test(line)) return false
401
-
402
- const words = line.split(/\s+/).filter(Boolean)
403
- if (words.length < 1 || words.length > 4) return false
404
-
405
- return words.every((word) => /^[A-Z][A-Za-z'.-]*$/.test(word))
406
- }
407
-
408
- function nextVisibleSegmentText(segments: MessageSegment[], fromIndex: number): string {
409
- for (let index = fromIndex + 1; index < segments.length; index += 1) {
410
- const text = firstVisibleLine(segments[index]?.visibleText ?? "")
411
- if (text) return text
412
- }
413
- return ""
414
- }
415
-
416
- function isFooterBoundary(segments: MessageSegment[], index: number): boolean {
417
- const line = firstVisibleLine(segments[index]?.visibleText ?? "")
418
- if (!line) return false
419
- if (SIGNATURE_DELIMITER_RE.test(line) || DETAILS_START_RE.test(line)) return true
420
-
421
- const nextText = nextVisibleSegmentText(segments, index)
422
- if (SIGNOFF_RE.test(line)) return Boolean(nextText && (isLikelySenderNameLine(nextText) || CONTACT_DETAIL_RE.test(nextText)))
423
-
424
- return isLikelySenderNameLine(line) && CONTACT_DETAIL_RE.test(nextText)
425
- }
426
-
427
- function splitFooterSegments(segments: MessageSegment[]): { bodySegments: MessageSegment[]; detailsSegments: MessageSegment[] } {
428
- const visibleIndexes = segments
429
- .map((segment, index) => (segment.visibleText ? index : -1))
430
- .filter((index) => index >= 0)
431
- const visibleCount = visibleIndexes.length
432
- if (visibleCount < 2) return { bodySegments: segments, detailsSegments: [] }
433
-
434
- const trailingCount = Math.max(Math.ceil(visibleCount * 0.4), 8)
435
- const firstTrailingOrdinal = Math.max(1, visibleCount - trailingCount)
436
-
437
- for (let ordinal = firstTrailingOrdinal; ordinal < visibleCount; ordinal += 1) {
438
- const index = visibleIndexes[ordinal]
439
- if (ordinal > 0 && isFooterBoundary(segments, index)) {
440
- return { bodySegments: segments.slice(0, index), detailsSegments: segments.slice(index) }
441
- }
442
- }
443
-
444
- return { bodySegments: segments, detailsSegments: [] }
445
- }
446
-
447
- function splitMessageHtml(rawHtml: string): CollapsedHtmlMessage {
448
- const sanitizedHtml = sanitizeHtml(rawHtml)
449
- const { bodySegments, detailsSegments } = splitFooterSegments(segmentHtmlMessage(sanitizedHtml))
450
-
451
- if (!detailsSegments.length) return { bodyHtml: sanitizedHtml, detailsHtml: "" }
452
-
453
- return {
454
- bodyHtml: bodySegments.map((segment) => segment.html ?? "").join(""),
455
- detailsHtml: detailsSegments.map((segment) => segment.html ?? "").join(""),
456
- }
457
- }
458
-
459
- function splitMessageText(text: string): CollapsedTextMessage {
460
- const { bodySegments, detailsSegments } = splitFooterSegments(segmentTextMessage(text))
461
-
462
- if (!detailsSegments.length) return { bodyText: text, detailsText: "" }
463
-
464
- return {
465
- bodyText: bodySegments.map((segment) => segment.text ?? "").join(""),
466
- detailsText: detailsSegments.map((segment) => segment.text ?? "").join(""),
467
- }
468
- }
469
-
470
- function messageBodySnippet(message: Pick<ConvMessage, "body" | "bodyHtml">, maxLength = 140): string {
471
- if (message.bodyHtml) {
472
- return htmlToTextSnippet(splitMessageHtml(message.bodyHtml).bodyHtml, maxLength)
473
- }
474
-
475
- return splitMessageText(message.body ?? "").bodyText.split("\n").find((line) => line.trim())?.trim() ?? ""
271
+ .map((entry) => entry.message)
476
272
  }
477
273
 
478
274
  function GmailMark({ size = 14 }: { size?: number }) {
@@ -489,20 +285,18 @@ function GmailMark({ size = 14 }: { size?: number }) {
489
285
  }
490
286
 
491
287
  function PersonAvatar({ person, size = "sm" }: { person: ConvParticipant; size?: "sm" | "default" }) {
288
+ const display = displayParticipant(person)
289
+
492
290
  return (
493
291
  <Avatar size={size}>
494
- {person.avatarUrl ? <AvatarImage src={person.avatarUrl} alt={person.name} /> : null}
292
+ {person.avatarUrl ? <AvatarImage src={person.avatarUrl} alt={display.name} /> : null}
495
293
  <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
496
- {getInitials({ name: person.name, email: person.email })}
294
+ {getInitials({ name: display.name, email: display.email ?? person.email })}
497
295
  </AvatarFallback>
498
296
  </Avatar>
499
297
  )
500
298
  }
501
299
 
502
- function firstName(name: string): string {
503
- return name.split(" ")[0] || name
504
- }
505
-
506
300
  const STATUS_PILL: Record<ConvStatus, { label: string; cls: string }> = {
507
301
  responded: { label: "New reply", cls: "bg-status-active-bg text-status-active-fg border-status-active-border" },
508
302
  draft: { label: "Draft", cls: "bg-background text-foreground/80 border-border" },
@@ -521,6 +315,62 @@ function effectiveStatus(t: ConversationThread): ConvStatus {
521
315
  return t.canReply === false ? "viewing" : t.status
522
316
  }
523
317
 
318
+ function disabledOpenInGmailReason(thread: ConversationThread): string {
319
+ return (
320
+ thread.openInGmailDisabledReason?.trim() ||
321
+ thread.replyDisabledReason?.trim() ||
322
+ "Gmail access is not available for this thread."
323
+ )
324
+ }
325
+
326
+ function canOpenInGmail(thread: ConversationThread, onOpenInGmail?: (threadId: string) => void): boolean {
327
+ return thread.openInGmailDisabled !== true && Boolean(thread.openInGmailUrl || onOpenInGmail)
328
+ }
329
+
330
+ function OpenInGmailButton({
331
+ thread,
332
+ onOpenInGmail,
333
+ label = "Open in Gmail",
334
+ }: {
335
+ thread: ConversationThread
336
+ onOpenInGmail?: (threadId: string) => void
337
+ label?: string
338
+ }) {
339
+ const hasConfiguredAction = Boolean(thread.openInGmailUrl || onOpenInGmail || thread.openInGmailDisabled || thread.openInGmailDisabledReason)
340
+ if (!hasConfiguredAction) return null
341
+
342
+ const disabled = !canOpenInGmail(thread, onOpenInGmail)
343
+ const disabledReason = disabled
344
+ ? disabledOpenInGmailReason(thread)
345
+ : undefined
346
+
347
+ if (!disabled && thread.openInGmailUrl) {
348
+ return (
349
+ <Button type="button" variant="ghost" size="sm" asChild>
350
+ <a href={thread.openInGmailUrl} target="_blank" rel="noopener noreferrer">
351
+ <GmailMark size={14} /> {label}
352
+ </a>
353
+ </Button>
354
+ )
355
+ }
356
+
357
+ return (
358
+ <span className="inline-flex" title={disabledReason}>
359
+ <Button
360
+ type="button"
361
+ variant="ghost"
362
+ size="sm"
363
+ disabled={disabled}
364
+ aria-disabled={disabled || undefined}
365
+ aria-label={disabledReason ? `${label}: ${disabledReason}` : label}
366
+ onClick={disabled ? undefined : () => onOpenInGmail?.(thread.threadId)}
367
+ >
368
+ <GmailMark size={14} /> {label}
369
+ </Button>
370
+ </span>
371
+ )
372
+ }
373
+
524
374
  /* ── One message (collapsible) ──────────────────────────────────────────── */
525
375
 
526
376
  function MessageView({
@@ -535,7 +385,8 @@ function MessageView({
535
385
  me?: ConvParticipant
536
386
  }) {
537
387
  const [quoteOpen, setQuoteOpen] = React.useState(false)
538
- const [detailsOpen, setDetailsOpen] = React.useState(false)
388
+ const fromDisplay = displayParticipant(message.from)
389
+ const toDisplay = displayParticipant(message.to)
539
390
 
540
391
  if (!expanded) {
541
392
  const snippet = messageBodySnippet(message, 140)
@@ -549,7 +400,7 @@ function MessageView({
549
400
  >
550
401
  <PersonAvatar person={message.from} />
551
402
  <span className="text-muted-foreground min-w-0 flex-1 truncate text-[13px]">
552
- <b className="text-foreground">{firstName(message.from.name)}</b> · {snippet}
403
+ <b className="text-foreground">{firstName(fromDisplay.name)}</b> · {snippet}
553
404
  </span>
554
405
  <span className="text-muted-foreground/60 shrink-0 text-xs">{message.ago ?? message.date}</span>
555
406
  <ChevronDown size={13} className="text-muted-foreground shrink-0" />
@@ -557,10 +408,8 @@ function MessageView({
557
408
  )
558
409
  }
559
410
 
560
- const toLabel = me && message.to.email === me.email ? "me" : firstName(message.to.name)
561
- const htmlParts = message.bodyHtml ? splitMessageHtml(message.bodyHtml) : null
562
- const textParts = message.bodyHtml ? null : splitMessageText(message.body ?? "")
563
- 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)
564
413
 
565
414
  return (
566
415
  <div data-slot="conv-message" className="rounded-md border border-border bg-background">
@@ -572,8 +421,10 @@ function MessageView({
572
421
  <PersonAvatar person={message.from} size="default" />
573
422
  <span className="min-w-0 flex-1">
574
423
  <span className="flex flex-wrap items-baseline gap-x-1.5">
575
- <span className="text-[13px] font-semibold">{message.from.name}</span>
576
- <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}
577
428
  </span>
578
429
  <span className="text-muted-foreground block text-xs">
579
430
  to <b>{toLabel}</b>
@@ -600,33 +451,15 @@ function MessageView({
600
451
  </button>
601
452
 
602
453
  <div className="px-3 pb-2.5">
603
- {htmlParts ? (
604
- <div data-slot="conv-message-body" className={PROSE} dangerouslySetInnerHTML={{ __html: htmlParts.bodyHtml }} />
605
- ) : (
606
- <div data-slot="conv-message-body" className={cn(PROSE, "whitespace-pre-line")}>{textParts?.bodyText}</div>
607
- )}
608
-
609
- {hasDetails ? (
610
- <div className="mt-2">
611
- <button
612
- type="button"
613
- onClick={() => setDetailsOpen((v) => !v)}
614
- className="text-muted-foreground hover:text-foreground hover:bg-muted rounded px-1.5 text-xs leading-5"
615
- aria-expanded={detailsOpen}
616
- >
617
- {detailsOpen ? "Hide signature/details" : "Show signature/details"}
618
- </button>
619
- {detailsOpen ? (
620
- <div className="border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]">
621
- {htmlParts ? (
622
- <div data-slot="conv-message-details" className={PROSE} dangerouslySetInnerHTML={{ __html: htmlParts.detailsHtml }} />
623
- ) : (
624
- <div data-slot="conv-message-details" className={cn(PROSE, "whitespace-pre-line")}>{textParts?.detailsText}</div>
625
- )}
626
- </div>
627
- ) : null}
628
- </div>
629
- ) : 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>
630
463
 
631
464
  {message.quoted ? (
632
465
  <div className="mt-2">
@@ -640,8 +473,10 @@ function MessageView({
640
473
  </button>
641
474
  {quoteOpen ? (
642
475
  <div className="border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]">
643
- <p className="mb-1" dangerouslySetInnerHTML={{ __html: sanitizeHtml(message.quoted.attr) }} />
644
- <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>
645
480
  </div>
646
481
  ) : null}
647
482
  </div>
@@ -675,6 +510,7 @@ function ReplyComposer({
675
510
  onSend,
676
511
  onDraft,
677
512
  onPreviewReply,
513
+ draftDisabledReason,
678
514
  }: {
679
515
  thread: ConversationThread
680
516
  me?: ConvParticipant
@@ -684,6 +520,7 @@ function ReplyComposer({
684
520
  onSend: (body: string, includeSignature: boolean) => void | Promise<void>
685
521
  onDraft: (body: string, includeSignature: boolean) => void | Promise<void>
686
522
  onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>
523
+ draftDisabledReason?: string | null
687
524
  }) {
688
525
  const [body, setBody] = React.useState(thread.draft ?? "")
689
526
  const [sig, setSig] = React.useState(true)
@@ -693,6 +530,7 @@ function ReplyComposer({
693
530
  const [sendError, setSendError] = React.useState<string | null>(null)
694
531
  const ccList = replyAll ? thread.cc ?? [] : []
695
532
  const subject = /^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`
533
+ const draftDisabled = Boolean(draftDisabledReason)
696
534
 
697
535
  const localPreviewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "")
698
536
 
@@ -702,7 +540,7 @@ function ReplyComposer({
702
540
 
703
541
  if (!onPreviewReply) {
704
542
  // No server preview contract: render a sanitized local draft preview only.
705
- setPreviewState({ loading: false, html: sanitizeHtml(localPreviewHtml), confirmationToken: null, error: null, local: true })
543
+ setPreviewState({ loading: false, html: localPreviewHtml, confirmationToken: null, error: null, local: true })
706
544
  return
707
545
  }
708
546
 
@@ -711,7 +549,7 @@ function ReplyComposer({
711
549
  const result = await onPreviewReply({ threadId: thread.threadId, body, includeSignature: sig, replyAll })
712
550
  setPreviewState({
713
551
  loading: false,
714
- html: sanitizeHtml(result.htmlBody ?? ""),
552
+ html: result.htmlBody ?? "",
715
553
  confirmationToken: result.confirmationToken ?? null,
716
554
  error: null,
717
555
  local: false,
@@ -742,6 +580,7 @@ function ReplyComposer({
742
580
  }
743
581
 
744
582
  const handleDraft = async () => {
583
+ if (draftDisabled) return
745
584
  setSending(true)
746
585
  setSendError(null)
747
586
  try {
@@ -767,7 +606,7 @@ function ReplyComposer({
767
606
  </>
768
607
  ) : (
769
608
  <>
770
- Reply to <b>{firstName(thread.contact.name)}</b>
609
+ Reply to <b>{firstName(displayParticipant(thread.contact).name)}</b>
771
610
  </>
772
611
  )}
773
612
  </span>
@@ -782,14 +621,14 @@ function ReplyComposer({
782
621
  <div className="border-border mb-2 space-y-1 border-b pb-2 text-[13px]">
783
622
  <div className="flex items-center gap-1.5">
784
623
  <span className="text-muted-foreground w-12 shrink-0 text-[11px] font-medium">To</span>
785
- <span className="font-medium">{thread.contact.name}</span>
786
- <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>
787
626
  </div>
788
627
  {replyAll && ccList.length ? (
789
628
  <div className="flex items-start gap-1.5">
790
629
  <span className="text-muted-foreground w-12 shrink-0 text-[11px] font-medium">Cc</span>
791
630
  <span className="text-muted-foreground text-xs">
792
- {ccList.map((c) => c.name).join(", ")}
631
+ {formatAddressList(ccList.map((c) => `${displayParticipant(c).name} <${displayParticipant(c).email ?? c.email}>`))}
793
632
  </span>
794
633
  </div>
795
634
  ) : null}
@@ -851,11 +690,11 @@ function ReplyComposer({
851
690
  <div className="border-border space-y-1 rounded-md border p-3 text-[13px]">
852
691
  <div>
853
692
  <span className="text-muted-foreground">To </span>
854
- <b>{thread.contact.name}</b>{" "}
855
- <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>
856
695
  </div>
857
696
  {replyAll && ccList.length ? (
858
- <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>
859
698
  ) : null}
860
699
  <div>
861
700
  <span className="text-muted-foreground">Subject </span>
@@ -875,9 +714,10 @@ function ReplyComposer({
875
714
  <div
876
715
  data-slot="conv-preview-body"
877
716
  data-confirmation-token={previewState.confirmationToken ?? undefined}
878
- className={cn(PROSE, "max-h-72 overflow-auto")}
879
- dangerouslySetInnerHTML={{ __html: previewState.html ?? "" }}
880
- />
717
+ className="max-h-72 overflow-auto"
718
+ >
719
+ <EmailBody html={previewState.html ?? ""} variant="preview" collapseDetails={false} defaultDetailsOpen />
720
+ </div>
881
721
  )}
882
722
  {sendError ? (
883
723
  <p role="alert" className="text-destructive text-sm">
@@ -885,14 +725,17 @@ function ReplyComposer({
885
725
  </p>
886
726
  ) : null}
887
727
  <DialogFooter className="sm:justify-between">
888
- <button
889
- type="button"
890
- disabled={sending || previewState.loading}
891
- onClick={handleDraft}
892
- className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50"
893
- >
894
- <GmailMark size={14} /> Open draft in Gmail
895
- </button>
728
+ <span className="inline-flex" title={draftDisabledReason ?? undefined}>
729
+ <button
730
+ type="button"
731
+ disabled={sending || previewState.loading || draftDisabled}
732
+ onClick={handleDraft}
733
+ aria-label={draftDisabledReason ? `Open draft in Gmail: ${draftDisabledReason}` : "Open draft in Gmail"}
734
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50"
735
+ >
736
+ <GmailMark size={14} /> Open draft in Gmail
737
+ </button>
738
+ </span>
896
739
  <span className="flex items-center gap-2">
897
740
  <Button type="button" variant="outline" size="sm" disabled={sending} onClick={() => { setPreview(false); setPreviewState(IDLE_PREVIEW) }}>
898
741
  Keep editing
@@ -939,17 +782,30 @@ function ThreadBody({
939
782
  onOpenInGmail?: (threadId: string) => void
940
783
  }) {
941
784
  const canReply = thread.canReply !== false
785
+ const replyDisabledReason = thread.replyDisabledReason?.trim() || "You are not a participant on this thread, so replying is disabled here."
786
+ const draftDisabledReason = onCreateGmailDraft ? null : "Gmail draft creation is not available for this thread."
942
787
  const hasCc = !!(thread.cc && thread.cc.length)
788
+ const sortedMessages = React.useMemo(() => sortMessagesChronologically(thread.messages), [thread.messages])
943
789
  const [mode, setMode] = React.useState<ThreadMode>("idle")
944
790
  const [replyAll, setReplyAll] = React.useState(false)
945
791
  const [expanded, setExpanded] = React.useState<Record<string, boolean>>(() => {
946
792
  const o: Record<string, boolean> = {}
947
- thread.messages.forEach((m, i) => {
948
- o[m.id] = i === thread.messages.length - 1
793
+ sortedMessages.forEach((m, i) => {
794
+ o[m.id] = i === sortedMessages.length - 1
949
795
  })
950
796
  return o
951
797
  })
952
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
+
953
809
  const toggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }))
954
810
 
955
811
  return (
@@ -965,17 +821,18 @@ function ThreadBody({
965
821
  ) : null}
966
822
 
967
823
  <div className="space-y-1">
968
- {thread.messages.map((m) => (
824
+ {sortedMessages.map((m) => (
969
825
  <MessageView key={m.id} message={m} expanded={!!expanded[m.id]} onToggle={() => toggle(m.id)} me={me} />
970
826
  ))}
971
827
  </div>
972
828
 
973
829
  {!canReply ? (
974
- <div className="border-border bg-muted/30 text-muted-foreground flex items-start gap-2 rounded-md border p-2.5 text-[12px]">
830
+ <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]">
975
831
  <Eye size={14} className="mt-0.5 shrink-0" />
976
- <span>
977
- <b>Viewing only.</b> You’re not a participant on this thread, so replying is disabled here.
832
+ <span className="min-w-0 flex-1">
833
+ <b>Viewing only.</b> {replyDisabledReason}
978
834
  </span>
835
+ <OpenInGmailButton thread={thread} onOpenInGmail={onOpenInGmail} />
979
836
  </div>
980
837
  ) : null}
981
838
 
@@ -989,9 +846,7 @@ function ThreadBody({
989
846
  <ReplyAll size={14} /> Reply all
990
847
  </Button>
991
848
  ) : null}
992
- <Button type="button" variant="ghost" size="sm" onClick={() => onOpenInGmail?.(thread.threadId)}>
993
- <GmailMark size={14} /> Open in Gmail
994
- </Button>
849
+ <OpenInGmailButton thread={thread} onOpenInGmail={onOpenInGmail} />
995
850
  <span className="text-muted-foreground/70 ml-auto inline-flex items-center gap-1.5 text-[12px]">
996
851
  <GitMerge size={13} /> Stays on this thread
997
852
  </span>
@@ -1011,9 +866,11 @@ function ThreadBody({
1011
866
  setMode("sent")
1012
867
  }}
1013
868
  onDraft={async (body, includeSignature) => {
1014
- await onCreateGmailDraft?.({ threadId: thread.threadId, body, includeSignature, replyAll })
869
+ if (!onCreateGmailDraft) return
870
+ await onCreateGmailDraft({ threadId: thread.threadId, body, includeSignature, replyAll })
1015
871
  setMode("draft")
1016
872
  }}
873
+ draftDisabledReason={draftDisabledReason}
1017
874
  />
1018
875
  ) : null}
1019
876
 
@@ -1022,7 +879,7 @@ function ThreadBody({
1022
879
  <Check size={16} className="text-status-active-fg shrink-0" />
1023
880
  <span className="flex-1">
1024
881
  <b>{replyAll ? "Reply all sent" : "Reply sent"}</b> · added to the thread. Delivered to{" "}
1025
- <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.
1026
883
  </span>
1027
884
  <Button type="button" variant="ghost" size="sm" onClick={() => setMode("idle")}>
1028
885
  Done
@@ -1036,9 +893,7 @@ function ThreadBody({
1036
893
  <span className="flex-1">
1037
894
  <b>Draft saved to Gmail.</b> Waiting on the <b>Re: {thread.subject}</b> thread; open it there to finish. Nothing was sent.
1038
895
  </span>
1039
- <Button type="button" variant="ghost" size="sm" onClick={() => onOpenInGmail?.(thread.threadId)}>
1040
- <GmailMark size={14} /> Open in Gmail
1041
- </Button>
896
+ <OpenInGmailButton thread={thread} onOpenInGmail={onOpenInGmail} />
1042
897
  <Button type="button" variant="ghost" size="sm" onClick={() => setMode("idle")}>
1043
898
  Done
1044
899
  </Button>
@@ -1066,8 +921,11 @@ function ThreadRow({
1066
921
  onToggleOpen: () => void
1067
922
  } & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onPreviewReply" | "onOpenInGmail">) {
1068
923
  const status = effectiveStatus(thread)
1069
- const last = thread.messages[thread.messages.length - 1]
1070
- 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 ?? "")
1071
929
  const lastSnippet = last ? messageBodySnippet(last, 120) : ""
1072
930
  const pill = STATUS_PILL[status]
1073
931
 
@@ -1088,7 +946,7 @@ function ThreadRow({
1088
946
  </span>
1089
947
  </span>
1090
948
  <span className="text-muted-foreground block truncate text-xs">
1091
- <b className="text-foreground/80">{thread.contact.name}</b> · {who}: {lastSnippet}
949
+ <b className="text-foreground/80">{displayParticipant(thread.contact).name}</b> · {who}: {lastSnippet}
1092
950
  </span>
1093
951
  </span>
1094
952
  <span className="text-muted-foreground/60 shrink-0 text-xs">{thread.lastWhen}</span>