@handled-ai/design-system 0.20.0 → 0.20.2
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 +1 -1
- package/dist/components/conversation-panel.js +282 -15
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/owner-chips.d.ts +3 -4
- package/dist/components/owner-chips.js +77 -41
- package/dist/components/owner-chips.js.map +1 -1
- package/dist/components/score-why-chips.d.ts +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -1
- package/dist/components/timeline-activity.d.ts +4 -2
- package/dist/components/timeline-activity.js +366 -154
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/prototype/index.d.ts +1 -1
- package/dist/prototype/prototype-accounts-view.d.ts +1 -1
- package/dist/prototype/prototype-admin-view.d.ts +1 -1
- package/dist/prototype/prototype-config.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.d.ts +9 -3
- package/dist/prototype/prototype-inbox-view.js +94 -47
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -1
- package/dist/prototype/prototype-shell.d.ts +1 -1
- package/dist/{signal-priority-popover-Cg9XPJsp.d.ts → signal-priority-popover-BJHd07dU.d.ts} +6 -0
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +276 -0
- package/src/components/__tests__/owner-chips.test.tsx +137 -17
- package/src/components/__tests__/timeline-activity.test.tsx +92 -1
- package/src/components/conversation-panel.tsx +358 -21
- package/src/components/owner-chips.tsx +98 -63
- package/src/components/timeline-activity.tsx +452 -160
- package/src/prototype/__tests__/detail-view-case-panel-v2.test.tsx +181 -0
- package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +16 -2
- package/src/prototype/prototype-config.ts +6 -0
- package/src/prototype/prototype-inbox-view.tsx +128 -51
|
@@ -120,7 +120,7 @@ export interface ConversationPanelProps {
|
|
|
120
120
|
/** Deployment brand, used in the paused-playbook copy. */
|
|
121
121
|
tenantName?: string
|
|
122
122
|
onSendReply?: (payload: ConversationReplyPayload) => void | Promise<void>
|
|
123
|
-
onCreateGmailDraft?: (payload: ConversationReplyPayload) => void
|
|
123
|
+
onCreateGmailDraft?: (payload: ConversationReplyPayload) => void | Promise<void>
|
|
124
124
|
onOpenInGmail?: (threadId: string) => void
|
|
125
125
|
/** Inline-open this thread initially (defaults to the first responded one). */
|
|
126
126
|
defaultOpenThreadId?: string
|
|
@@ -152,6 +152,310 @@ function textToHtml(text: string): string {
|
|
|
152
152
|
.join("")
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
type CollapsedHtmlMessage = { bodyHtml: string; detailsHtml: string }
|
|
156
|
+
type CollapsedTextMessage = { bodyText: string; detailsText: string }
|
|
157
|
+
type MessageSegment = { html?: string; text?: string; visibleText: string }
|
|
158
|
+
|
|
159
|
+
const SPLITTABLE_BLOCK_TAGS = new Set(["p", "div"])
|
|
160
|
+
const WHOLE_BLOCK_TAGS = new Set(["blockquote", "table", "ul", "ol", "hr"])
|
|
161
|
+
const BLOCK_TAGS = new Set([...SPLITTABLE_BLOCK_TAGS, ...WHOLE_BLOCK_TAGS])
|
|
162
|
+
const BR_TAG_RE = /<br\s*\/?>/gi
|
|
163
|
+
const HTML_BLOCK_START_RE = /<(p|div|blockquote|table|ul|ol|hr)\b[^>]*>/i
|
|
164
|
+
|
|
165
|
+
const SIGNATURE_DELIMITER_RE = /^--\s*$/
|
|
166
|
+
const SIGNOFF_RE = /^(?:thanks,|thank you,|best,|regards,|sincerely,)$/i
|
|
167
|
+
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
|
|
168
|
+
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
|
|
169
|
+
|
|
170
|
+
function decodeHtmlText(value: string): string {
|
|
171
|
+
const withoutTags = value
|
|
172
|
+
.replace(BR_TAG_RE, "\n")
|
|
173
|
+
.replace(/<\/(p|div|blockquote|li|tr|table|ul|ol)>/gi, "\n")
|
|
174
|
+
.replace(/<[^>]*>/g, "")
|
|
175
|
+
|
|
176
|
+
if (typeof document !== "undefined") {
|
|
177
|
+
const textarea = document.createElement("textarea")
|
|
178
|
+
textarea.innerHTML = withoutTags
|
|
179
|
+
return textarea.value
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return withoutTags
|
|
183
|
+
.replace(/ /gi, " ")
|
|
184
|
+
.replace(/&/gi, "&")
|
|
185
|
+
.replace(/</gi, "<")
|
|
186
|
+
.replace(/>/gi, ">")
|
|
187
|
+
.replace(/"/gi, '"')
|
|
188
|
+
.replace(/'|'/gi, "'")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function htmlToVisibleText(html: string): string {
|
|
192
|
+
return decodeHtmlText(html).replace(/\u00a0/g, " ").replace(/[ \t]+/g, " ").trim()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function serializeNode(node: Node): string {
|
|
196
|
+
const host = document.createElement("div")
|
|
197
|
+
host.appendChild(node.cloneNode(true))
|
|
198
|
+
return host.innerHTML
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function wrapHtmlLike(element: Element, innerHtml: string): string {
|
|
202
|
+
const clone = element.cloneNode(false) as HTMLElement
|
|
203
|
+
clone.innerHTML = innerHtml
|
|
204
|
+
return clone.outerHTML
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function hasDirectBr(nodes: readonly Node[]): boolean {
|
|
208
|
+
return nodes.some((node) => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "br")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasDirectBlockChild(element: Element): boolean {
|
|
212
|
+
return Array.from(element.children).some((child) => {
|
|
213
|
+
const tagName = child.tagName.toLowerCase()
|
|
214
|
+
return tagName !== "br" && BLOCK_TAGS.has(tagName)
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function makeHtmlSegment(html: string): MessageSegment | null {
|
|
219
|
+
const visibleText = htmlToVisibleText(html)
|
|
220
|
+
if (!html.trim() || (!visibleText && !/<(?:img|hr)\b/i.test(html))) return null
|
|
221
|
+
return { html, visibleText }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSegment[] {
|
|
225
|
+
const containsBr = hasDirectBr(nodes)
|
|
226
|
+
const chunks: string[][] = [[]]
|
|
227
|
+
|
|
228
|
+
nodes.forEach((node) => {
|
|
229
|
+
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "br") {
|
|
230
|
+
chunks.push([])
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
chunks[chunks.length - 1]?.push(serializeNode(node))
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
return chunks
|
|
238
|
+
.map((chunk) => chunk.join(""))
|
|
239
|
+
.map((innerHtml) => {
|
|
240
|
+
if (wrapper) return wrapHtmlLike(wrapper, innerHtml)
|
|
241
|
+
return containsBr ? `<div>${innerHtml}</div>` : innerHtml
|
|
242
|
+
})
|
|
243
|
+
.map(makeHtmlSegment)
|
|
244
|
+
.filter((segment): segment is MessageSegment => Boolean(segment))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function splitElementSegment(element: Element): MessageSegment[] {
|
|
248
|
+
const tagName = element.tagName.toLowerCase()
|
|
249
|
+
|
|
250
|
+
if (tagName === "div" && hasDirectBlockChild(element)) {
|
|
251
|
+
const childSegments = splitHtmlNodes(Array.from(element.childNodes))
|
|
252
|
+
return childSegments.length ? childSegments : [makeHtmlSegment(element.outerHTML)].filter(Boolean) as MessageSegment[]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (SPLITTABLE_BLOCK_TAGS.has(tagName) && hasDirectBr(Array.from(element.childNodes)) && !hasDirectBlockChild(element)) {
|
|
256
|
+
return splitInlineNodes(Array.from(element.childNodes), element)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const segment = makeHtmlSegment(element.outerHTML)
|
|
260
|
+
return segment ? [segment] : []
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function splitHtmlNodes(nodes: readonly Node[]): MessageSegment[] {
|
|
264
|
+
const segments: MessageSegment[] = []
|
|
265
|
+
let inlineNodes: Node[] = []
|
|
266
|
+
|
|
267
|
+
const flushInline = () => {
|
|
268
|
+
if (!inlineNodes.length) return
|
|
269
|
+
segments.push(...splitInlineNodes(inlineNodes))
|
|
270
|
+
inlineNodes = []
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
nodes.forEach((node) => {
|
|
274
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
275
|
+
const element = node as Element
|
|
276
|
+
const tagName = element.tagName.toLowerCase()
|
|
277
|
+
if (BLOCK_TAGS.has(tagName)) {
|
|
278
|
+
flushInline()
|
|
279
|
+
segments.push(...splitElementSegment(element))
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
inlineNodes.push(node)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
flushInline()
|
|
288
|
+
return segments
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function findMatchingCloseTag(html: string, tagName: string, openTagEnd: number): number {
|
|
292
|
+
if (tagName === "hr") return openTagEnd + 1
|
|
293
|
+
|
|
294
|
+
const tagPattern = new RegExp(`</?${tagName}\\b[^>]*>`, "gi")
|
|
295
|
+
tagPattern.lastIndex = openTagEnd + 1
|
|
296
|
+
let depth = 1
|
|
297
|
+
let match: RegExpExecArray | null
|
|
298
|
+
|
|
299
|
+
while ((match = tagPattern.exec(html)) !== null) {
|
|
300
|
+
const rawTag = match[0]
|
|
301
|
+
if (/^<\//.test(rawTag)) depth -= 1
|
|
302
|
+
else if (!/\/\s*>$/.test(rawTag)) depth += 1
|
|
303
|
+
if (depth === 0) return tagPattern.lastIndex
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return html.length
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
|
|
310
|
+
const segments: MessageSegment[] = []
|
|
311
|
+
let cursor = 0
|
|
312
|
+
|
|
313
|
+
const pushInline = (inlineHtml: string) => {
|
|
314
|
+
const chunks = inlineHtml.split(BR_TAG_RE)
|
|
315
|
+
const hadBr = chunks.length > 1
|
|
316
|
+
chunks.forEach((chunk) => {
|
|
317
|
+
const segment = makeHtmlSegment(hadBr ? `<div>${chunk}</div>` : chunk)
|
|
318
|
+
if (segment) segments.push(segment)
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
while (cursor < html.length) {
|
|
323
|
+
const rest = html.slice(cursor)
|
|
324
|
+
const match = HTML_BLOCK_START_RE.exec(rest)
|
|
325
|
+
if (!match || match.index === undefined) {
|
|
326
|
+
pushInline(rest)
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (match.index > 0) pushInline(rest.slice(0, match.index))
|
|
331
|
+
|
|
332
|
+
const tagStart = cursor + match.index
|
|
333
|
+
const rawOpen = match[0]
|
|
334
|
+
const tagName = match[1].toLowerCase()
|
|
335
|
+
const openTagEnd = tagStart + rawOpen.length - 1
|
|
336
|
+
const segmentEnd = findMatchingCloseTag(html, tagName, openTagEnd)
|
|
337
|
+
const blockHtml = html.slice(tagStart, segmentEnd)
|
|
338
|
+
|
|
339
|
+
if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
|
|
340
|
+
const openTag = rawOpen
|
|
341
|
+
const closeTag = `</${tagName}>`
|
|
342
|
+
const inner = blockHtml.replace(new RegExp(`^${rawOpen.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"), "").replace(new RegExp(`${closeTag}$`, "i"), "")
|
|
343
|
+
inner.split(BR_TAG_RE).forEach((chunk) => {
|
|
344
|
+
const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`)
|
|
345
|
+
if (segment) segments.push(segment)
|
|
346
|
+
})
|
|
347
|
+
} else {
|
|
348
|
+
const segment = makeHtmlSegment(blockHtml)
|
|
349
|
+
if (segment) segments.push(segment)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
cursor = segmentEnd
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return segments
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function segmentHtmlMessage(html: string): MessageSegment[] {
|
|
359
|
+
if (typeof document === "undefined" || typeof Node === "undefined") {
|
|
360
|
+
return splitHtmlSegmentsFallback(html)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const template = document.createElement("template")
|
|
364
|
+
template.innerHTML = html
|
|
365
|
+
return splitHtmlNodes(Array.from(template.content.childNodes))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function segmentTextMessage(text: string): MessageSegment[] {
|
|
369
|
+
const lines = text.match(/[^\n]*(?:\n|$)/g) ?? []
|
|
370
|
+
return lines
|
|
371
|
+
.filter((line, index) => line.length > 0 && !(index === lines.length - 1 && line === ""))
|
|
372
|
+
.map((line) => ({ text: line, visibleText: line.replace(/\u00a0/g, " ").trim() }))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function firstVisibleLine(text: string): string {
|
|
376
|
+
return text.replace(/\u00a0/g, " ").trimStart().split(/\r?\n/).find((line) => line.trim())?.trim() ?? ""
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function isLikelySenderNameLine(line: string): boolean {
|
|
380
|
+
if (!line || line.length > 60) return false
|
|
381
|
+
if (/[,@:;!?]|https?:\/\/|www\.|\d/.test(line)) return false
|
|
382
|
+
|
|
383
|
+
const words = line.split(/\s+/).filter(Boolean)
|
|
384
|
+
if (words.length < 1 || words.length > 4) return false
|
|
385
|
+
|
|
386
|
+
return words.every((word) => /^[A-Z][A-Za-z'.-]*$/.test(word))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function nextVisibleSegmentText(segments: MessageSegment[], fromIndex: number): string {
|
|
390
|
+
for (let index = fromIndex + 1; index < segments.length; index += 1) {
|
|
391
|
+
const text = firstVisibleLine(segments[index]?.visibleText ?? "")
|
|
392
|
+
if (text) return text
|
|
393
|
+
}
|
|
394
|
+
return ""
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isFooterBoundary(segments: MessageSegment[], index: number): boolean {
|
|
398
|
+
const line = firstVisibleLine(segments[index]?.visibleText ?? "")
|
|
399
|
+
if (!line) return false
|
|
400
|
+
if (SIGNATURE_DELIMITER_RE.test(line) || DETAILS_START_RE.test(line)) return true
|
|
401
|
+
|
|
402
|
+
const nextText = nextVisibleSegmentText(segments, index)
|
|
403
|
+
if (SIGNOFF_RE.test(line)) return Boolean(nextText && (isLikelySenderNameLine(nextText) || CONTACT_DETAIL_RE.test(nextText)))
|
|
404
|
+
|
|
405
|
+
return isLikelySenderNameLine(line) && CONTACT_DETAIL_RE.test(nextText)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function splitFooterSegments(segments: MessageSegment[]): { bodySegments: MessageSegment[]; detailsSegments: MessageSegment[] } {
|
|
409
|
+
const visibleIndexes = segments
|
|
410
|
+
.map((segment, index) => (segment.visibleText ? index : -1))
|
|
411
|
+
.filter((index) => index >= 0)
|
|
412
|
+
const visibleCount = visibleIndexes.length
|
|
413
|
+
if (visibleCount < 2) return { bodySegments: segments, detailsSegments: [] }
|
|
414
|
+
|
|
415
|
+
const trailingCount = Math.max(Math.ceil(visibleCount * 0.4), 8)
|
|
416
|
+
const firstTrailingOrdinal = Math.max(1, visibleCount - trailingCount)
|
|
417
|
+
|
|
418
|
+
for (let ordinal = firstTrailingOrdinal; ordinal < visibleCount; ordinal += 1) {
|
|
419
|
+
const index = visibleIndexes[ordinal]
|
|
420
|
+
if (ordinal > 0 && isFooterBoundary(segments, index)) {
|
|
421
|
+
return { bodySegments: segments.slice(0, index), detailsSegments: segments.slice(index) }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { bodySegments: segments, detailsSegments: [] }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function splitMessageHtml(rawHtml: string): CollapsedHtmlMessage {
|
|
429
|
+
const sanitizedHtml = sanitizeHtml(rawHtml)
|
|
430
|
+
const { bodySegments, detailsSegments } = splitFooterSegments(segmentHtmlMessage(sanitizedHtml))
|
|
431
|
+
|
|
432
|
+
if (!detailsSegments.length) return { bodyHtml: sanitizedHtml, detailsHtml: "" }
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
bodyHtml: bodySegments.map((segment) => segment.html ?? "").join(""),
|
|
436
|
+
detailsHtml: detailsSegments.map((segment) => segment.html ?? "").join(""),
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function splitMessageText(text: string): CollapsedTextMessage {
|
|
441
|
+
const { bodySegments, detailsSegments } = splitFooterSegments(segmentTextMessage(text))
|
|
442
|
+
|
|
443
|
+
if (!detailsSegments.length) return { bodyText: text, detailsText: "" }
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
bodyText: bodySegments.map((segment) => segment.text ?? "").join(""),
|
|
447
|
+
detailsText: detailsSegments.map((segment) => segment.text ?? "").join(""),
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function messageBodySnippet(message: Pick<ConvMessage, "body" | "bodyHtml">, maxLength = 140): string {
|
|
452
|
+
if (message.bodyHtml) {
|
|
453
|
+
return htmlToTextSnippet(splitMessageHtml(message.bodyHtml).bodyHtml, maxLength)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return splitMessageText(message.body ?? "").bodyText.split("\n").find((line) => line.trim())?.trim() ?? ""
|
|
457
|
+
}
|
|
458
|
+
|
|
155
459
|
function GmailMark({ size = 14 }: { size?: number }) {
|
|
156
460
|
return (
|
|
157
461
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -212,11 +516,11 @@ function MessageView({
|
|
|
212
516
|
me?: ConvParticipant
|
|
213
517
|
}) {
|
|
214
518
|
const [quoteOpen, setQuoteOpen] = React.useState(false)
|
|
215
|
-
const
|
|
216
|
-
message.body?.split("\n").find(Boolean) ??
|
|
217
|
-
(message.bodyHtml ? htmlToTextSnippet(message.bodyHtml, 140) : "")
|
|
519
|
+
const [detailsOpen, setDetailsOpen] = React.useState(false)
|
|
218
520
|
|
|
219
521
|
if (!expanded) {
|
|
522
|
+
const snippet = messageBodySnippet(message, 140)
|
|
523
|
+
|
|
220
524
|
return (
|
|
221
525
|
<button
|
|
222
526
|
type="button"
|
|
@@ -235,6 +539,9 @@ function MessageView({
|
|
|
235
539
|
}
|
|
236
540
|
|
|
237
541
|
const toLabel = me && message.to.email === me.email ? "me" : firstName(message.to.name)
|
|
542
|
+
const htmlParts = message.bodyHtml ? splitMessageHtml(message.bodyHtml) : null
|
|
543
|
+
const textParts = message.bodyHtml ? null : splitMessageText(message.body ?? "")
|
|
544
|
+
const hasDetails = Boolean(htmlParts?.detailsHtml || textParts?.detailsText)
|
|
238
545
|
|
|
239
546
|
return (
|
|
240
547
|
<div data-slot="conv-message" className="rounded-md border border-border bg-background">
|
|
@@ -273,13 +580,35 @@ function MessageView({
|
|
|
273
580
|
</span>
|
|
274
581
|
</button>
|
|
275
582
|
|
|
276
|
-
<div className="px-3 pb-
|
|
277
|
-
{
|
|
278
|
-
<div className={PROSE} dangerouslySetInnerHTML={{ __html:
|
|
583
|
+
<div className="px-3 pb-2.5">
|
|
584
|
+
{htmlParts ? (
|
|
585
|
+
<div data-slot="conv-message-body" className={PROSE} dangerouslySetInnerHTML={{ __html: htmlParts.bodyHtml }} />
|
|
279
586
|
) : (
|
|
280
|
-
<div className={cn(PROSE, "whitespace-pre-line")}>{
|
|
587
|
+
<div data-slot="conv-message-body" className={cn(PROSE, "whitespace-pre-line")}>{textParts?.bodyText}</div>
|
|
281
588
|
)}
|
|
282
589
|
|
|
590
|
+
{hasDetails ? (
|
|
591
|
+
<div className="mt-2">
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
onClick={() => setDetailsOpen((v) => !v)}
|
|
595
|
+
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded px-1.5 text-xs leading-5"
|
|
596
|
+
aria-expanded={detailsOpen}
|
|
597
|
+
>
|
|
598
|
+
{detailsOpen ? "Hide signature/details" : "Show signature/details"}
|
|
599
|
+
</button>
|
|
600
|
+
{detailsOpen ? (
|
|
601
|
+
<div className="border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]">
|
|
602
|
+
{htmlParts ? (
|
|
603
|
+
<div data-slot="conv-message-details" className={PROSE} dangerouslySetInnerHTML={{ __html: htmlParts.detailsHtml }} />
|
|
604
|
+
) : (
|
|
605
|
+
<div data-slot="conv-message-details" className={cn(PROSE, "whitespace-pre-line")}>{textParts?.detailsText}</div>
|
|
606
|
+
)}
|
|
607
|
+
</div>
|
|
608
|
+
) : null}
|
|
609
|
+
</div>
|
|
610
|
+
) : null}
|
|
611
|
+
|
|
283
612
|
{message.quoted ? (
|
|
284
613
|
<div className="mt-2">
|
|
285
614
|
<button
|
|
@@ -320,7 +649,7 @@ function ReplyComposer({
|
|
|
320
649
|
tenantName?: string
|
|
321
650
|
onClose: () => void
|
|
322
651
|
onSend: (body: string, includeSignature: boolean) => void | Promise<void>
|
|
323
|
-
onDraft: (body: string, includeSignature: boolean) => void
|
|
652
|
+
onDraft: (body: string, includeSignature: boolean) => void | Promise<void>
|
|
324
653
|
}) {
|
|
325
654
|
const [body, setBody] = React.useState(thread.draft ?? "")
|
|
326
655
|
const [sig, setSig] = React.useState(true)
|
|
@@ -345,6 +674,19 @@ function ReplyComposer({
|
|
|
345
674
|
}
|
|
346
675
|
}
|
|
347
676
|
|
|
677
|
+
const handleDraft = async () => {
|
|
678
|
+
setSending(true)
|
|
679
|
+
setSendError(null)
|
|
680
|
+
try {
|
|
681
|
+
await onDraft(body, sig)
|
|
682
|
+
setPreview(false)
|
|
683
|
+
} catch (error) {
|
|
684
|
+
setSendError(error instanceof Error ? error.message : "Could not create the Gmail draft. Please try again.")
|
|
685
|
+
} finally {
|
|
686
|
+
setSending(false)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
348
690
|
return (
|
|
349
691
|
<div data-slot="conv-reply" className="border-border bg-muted/20 rounded-md border p-3">
|
|
350
692
|
<div className="mb-2 flex items-center gap-2">
|
|
@@ -460,10 +802,7 @@ function ReplyComposer({
|
|
|
460
802
|
<button
|
|
461
803
|
type="button"
|
|
462
804
|
disabled={sending}
|
|
463
|
-
onClick={
|
|
464
|
-
setPreview(false)
|
|
465
|
-
onDraft(body, sig)
|
|
466
|
-
}}
|
|
805
|
+
onClick={handleDraft}
|
|
467
806
|
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50"
|
|
468
807
|
>
|
|
469
808
|
<GmailMark size={14} /> Open draft in Gmail
|
|
@@ -508,7 +847,7 @@ function ThreadBody({
|
|
|
508
847
|
me?: ConvParticipant
|
|
509
848
|
tenantName?: string
|
|
510
849
|
onSendReply?: (p: ConversationReplyPayload) => void | Promise<void>
|
|
511
|
-
onCreateGmailDraft?: (p: ConversationReplyPayload) => void
|
|
850
|
+
onCreateGmailDraft?: (p: ConversationReplyPayload) => void | Promise<void>
|
|
512
851
|
onOpenInGmail?: (threadId: string) => void
|
|
513
852
|
}) {
|
|
514
853
|
const canReply = thread.canReply !== false
|
|
@@ -526,7 +865,7 @@ function ThreadBody({
|
|
|
526
865
|
const toggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }))
|
|
527
866
|
|
|
528
867
|
return (
|
|
529
|
-
<div data-slot="conv-thread-body" className="space-y-
|
|
868
|
+
<div data-slot="conv-thread-body" className="space-y-1.5">
|
|
530
869
|
{canReply && thread.paused ? (
|
|
531
870
|
<div className="border-status-pending-border bg-status-pending-bg text-status-pending-fg flex items-start gap-2 rounded-md border p-2.5 text-[12px]">
|
|
532
871
|
<Pause size={13} className="mt-0.5 shrink-0" />
|
|
@@ -537,7 +876,7 @@ function ThreadBody({
|
|
|
537
876
|
</div>
|
|
538
877
|
) : null}
|
|
539
878
|
|
|
540
|
-
<div className="space-y-1
|
|
879
|
+
<div className="space-y-1">
|
|
541
880
|
{thread.messages.map((m) => (
|
|
542
881
|
<MessageView key={m.id} message={m} expanded={!!expanded[m.id]} onToggle={() => toggle(m.id)} me={me} />
|
|
543
882
|
))}
|
|
@@ -582,8 +921,8 @@ function ThreadBody({
|
|
|
582
921
|
await onSendReply?.({ threadId: thread.threadId, body, includeSignature, replyAll })
|
|
583
922
|
setMode("sent")
|
|
584
923
|
}}
|
|
585
|
-
onDraft={(body, includeSignature) => {
|
|
586
|
-
onCreateGmailDraft?.({ threadId: thread.threadId, body, includeSignature, replyAll })
|
|
924
|
+
onDraft={async (body, includeSignature) => {
|
|
925
|
+
await onCreateGmailDraft?.({ threadId: thread.threadId, body, includeSignature, replyAll })
|
|
587
926
|
setMode("draft")
|
|
588
927
|
}}
|
|
589
928
|
/>
|
|
@@ -639,9 +978,7 @@ function ThreadRow({
|
|
|
639
978
|
const status = effectiveStatus(thread)
|
|
640
979
|
const last = thread.messages[thread.messages.length - 1]
|
|
641
980
|
const who = last?.direction === "inbound" ? firstName(last.from.name) : "You"
|
|
642
|
-
const lastSnippet =
|
|
643
|
-
last?.body?.split("\n").find(Boolean) ??
|
|
644
|
-
(last?.bodyHtml ? htmlToTextSnippet(last.bodyHtml, 120) : "")
|
|
981
|
+
const lastSnippet = last ? messageBodySnippet(last, 120) : ""
|
|
645
982
|
const pill = STATUS_PILL[status]
|
|
646
983
|
|
|
647
984
|
return (
|
|
@@ -12,10 +12,9 @@
|
|
|
12
12
|
* more than one (AE + RM). Informational + links out to Salesforce; never
|
|
13
13
|
* assigned from inside Handled. Leads with the Salesforce mark.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* while still surfacing the full set when there is more than one.
|
|
15
|
+
* Account owner chips are read-only dropdowns. A single owner still opens a
|
|
16
|
+
* small Salesforce-sourced details menu; multiple owners show stacked avatars
|
|
17
|
+
* and a ×N badge before listing each owner with optional Salesforce links.
|
|
19
18
|
*
|
|
20
19
|
* Presentational only: data + handlers come from the consumer (the app).
|
|
21
20
|
*/
|
|
@@ -125,7 +124,7 @@ function SignalOwnerChip({
|
|
|
125
124
|
</span>
|
|
126
125
|
<span className="bg-border/70 mx-0.5 h-3.5 w-px" aria-hidden />
|
|
127
126
|
<span className={cn("font-medium", owner ? "text-foreground" : "text-muted-foreground")}>
|
|
128
|
-
{owner ? owner.name
|
|
127
|
+
{owner ? owner.name : "Unassigned"}
|
|
129
128
|
</span>
|
|
130
129
|
</>
|
|
131
130
|
)
|
|
@@ -208,43 +207,103 @@ export interface AccountOwnerChipProps {
|
|
|
208
207
|
className?: string
|
|
209
208
|
}
|
|
210
209
|
|
|
211
|
-
function
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
210
|
+
function AccountOwnerSummary({ person }: { person: OwnerPerson }) {
|
|
211
|
+
return (
|
|
212
|
+
<span className="flex min-w-0 flex-col">
|
|
213
|
+
<span className="truncate text-[13px] font-medium">{person.name}</span>
|
|
214
|
+
{person.role ? (
|
|
215
|
+
<span className="text-muted-foreground truncate text-[11px]">{person.role}</span>
|
|
216
|
+
) : null}
|
|
217
|
+
{person.email ? (
|
|
218
|
+
<span className="text-muted-foreground truncate text-[11px]">{person.email}</span>
|
|
219
|
+
) : null}
|
|
220
|
+
</span>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
215
223
|
|
|
216
|
-
|
|
217
|
-
if (!
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
<span className="inline-flex shrink-0 items-center">
|
|
222
|
-
<SalesforceMark />
|
|
223
|
-
</span>
|
|
224
|
-
<OwnerAvatar person={only} />
|
|
225
|
-
<span className="text-foreground font-medium">{only.name.split(" ")[0]}</span>
|
|
226
|
-
</>
|
|
227
|
-
)
|
|
228
|
-
return only.href ? (
|
|
224
|
+
function AccountOwnerSalesforceLink({ person }: { person: OwnerPerson }) {
|
|
225
|
+
if (!person.href) return null
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<DropdownMenuItem asChild className="p-0">
|
|
229
229
|
<a
|
|
230
|
-
href={
|
|
230
|
+
href={person.href}
|
|
231
231
|
target="_blank"
|
|
232
232
|
rel="noopener noreferrer"
|
|
233
|
-
data-
|
|
234
|
-
className=
|
|
235
|
-
title={`
|
|
233
|
+
data-account-owner-salesforce-link="true"
|
|
234
|
+
className="text-foreground focus:bg-accent focus:text-accent-foreground flex items-center gap-1.5 rounded-sm px-2 py-1.5 text-[12px] font-medium outline-hidden transition-colors"
|
|
235
|
+
title={`Open ${person.name} in Salesforce`}
|
|
236
236
|
>
|
|
237
|
-
{
|
|
238
|
-
|
|
237
|
+
<ArrowUpRight size={12} className="text-muted-foreground" />
|
|
238
|
+
Open in Salesforce
|
|
239
239
|
</a>
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
240
|
+
</DropdownMenuItem>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function AccountOwnerRow({ person }: { person: OwnerPerson }) {
|
|
245
|
+
return (
|
|
246
|
+
<>
|
|
247
|
+
<DropdownMenuItem
|
|
248
|
+
disabled
|
|
249
|
+
data-owner-row="true"
|
|
250
|
+
className="items-start gap-2 py-1.5 data-[disabled]:opacity-100"
|
|
245
251
|
>
|
|
246
|
-
{
|
|
247
|
-
|
|
252
|
+
<OwnerAvatar person={person} size="default" />
|
|
253
|
+
<AccountOwnerSummary person={person} />
|
|
254
|
+
</DropdownMenuItem>
|
|
255
|
+
<AccountOwnerSalesforceLink person={person} />
|
|
256
|
+
</>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function SalesforceReadOnlyHelper() {
|
|
261
|
+
return (
|
|
262
|
+
<div role="presentation" className="text-muted-foreground flex items-start gap-1.5 px-2 py-1.5 text-[11px]">
|
|
263
|
+
<Info size={12} className="mt-0.5 shrink-0" />
|
|
264
|
+
<span>Read-only from Salesforce. Manage owners in Salesforce.</span>
|
|
265
|
+
</div>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function AccountOwnerChip({ owners, className }: AccountOwnerChipProps) {
|
|
270
|
+
const [open, setOpen] = React.useState(false)
|
|
271
|
+
if (!owners.length) return null
|
|
272
|
+
const multi = owners.length > 1
|
|
273
|
+
|
|
274
|
+
if (!multi) {
|
|
275
|
+
const only = owners[0]
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
279
|
+
<DropdownMenuTrigger asChild>
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
data-slot="account-owner-chip"
|
|
283
|
+
className={cn(chipBase, "hover:bg-muted cursor-pointer transition-colors", className)}
|
|
284
|
+
title={`Account owner in Salesforce — ${only.name}`}
|
|
285
|
+
>
|
|
286
|
+
<span className="inline-flex shrink-0 items-center">
|
|
287
|
+
<SalesforceMark />
|
|
288
|
+
</span>
|
|
289
|
+
<OwnerAvatar person={only} />
|
|
290
|
+
<span className="text-foreground font-medium">{only.name}</span>
|
|
291
|
+
<span className="text-muted-foreground ml-0.5">
|
|
292
|
+
{open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
|
293
|
+
</span>
|
|
294
|
+
</button>
|
|
295
|
+
</DropdownMenuTrigger>
|
|
296
|
+
<DropdownMenuContent align="start" className="w-72">
|
|
297
|
+
<DropdownMenuLabel className="flex items-center gap-1.5 text-[13px]">
|
|
298
|
+
<SalesforceMark />
|
|
299
|
+
Account owner
|
|
300
|
+
</DropdownMenuLabel>
|
|
301
|
+
<DropdownMenuSeparator />
|
|
302
|
+
<AccountOwnerRow person={only} />
|
|
303
|
+
<DropdownMenuSeparator />
|
|
304
|
+
<SalesforceReadOnlyHelper />
|
|
305
|
+
</DropdownMenuContent>
|
|
306
|
+
</DropdownMenu>
|
|
248
307
|
)
|
|
249
308
|
}
|
|
250
309
|
|
|
@@ -282,35 +341,11 @@ function AccountOwnerChip({ owners, className }: AccountOwnerChipProps) {
|
|
|
282
341
|
{owners.length} account owners
|
|
283
342
|
</DropdownMenuLabel>
|
|
284
343
|
<DropdownMenuSeparator />
|
|
285
|
-
{owners.map((o) =>
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
<OwnerAvatar person={o} size="default" />
|
|
289
|
-
<span className="flex min-w-0 flex-col">
|
|
290
|
-
<span className="truncate text-[13px] font-medium">{o.name}</span>
|
|
291
|
-
{o.role ? (
|
|
292
|
-
<span className="text-muted-foreground truncate text-[11px]">{o.role}</span>
|
|
293
|
-
) : null}
|
|
294
|
-
</span>
|
|
295
|
-
{o.href ? <ArrowUpRight size={13} className="text-muted-foreground ml-auto" /> : null}
|
|
296
|
-
</>
|
|
297
|
-
)
|
|
298
|
-
return (
|
|
299
|
-
<DropdownMenuItem key={o.id ?? o.name} asChild={!!o.href} className="gap-2">
|
|
300
|
-
{o.href ? (
|
|
301
|
-
<a href={o.href} target="_blank" rel="noopener noreferrer" title={`Open ${o.name} in Salesforce`}>
|
|
302
|
-
{row}
|
|
303
|
-
</a>
|
|
304
|
-
) : (
|
|
305
|
-
<span>{row}</span>
|
|
306
|
-
)}
|
|
307
|
-
</DropdownMenuItem>
|
|
308
|
-
)
|
|
309
|
-
})}
|
|
344
|
+
{owners.map((o) => (
|
|
345
|
+
<AccountOwnerRow key={o.id ?? o.name} person={o} />
|
|
346
|
+
))}
|
|
310
347
|
<DropdownMenuSeparator />
|
|
311
|
-
<
|
|
312
|
-
<Info size={12} /> Synced from Salesforce. Manage owners there.
|
|
313
|
-
</div>
|
|
348
|
+
<SalesforceReadOnlyHelper />
|
|
314
349
|
</DropdownMenuContent>
|
|
315
350
|
</DropdownMenu>
|
|
316
351
|
)
|