@handled-ai/design-system 0.20.23 → 0.20.26
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/button.d.ts +1 -1
- package/dist/components/conversation-panel.d.ts +30 -2
- package/dist/components/conversation-panel.js +71 -11
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/email-display-helpers.d.ts +3 -1
- package/dist/components/email-display-helpers.js +63 -14
- package/dist/components/email-display-helpers.js.map +1 -1
- package/dist/components/timeline-activity.js +18 -6
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +58 -0
- package/src/components/__tests__/email-display-helpers.test.ts +45 -1
- package/src/components/__tests__/timeline-activity.test.tsx +7 -1
- package/src/components/conversation-panel.tsx +95 -1
- package/src/components/email-display-helpers.ts +63 -15
- package/src/components/timeline-activity.tsx +28 -6
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
GitMerge,
|
|
39
39
|
Check,
|
|
40
40
|
X,
|
|
41
|
+
FileText,
|
|
41
42
|
} from "lucide-react"
|
|
42
43
|
|
|
43
44
|
import { cn } from "../lib/utils"
|
|
@@ -48,6 +49,14 @@ import { Button } from "./button"
|
|
|
48
49
|
import { Switch } from "./switch"
|
|
49
50
|
import { Textarea } from "./textarea"
|
|
50
51
|
import { RichTextToolbar } from "./rich-text-toolbar"
|
|
52
|
+
import {
|
|
53
|
+
DropdownMenu,
|
|
54
|
+
DropdownMenuContent,
|
|
55
|
+
DropdownMenuItem,
|
|
56
|
+
DropdownMenuLabel,
|
|
57
|
+
DropdownMenuSeparator,
|
|
58
|
+
DropdownMenuTrigger,
|
|
59
|
+
} from "./dropdown-menu"
|
|
51
60
|
import { EmailBody } from "./email-body"
|
|
52
61
|
import { decodeEmailDisplayText, emailBodySnippet, formatAddressList, normalizeEmailSender } from "./email-display-helpers"
|
|
53
62
|
import {
|
|
@@ -116,6 +125,21 @@ export interface ConvMessage {
|
|
|
116
125
|
|
|
117
126
|
export type ConvStatus = "responded" | "awaiting" | "viewing" | "draft"
|
|
118
127
|
|
|
128
|
+
/**
|
|
129
|
+
* A reply template offered in the in-composer "Apply template" picker (WIT-970).
|
|
130
|
+
* Presentational: the consumer fills variables for THIS thread's recipient
|
|
131
|
+
* before passing it in, so `body` is composer-ready text. `tags` (e.g. "Reply")
|
|
132
|
+
* are shown as chips so reps can recognize the right template at a glance.
|
|
133
|
+
*/
|
|
134
|
+
export interface ConversationReplyTemplate {
|
|
135
|
+
id: string
|
|
136
|
+
name: string
|
|
137
|
+
/** Composer-ready reply body, already personalized by the consumer. */
|
|
138
|
+
body: string
|
|
139
|
+
/** Optional tags shown as chips in the picker for findability. */
|
|
140
|
+
tags?: string[]
|
|
141
|
+
}
|
|
142
|
+
|
|
119
143
|
export interface ConversationThread {
|
|
120
144
|
threadId: string
|
|
121
145
|
subject: string
|
|
@@ -141,6 +165,12 @@ export interface ConversationThread {
|
|
|
141
165
|
draft?: string
|
|
142
166
|
/** Signature text appended to replies (plain text). */
|
|
143
167
|
signature?: string
|
|
168
|
+
/**
|
|
169
|
+
* Reply templates offered in the composer's "Apply template" picker, already
|
|
170
|
+
* personalized for this thread's recipient (WIT-970). Omit/empty hides the
|
|
171
|
+
* picker.
|
|
172
|
+
*/
|
|
173
|
+
replyTemplates?: ConversationReplyTemplate[]
|
|
144
174
|
}
|
|
145
175
|
|
|
146
176
|
export interface ConversationReplyPayload {
|
|
@@ -178,6 +208,11 @@ export interface ConversationPanelProps {
|
|
|
178
208
|
*/
|
|
179
209
|
onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>
|
|
180
210
|
onOpenInGmail?: (threadId: string) => void
|
|
211
|
+
/**
|
|
212
|
+
* Fired when the rep applies a reply template from the composer picker
|
|
213
|
+
* (WIT-970) — for analytics / template-usage tracking. Optional.
|
|
214
|
+
*/
|
|
215
|
+
onApplyReplyTemplate?: (info: { threadId: string; templateId: string }) => void
|
|
181
216
|
/** Inline-open this thread initially (defaults to the first responded one). */
|
|
182
217
|
defaultOpenThreadId?: string
|
|
183
218
|
className?: string
|
|
@@ -535,6 +570,7 @@ function ReplyComposer({
|
|
|
535
570
|
onSend,
|
|
536
571
|
onDraft,
|
|
537
572
|
onPreviewReply,
|
|
573
|
+
onApplyTemplate,
|
|
538
574
|
draftDisabledReason,
|
|
539
575
|
}: {
|
|
540
576
|
thread: ConversationThread
|
|
@@ -545,10 +581,19 @@ function ReplyComposer({
|
|
|
545
581
|
onSend: (body: string, includeSignature: boolean) => void | Promise<void>
|
|
546
582
|
onDraft: (body: string, includeSignature: boolean) => void | Promise<void>
|
|
547
583
|
onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>
|
|
584
|
+
onApplyTemplate?: (templateId: string) => void
|
|
548
585
|
draftDisabledReason?: string | null
|
|
549
586
|
}) {
|
|
550
587
|
const [body, setBody] = React.useState(thread.draft ?? "")
|
|
588
|
+
const [appliedTemplate, setAppliedTemplate] = React.useState<string | null>(null)
|
|
551
589
|
const [sig, setSig] = React.useState(true)
|
|
590
|
+
const replyTemplates = thread.replyTemplates ?? []
|
|
591
|
+
|
|
592
|
+
const applyTemplate = (template: ConversationReplyTemplate) => {
|
|
593
|
+
setBody(template.body)
|
|
594
|
+
setAppliedTemplate(template.name)
|
|
595
|
+
onApplyTemplate?.(template.id)
|
|
596
|
+
}
|
|
552
597
|
const [preview, setPreview] = React.useState(false)
|
|
553
598
|
const [previewState, setPreviewState] = React.useState<PreviewState>(IDLE_PREVIEW)
|
|
554
599
|
const [sending, setSending] = React.useState(false)
|
|
@@ -686,6 +731,48 @@ function ReplyComposer({
|
|
|
686
731
|
</div>
|
|
687
732
|
) : null}
|
|
688
733
|
|
|
734
|
+
{replyTemplates.length > 0 ? (
|
|
735
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
736
|
+
<DropdownMenu>
|
|
737
|
+
<DropdownMenuTrigger asChild>
|
|
738
|
+
<Button type="button" variant="outline" size="sm" disabled={sending} data-slot="conv-reply-template-trigger">
|
|
739
|
+
<FileText size={14} /> Apply template
|
|
740
|
+
</Button>
|
|
741
|
+
</DropdownMenuTrigger>
|
|
742
|
+
<DropdownMenuContent align="start" className="max-w-xs">
|
|
743
|
+
<DropdownMenuLabel>Reply templates</DropdownMenuLabel>
|
|
744
|
+
<DropdownMenuSeparator />
|
|
745
|
+
{replyTemplates.map((template) => (
|
|
746
|
+
<DropdownMenuItem
|
|
747
|
+
key={template.id}
|
|
748
|
+
onSelect={() => applyTemplate(template)}
|
|
749
|
+
className="flex flex-col items-start gap-0.5"
|
|
750
|
+
>
|
|
751
|
+
<span className="text-[13px] font-medium">{template.name}</span>
|
|
752
|
+
{template.tags && template.tags.length ? (
|
|
753
|
+
<span className="flex flex-wrap gap-1">
|
|
754
|
+
{template.tags.map((tag) => (
|
|
755
|
+
<span
|
|
756
|
+
key={tag}
|
|
757
|
+
className="border-border bg-muted text-muted-foreground rounded border px-1 py-px text-[10px] leading-4"
|
|
758
|
+
>
|
|
759
|
+
{tag}
|
|
760
|
+
</span>
|
|
761
|
+
))}
|
|
762
|
+
</span>
|
|
763
|
+
) : null}
|
|
764
|
+
</DropdownMenuItem>
|
|
765
|
+
))}
|
|
766
|
+
</DropdownMenuContent>
|
|
767
|
+
</DropdownMenu>
|
|
768
|
+
{appliedTemplate ? (
|
|
769
|
+
<span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
|
|
770
|
+
<Check size={11} /> Applied “{appliedTemplate}” · edit before sending
|
|
771
|
+
</span>
|
|
772
|
+
) : null}
|
|
773
|
+
</div>
|
|
774
|
+
) : null}
|
|
775
|
+
|
|
689
776
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
690
777
|
<RichTextToolbar />
|
|
691
778
|
<label className="text-muted-foreground ml-auto inline-flex cursor-pointer items-center gap-1.5 text-[12px]">
|
|
@@ -797,6 +884,7 @@ function ThreadBody({
|
|
|
797
884
|
onCreateGmailDraft,
|
|
798
885
|
onPreviewReply,
|
|
799
886
|
onOpenInGmail,
|
|
887
|
+
onApplyReplyTemplate,
|
|
800
888
|
}: {
|
|
801
889
|
thread: ConversationThread
|
|
802
890
|
me?: ConvParticipant
|
|
@@ -805,6 +893,7 @@ function ThreadBody({
|
|
|
805
893
|
onCreateGmailDraft?: (p: ConversationReplyPayload) => void | Promise<void>
|
|
806
894
|
onPreviewReply?: (p: ConversationReplyPayload) => Promise<ConversationReplyPreview>
|
|
807
895
|
onOpenInGmail?: (threadId: string) => void
|
|
896
|
+
onApplyReplyTemplate?: ConversationPanelProps["onApplyReplyTemplate"]
|
|
808
897
|
}) {
|
|
809
898
|
const canReply = thread.canReply !== false
|
|
810
899
|
const replyDisabledReason = thread.replyDisabledReason?.trim() || "You are not a participant on this thread, so replying is disabled here."
|
|
@@ -885,6 +974,7 @@ function ThreadBody({
|
|
|
885
974
|
replyAll={replyAll}
|
|
886
975
|
tenantName={tenantName}
|
|
887
976
|
onPreviewReply={onPreviewReply}
|
|
977
|
+
onApplyTemplate={(templateId) => onApplyReplyTemplate?.({ threadId: thread.threadId, templateId })}
|
|
888
978
|
onClose={() => setMode("idle")}
|
|
889
979
|
onSend={async (body, includeSignature) => {
|
|
890
980
|
await onSendReply?.({ threadId: thread.threadId, body, includeSignature, replyAll })
|
|
@@ -940,11 +1030,12 @@ function ThreadRow({
|
|
|
940
1030
|
onCreateGmailDraft,
|
|
941
1031
|
onPreviewReply,
|
|
942
1032
|
onOpenInGmail,
|
|
1033
|
+
onApplyReplyTemplate,
|
|
943
1034
|
}: {
|
|
944
1035
|
thread: ConversationThread
|
|
945
1036
|
open: boolean
|
|
946
1037
|
onToggleOpen: () => void
|
|
947
|
-
} & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onPreviewReply" | "onOpenInGmail">) {
|
|
1038
|
+
} & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onPreviewReply" | "onOpenInGmail" | "onApplyReplyTemplate">) {
|
|
948
1039
|
const status = effectiveStatus(thread)
|
|
949
1040
|
const sortedMessages = React.useMemo(() => sortMessagesChronologically(thread.messages), [thread.messages])
|
|
950
1041
|
const last = sortedMessages[sortedMessages.length - 1]
|
|
@@ -997,6 +1088,7 @@ function ThreadRow({
|
|
|
997
1088
|
onCreateGmailDraft={onCreateGmailDraft}
|
|
998
1089
|
onPreviewReply={onPreviewReply}
|
|
999
1090
|
onOpenInGmail={onOpenInGmail}
|
|
1091
|
+
onApplyReplyTemplate={onApplyReplyTemplate}
|
|
1000
1092
|
/>
|
|
1001
1093
|
</div>
|
|
1002
1094
|
) : null}
|
|
@@ -1014,6 +1106,7 @@ function ConversationPanel({
|
|
|
1014
1106
|
onCreateGmailDraft,
|
|
1015
1107
|
onPreviewReply,
|
|
1016
1108
|
onOpenInGmail,
|
|
1109
|
+
onApplyReplyTemplate,
|
|
1017
1110
|
defaultOpenThreadId,
|
|
1018
1111
|
className,
|
|
1019
1112
|
}: ConversationPanelProps) {
|
|
@@ -1148,6 +1241,7 @@ function ConversationPanel({
|
|
|
1148
1241
|
onCreateGmailDraft={onCreateGmailDraft}
|
|
1149
1242
|
onPreviewReply={onPreviewReply}
|
|
1150
1243
|
onOpenInGmail={onOpenInGmail}
|
|
1244
|
+
onApplyReplyTemplate={onApplyReplyTemplate}
|
|
1151
1245
|
/>
|
|
1152
1246
|
))}
|
|
1153
1247
|
</div>
|
|
@@ -214,18 +214,25 @@ export function formatAddressList(input?: string | string[] | null): string {
|
|
|
214
214
|
.join(", ")
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
export function formatEmailTimestamp(
|
|
217
|
+
export function formatEmailTimestamp(
|
|
218
|
+
value?: string | Date | null,
|
|
219
|
+
opts?: { timeZone?: string },
|
|
220
|
+
): string | null {
|
|
218
221
|
if (!value) return null
|
|
219
222
|
const date = value instanceof Date ? value : new Date(value)
|
|
220
223
|
if (Number.isNaN(date.getTime())) return null
|
|
221
224
|
|
|
225
|
+
// Default = the runtime's local timezone (the viewer's, in the browser).
|
|
226
|
+
// Callers that render during SSR/hydration pass timeZone "UTC" for the
|
|
227
|
+
// first paint so server and client HTML match, then re-render local after
|
|
228
|
+
// mount (see useHydrated in timeline-activity).
|
|
222
229
|
return new Intl.DateTimeFormat("en-US", {
|
|
223
230
|
month: "short",
|
|
224
231
|
day: "numeric",
|
|
225
232
|
year: "numeric",
|
|
226
233
|
hour: "numeric",
|
|
227
234
|
minute: "2-digit",
|
|
228
|
-
timeZone:
|
|
235
|
+
...(opts?.timeZone ? { timeZone: opts.timeZone } : {}),
|
|
229
236
|
}).format(date)
|
|
230
237
|
}
|
|
231
238
|
|
|
@@ -302,6 +309,18 @@ function makeHtmlSegment(html: string): MessageSegment | null {
|
|
|
302
309
|
return { html, visibleText }
|
|
303
310
|
}
|
|
304
311
|
|
|
312
|
+
// Blank-line segments carry no visible text, so they never participate in
|
|
313
|
+
// footer-boundary detection — but their html must survive the rebuild, or a
|
|
314
|
+
// split body loses every intentional blank line between paragraphs (the
|
|
315
|
+
// Gmail wire format marks them as <div><br></div>).
|
|
316
|
+
function makeBlankLineSegment(html: string): MessageSegment {
|
|
317
|
+
return { html, visibleText: "" }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isBlankLineHtml(html: string): boolean {
|
|
321
|
+
return Boolean(html.trim()) && !htmlToVisibleText(html) && !/<(?:img|hr)\b/i.test(html)
|
|
322
|
+
}
|
|
323
|
+
|
|
305
324
|
function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSegment[] {
|
|
306
325
|
const containsBr = hasDirectBr(nodes)
|
|
307
326
|
const chunks: string[][] = [[]]
|
|
@@ -315,14 +334,22 @@ function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSeg
|
|
|
315
334
|
chunks[chunks.length - 1]?.push(serializeNode(node))
|
|
316
335
|
})
|
|
317
336
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
337
|
+
const rendered = chunks.map((chunk) => chunk.join(""))
|
|
338
|
+
const segments: MessageSegment[] = []
|
|
339
|
+
rendered.forEach((innerHtml, index) => {
|
|
340
|
+
const html = wrapper ? wrapHtmlLike(wrapper, innerHtml) : containsBr ? `<div>${innerHtml}</div>` : innerHtml
|
|
341
|
+
const segment = makeHtmlSegment(html)
|
|
342
|
+
if (segment) {
|
|
343
|
+
segments.push(segment)
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
// An interior empty chunk sits between two <br>s: an intentional blank
|
|
347
|
+
// line. Leading/trailing empties stay dropped, as before.
|
|
348
|
+
if (containsBr && index > 0 && index < rendered.length - 1) {
|
|
349
|
+
segments.push(makeBlankLineSegment(wrapper ? wrapHtmlLike(wrapper, "<br>") : "<div><br></div>"))
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
return segments
|
|
326
353
|
}
|
|
327
354
|
|
|
328
355
|
function splitElementSegment(element: Element): MessageSegment[] {
|
|
@@ -333,6 +360,12 @@ function splitElementSegment(element: Element): MessageSegment[] {
|
|
|
333
360
|
return segment ? [segment] : []
|
|
334
361
|
}
|
|
335
362
|
|
|
363
|
+
// A block with no visible content (e.g. <div><br></div>, <p></p>) is a
|
|
364
|
+
// blank-line marker — keep it whole instead of splitting/dropping it.
|
|
365
|
+
if (isBlankLineHtml(element.outerHTML)) {
|
|
366
|
+
return [makeBlankLineSegment(element.outerHTML)]
|
|
367
|
+
}
|
|
368
|
+
|
|
336
369
|
if (tagName === "div" && hasDirectBlockChild(element)) {
|
|
337
370
|
const childSegments = splitHtmlNodes(Array.from(element.childNodes))
|
|
338
371
|
return childSegments.length ? childSegments : ([makeHtmlSegment(element.outerHTML)].filter(Boolean) as MessageSegment[])
|
|
@@ -399,9 +432,15 @@ function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
|
|
|
399
432
|
const pushInline = (inlineHtml: string) => {
|
|
400
433
|
const chunks = inlineHtml.split(BR_TAG_RE)
|
|
401
434
|
const hadBr = chunks.length > 1
|
|
402
|
-
chunks.forEach((chunk) => {
|
|
435
|
+
chunks.forEach((chunk, index) => {
|
|
403
436
|
const segment = makeHtmlSegment(hadBr ? `<div>${chunk}</div>` : chunk)
|
|
404
|
-
if (segment)
|
|
437
|
+
if (segment) {
|
|
438
|
+
segments.push(segment)
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
if (hadBr && index > 0 && index < chunks.length - 1) {
|
|
442
|
+
segments.push(makeBlankLineSegment("<div><br /></div>"))
|
|
443
|
+
}
|
|
405
444
|
})
|
|
406
445
|
}
|
|
407
446
|
|
|
@@ -422,13 +461,22 @@ function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
|
|
|
422
461
|
const segmentEnd = findMatchingCloseTag(html, tagName, openTagEnd)
|
|
423
462
|
const blockHtml = html.slice(tagStart, segmentEnd)
|
|
424
463
|
|
|
425
|
-
if (
|
|
464
|
+
if (isBlankLineHtml(blockHtml)) {
|
|
465
|
+
segments.push(makeBlankLineSegment(blockHtml))
|
|
466
|
+
} else if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
|
|
426
467
|
const openTag = rawOpen
|
|
427
468
|
const closeTag = `</${tagName}>`
|
|
428
469
|
const inner = blockHtml.replace(new RegExp(`^${rawOpen.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"), "").replace(new RegExp(`${closeTag}$`, "i"), "")
|
|
429
|
-
inner.split(BR_TAG_RE)
|
|
470
|
+
const innerChunks = inner.split(BR_TAG_RE)
|
|
471
|
+
innerChunks.forEach((chunk, index) => {
|
|
430
472
|
const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`)
|
|
431
|
-
if (segment)
|
|
473
|
+
if (segment) {
|
|
474
|
+
segments.push(segment)
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
if (index > 0 && index < innerChunks.length - 1) {
|
|
478
|
+
segments.push(makeBlankLineSegment(`${openTag}<br />${closeTag}`))
|
|
479
|
+
}
|
|
432
480
|
})
|
|
433
481
|
} else {
|
|
434
482
|
const segment = makeHtmlSegment(blockHtml)
|
|
@@ -392,9 +392,29 @@ function cleanGongText(value?: string | null): string {
|
|
|
392
392
|
return decodeEmailDisplayText(value).trim()
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
-
|
|
395
|
+
/**
|
|
396
|
+
* True once the component has mounted on the client. Timestamps render in
|
|
397
|
+
* UTC for SSR + the hydration pass (server and client HTML must match — the
|
|
398
|
+
* server has no idea what timezone the viewer is in), then flip to the
|
|
399
|
+
* viewer's local timezone immediately after mount.
|
|
400
|
+
*/
|
|
401
|
+
function useHydrated(): boolean {
|
|
402
|
+
const [hydrated, setHydrated] = React.useState(false)
|
|
403
|
+
React.useEffect(() => {
|
|
404
|
+
setHydrated(true)
|
|
405
|
+
}, [])
|
|
406
|
+
return hydrated
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
type TimestampDisplayOptions = { utcTimestamps?: boolean }
|
|
410
|
+
|
|
411
|
+
function timestampTimeZone(opts?: TimestampDisplayOptions): { timeZone?: string } {
|
|
412
|
+
return opts?.utcTimestamps ? { timeZone: "UTC" } : {}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getTimelineGongCallDisplay(gongCall: TimelineGongCall, opts?: TimestampDisplayOptions) {
|
|
396
416
|
const title = cleanGongText(gongCall.title)
|
|
397
|
-
const startTime = formatEmailTimestamp(gongCall.startTime) ?? ""
|
|
417
|
+
const startTime = formatEmailTimestamp(gongCall.startTime, timestampTimeZone(opts)) ?? ""
|
|
398
418
|
const duration = formatCallDuration(gongCall.durationSeconds)
|
|
399
419
|
const direction = cleanGongText(gongCall.direction)
|
|
400
420
|
const outcome = cleanGongText(gongCall.outcome)
|
|
@@ -407,12 +427,12 @@ function getTimelineGongCallDisplay(gongCall: TimelineGongCall) {
|
|
|
407
427
|
return { title, startTime, duration, direction, outcome, brief, keyPoints, nextSteps, url, snippet }
|
|
408
428
|
}
|
|
409
429
|
|
|
410
|
-
function getTimelineEmailDisplay(email: TimelineEmail) {
|
|
430
|
+
function getTimelineEmailDisplay(email: TimelineEmail, opts?: TimestampDisplayOptions) {
|
|
411
431
|
const sender = normalizeEmailSender({ name: email.from, email: email.fromEmail })
|
|
412
432
|
const to = formatAddressList(email.to) || decodeEmailDisplayText(email.to ?? "")
|
|
413
433
|
const cc = formatAddressList(email.cc) || decodeEmailDisplayText(email.cc ?? "")
|
|
414
434
|
const bcc = formatAddressList(email.bcc) || decodeEmailDisplayText(email.bcc ?? "")
|
|
415
|
-
const date = formatEmailTimestamp(email.date) ?? decodeEmailDisplayText(email.date ?? "")
|
|
435
|
+
const date = formatEmailTimestamp(email.date, timestampTimeZone(opts)) ?? decodeEmailDisplayText(email.date ?? "")
|
|
416
436
|
const subject = email.subject ? decodeEmailDisplayText(email.subject) : ""
|
|
417
437
|
const bodyText = reactNodeToDisplayText(email.body)
|
|
418
438
|
const snippet = emailBodySnippet({ bodyHtml: email.bodyHtml, body: bodyText }, 140)
|
|
@@ -430,7 +450,8 @@ function EmailMetadata({
|
|
|
430
450
|
showAllRecipients: boolean
|
|
431
451
|
setShowAllRecipients: React.Dispatch<React.SetStateAction<boolean>>
|
|
432
452
|
}) {
|
|
433
|
-
const
|
|
453
|
+
const hydrated = useHydrated()
|
|
454
|
+
const display = getTimelineEmailDisplay(email, { utcTimestamps: !hydrated })
|
|
434
455
|
const hasExpandableRecipients = Boolean(display.cc || display.bcc)
|
|
435
456
|
|
|
436
457
|
return (
|
|
@@ -847,7 +868,8 @@ function GongCallCard({
|
|
|
847
868
|
classes: TimelineVariantClasses
|
|
848
869
|
}) {
|
|
849
870
|
const gongCall = event.gongCall as TimelineGongCall
|
|
850
|
-
const
|
|
871
|
+
const hydrated = useHydrated()
|
|
872
|
+
const display = getTimelineGongCallDisplay(gongCall, { utcTimestamps: !hydrated })
|
|
851
873
|
|
|
852
874
|
if (variant === "default") {
|
|
853
875
|
return (
|