@handled-ai/design-system 0.20.5 → 0.20.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/conversation-panel.d.ts +19 -0
- package/dist/components/conversation-panel.js +116 -292
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/email-body.d.ts +15 -0
- package/dist/components/email-body.js +101 -0
- package/dist/components/email-body.js.map +1 -0
- package/dist/components/email-display-helpers.d.ts +34 -0
- package/dist/components/email-display-helpers.js +436 -0
- package/dist/components/email-display-helpers.js.map +1 -0
- package/dist/components/email-preview-card.d.ts +7 -4
- package/dist/components/email-preview-card.js +48 -25
- package/dist/components/email-preview-card.js.map +1 -1
- package/dist/components/timeline-activity.d.ts +1 -0
- package/dist/components/timeline-activity.js +116 -65
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/safe-html.d.ts +1 -1
- package/dist/internal/safe-html.js +64 -3
- package/dist/internal/safe-html.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +182 -22
- package/src/components/__tests__/email-body.test.tsx +83 -0
- package/src/components/__tests__/email-display-helpers.test.ts +91 -0
- package/src/components/__tests__/email-preview-card.test.tsx +36 -2
- package/src/components/__tests__/timeline-activity.test.tsx +87 -1
- package/src/components/conversation-panel.tsx +136 -350
- package/src/components/email-body.tsx +126 -0
- package/src/components/email-display-helpers.ts +557 -0
- package/src/components/email-preview-card.tsx +54 -29
- package/src/components/timeline-activity.tsx +105 -63
- package/src/index.ts +2 -0
- package/src/internal/__tests__/safe-html.test.ts +34 -2
- 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
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
|
-
|
|
183
|
-
|
|
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(/ /gi, " ")
|
|
211
|
-
.replace(/&/gi, "&")
|
|
212
|
-
.replace(/</gi, "<")
|
|
213
|
-
.replace(/>/gi, ">")
|
|
214
|
-
.replace(/"/gi, '"')
|
|
215
|
-
.replace(/'|'/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
|
|
223
|
-
|
|
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
|
|
229
|
-
|
|
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
|
|
235
|
-
return
|
|
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
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
const chunks: string[][] = [[]]
|
|
212
|
+
function parseMessageTimestampValue(value: MessageTimestampValue): number | null {
|
|
213
|
+
if (value == null || value === "") return null
|
|
254
214
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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 (
|
|
283
|
-
|
|
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
|
|
287
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
259
|
+
return null
|
|
334
260
|
}
|
|
335
261
|
|
|
336
|
-
function
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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={
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
625
|
-
const
|
|
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">{
|
|
640
|
-
|
|
424
|
+
<span className="text-[13px] font-semibold">{fromDisplay.name}</span>
|
|
425
|
+
{fromDisplay.email ? (
|
|
426
|
+
<span className="text-muted-foreground/60 truncate text-xs"><{fromDisplay.email}></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
|
-
|
|
668
|
-
<
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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"
|
|
708
|
-
<div
|
|
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:
|
|
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:
|
|
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).
|
|
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"><{thread.contact.email}></span>
|
|
693
|
+
<b>{displayParticipant(thread.contact).name}</b>{" "}
|
|
694
|
+
<span className="text-muted-foreground/60"><{displayParticipant(thread.contact).email ?? thread.contact.email}></span>
|
|
924
695
|
</div>
|
|
925
696
|
{replyAll && ccList.length ? (
|
|
926
|
-
<div className="text-muted-foreground">Cc {ccList.map((c) => c.name).
|
|
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=
|
|
947
|
-
|
|
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
|
-
|
|
1021
|
-
o[m.id] = i ===
|
|
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
|
-
{
|
|
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
|
|
1142
|
-
const
|
|
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>
|