@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.
- package/dist/components/conversation-panel.d.ts +28 -1
- package/dist/components/conversation-panel.js +180 -310
- 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.js +66 -42
- 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 +230 -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 +53 -1
- package/src/components/conversation-panel.tsx +227 -369
- 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 +73 -53
- 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 }
|
|
@@ -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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
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
|
-
|
|
175
|
-
|
|
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(/ /gi, " ")
|
|
203
|
-
.replace(/&/gi, "&")
|
|
204
|
-
.replace(/</gi, "<")
|
|
205
|
-
.replace(/>/gi, ">")
|
|
206
|
-
.replace(/"/gi, '"')
|
|
207
|
-
.replace(/'|'/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
|
|
221
|
-
|
|
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
|
|
227
|
-
return
|
|
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
|
|
231
|
-
return
|
|
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
|
-
|
|
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
|
|
267
|
-
|
|
212
|
+
function parseMessageTimestampValue(value: MessageTimestampValue): number | null {
|
|
213
|
+
if (value == null || value === "") return null
|
|
268
214
|
|
|
269
|
-
if (
|
|
270
|
-
const
|
|
271
|
-
return
|
|
215
|
+
if (value instanceof Date) {
|
|
216
|
+
const time = value.getTime()
|
|
217
|
+
return Number.isNaN(time) ? null : time
|
|
272
218
|
}
|
|
273
219
|
|
|
274
|
-
if (
|
|
275
|
-
|
|
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
|
|
279
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
259
|
+
return null
|
|
326
260
|
}
|
|
327
261
|
|
|
328
|
-
function
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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={
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
561
|
-
const
|
|
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">{
|
|
576
|
-
|
|
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}
|
|
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
|
-
|
|
604
|
-
<
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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"
|
|
644
|
-
<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>
|
|
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:
|
|
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:
|
|
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).
|
|
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"><{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>
|
|
856
695
|
</div>
|
|
857
696
|
{replyAll && ccList.length ? (
|
|
858
|
-
<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>
|
|
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=
|
|
879
|
-
|
|
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
|
-
<
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
948
|
-
o[m.id] = i ===
|
|
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
|
-
{
|
|
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>
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
1070
|
-
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 ?? "")
|
|
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>
|