@handled-ai/design-system 0.20.12 → 0.20.14

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.
@@ -57,14 +57,77 @@ describe("ConversationPanel", () => {
57
57
  expect(container.querySelector('[data-slot="conversation-panel"]')).toBeNull();
58
58
  });
59
59
 
60
- it("shows the 'Email response detected' badge when a thread has responded", () => {
61
- render(<ConversationPanel threads={[thread()]} me={me} />);
60
+ it("shows the amber response-detected treatment when a thread has responded", () => {
61
+ const { container } = render(<ConversationPanel threads={[thread({ paused: { playbook: "Mercury Renewal Save" } })]} me={me} />);
62
+
63
+ const panel = container.querySelector('[data-slot="conversation-panel"]')!;
64
+ const header = container.querySelector('[data-slot="conversation-panel-header"]')!;
65
+ const row = container.querySelector('[data-slot="conv-thread"]')!;
66
+
67
+ expect(panel.getAttribute("data-state")).toBe("responded");
68
+ expect(panel.className).toContain("border-status-warning-border");
69
+ expect(header.className).toContain("bg-status-warning-bg");
70
+ expect(row.getAttribute("data-status")).toBe("responded");
71
+ expect(row.className).toContain("border-l-status-warning-border");
62
72
  expect(screen.getByText("Email response detected")).toBeDefined();
73
+ expect(screen.getByText("1 reply needs your response")).toBeDefined();
74
+ expect(screen.getByText("NEW REPLY")).toBeDefined();
75
+ expect(screen.getAllByText(/Playbook stopped/i).length).toBeGreaterThanOrEqual(1);
63
76
  });
64
77
 
65
- it("shows the 'Awaiting response' badge when nothing has responded", () => {
66
- render(<ConversationPanel threads={[thread({ status: "awaiting" })]} me={me} />);
67
- expect(screen.getByText("Awaiting response")).toBeDefined();
78
+ it("shows the blue sent/awaiting treatment, receipt chips, and hub Gmail action", () => {
79
+ const { container } = render(
80
+ <ConversationPanel
81
+ threads={[
82
+ thread({
83
+ status: "awaiting",
84
+ openInGmailUrl: "https://mail.google.com/mail/u/0/#sent/thread-mercury",
85
+ messages: [
86
+ {
87
+ id: "sent-1",
88
+ direction: "outbound",
89
+ from: me,
90
+ to: priya,
91
+ date: "Jun 8, 2026, 9:12 AM",
92
+ receipt: { kind: "sent", label: "Sent" },
93
+ body: "Hi Priya — looping back on the Mercury prior authorization workflow.",
94
+ },
95
+ {
96
+ id: "opened-1",
97
+ direction: "outbound",
98
+ from: me,
99
+ to: priya,
100
+ date: "Jun 8, 2026, 9:24 AM",
101
+ receipt: { kind: "opened", label: "Opened" },
102
+ body: "Sharing the implementation notes again so they stay on this thread.",
103
+ },
104
+ ],
105
+ }),
106
+ ]}
107
+ me={me}
108
+ />,
109
+ );
110
+
111
+ const panel = container.querySelector('[data-slot="conversation-panel"]')!;
112
+ const header = container.querySelector('[data-slot="conversation-panel-header"]')!;
113
+ const row = container.querySelector('[data-slot="conv-thread"]')!;
114
+
115
+ expect(panel.getAttribute("data-state")).toBe("awaiting");
116
+ expect(panel.className).toContain("border-status-info-border");
117
+ expect(header.className).toContain("bg-status-info-bg");
118
+ expect(row.getAttribute("data-status")).toBe("awaiting");
119
+ expect(row.className).toContain("border-l-status-info-border");
120
+ expect(screen.getByText("Email sent · awaiting reply")).toBeDefined();
121
+ expect(screen.getByText("Awaiting a response from Priya")).toBeDefined();
122
+ expect(screen.getByText("SENT")).toBeDefined();
123
+ expect(screen.getByText("Opened")).toBeDefined();
124
+
125
+ fireEvent.click(screen.getByText(/Hi Priya — looping back/i));
126
+ expect(screen.getByText("Sent")).toBeDefined();
127
+
128
+ const gmailLinks = screen.getAllByRole("link", { name: /Open in Gmail/ });
129
+ expect(gmailLinks.length).toBeGreaterThanOrEqual(1);
130
+ expect(gmailLinks[0].getAttribute("href")).toBe("https://mail.google.com/mail/u/0/#sent/thread-mercury");
68
131
  });
69
132
 
70
133
 
@@ -93,12 +156,130 @@ describe("ConversationPanel", () => {
93
156
 
94
157
  expect(screen.getByText("Draft ready")).toBeDefined();
95
158
  expect(screen.getByText("Draft ready on 1 thread")).toBeDefined();
96
- expect(screen.queryByText("Awaiting response")).toBeNull();
159
+ expect(screen.queryByText("Email sent · awaiting reply")).toBeNull();
97
160
  expect(screen.getAllByText("Draft").length).toBeGreaterThan(0);
98
161
  expect(screen.getByText("Draft copy")).toBeDefined();
99
162
  });
100
163
 
101
164
 
165
+
166
+ it("prioritizes responded threads for the open row and header Gmail action", () => {
167
+ render(
168
+ <ConversationPanel
169
+ threads={[
170
+ thread({
171
+ threadId: "awaiting-thread",
172
+ status: "awaiting",
173
+ openInGmailUrl: "https://mail.google.com/mail/u/0/#sent/awaiting-thread",
174
+ messages: [
175
+ {
176
+ id: "sent-1",
177
+ direction: "outbound",
178
+ from: me,
179
+ to: priya,
180
+ date: "Today",
181
+ receipt: { kind: "sent", label: "Sent" },
182
+ body: "Awaiting response",
183
+ },
184
+ ],
185
+ }),
186
+ thread({
187
+ threadId: "responded-without-gmail-thread",
188
+ status: "responded",
189
+ openInGmailUrl: undefined,
190
+ messages: [
191
+ {
192
+ id: "reply-1",
193
+ direction: "inbound",
194
+ from: priya,
195
+ to: me,
196
+ date: "Today",
197
+ receipt: { kind: "new", label: "Reply" },
198
+ body: "Can you send the updated numbers?",
199
+ },
200
+ ],
201
+ }),
202
+ thread({
203
+ threadId: "responded-thread",
204
+ status: "responded",
205
+ openInGmailUrl: "https://mail.google.com/mail/u/0/#inbox/responded-thread",
206
+ messages: [
207
+ {
208
+ id: "reply-2",
209
+ direction: "inbound",
210
+ from: priya,
211
+ to: me,
212
+ date: "Today",
213
+ receipt: { kind: "new", label: "Reply" },
214
+ body: "Following up with the numbers.",
215
+ },
216
+ ],
217
+ }),
218
+ ]}
219
+ me={me}
220
+ />,
221
+ );
222
+
223
+ expect(screen.getByText("Email response detected")).toBeDefined();
224
+ expect(screen.getByText("2 replies need your response")).toBeDefined();
225
+
226
+ const rows = document.querySelectorAll('[data-slot="conv-thread"]');
227
+ expect(rows[0].getAttribute("data-open")).toBeNull();
228
+ expect(rows[1].getAttribute("data-open")).toBe("true");
229
+ expect(rows[2].getAttribute("data-open")).toBeNull();
230
+
231
+ const gmailLinks = screen.getAllByRole("link", { name: /Open in Gmail/ });
232
+ expect(gmailLinks[0].getAttribute("href")).toBe("https://mail.google.com/mail/u/0/#inbox/responded-thread");
233
+ });
234
+
235
+ it("keeps draft-ready badge styling when draft and awaiting threads are mixed", () => {
236
+ const { container } = render(
237
+ <ConversationPanel
238
+ threads={[
239
+ thread({
240
+ threadId: "awaiting-thread",
241
+ status: "awaiting",
242
+ messages: [
243
+ {
244
+ id: "sent-1",
245
+ direction: "outbound",
246
+ from: me,
247
+ to: priya,
248
+ date: "Today",
249
+ receipt: { kind: "sent", label: "Sent" },
250
+ body: "Awaiting response",
251
+ },
252
+ ],
253
+ }),
254
+ thread({
255
+ threadId: "draft-thread",
256
+ status: "draft",
257
+ messages: [
258
+ {
259
+ id: "draft-1",
260
+ direction: "outbound",
261
+ from: me,
262
+ to: priya,
263
+ date: "Today",
264
+ receipt: { kind: "draft", label: "Draft" },
265
+ body: "Draft copy",
266
+ },
267
+ ],
268
+ }),
269
+ ]}
270
+ me={me}
271
+ />,
272
+ );
273
+
274
+ expect(screen.getByText("Draft ready")).toBeDefined();
275
+ expect(screen.getByText("Draft ready on 1 thread")).toBeDefined();
276
+
277
+ const badge = container.querySelector('[data-slot="conversation-badge"]')!;
278
+ expect(badge.className).toContain("bg-status-pending-bg");
279
+ expect(badge.className).toContain("text-status-pending-fg");
280
+ expect(badge.className).not.toContain("bg-status-info-bg");
281
+ });
282
+
102
283
  it("uses the custom read-only reason and disables Open in Gmail when access is unavailable", () => {
103
284
  const onOpenInGmail = vi.fn();
104
285
 
@@ -140,10 +321,11 @@ describe("ConversationPanel", () => {
140
321
  />,
141
322
  );
142
323
 
143
- const link = screen.getByRole("link", { name: /Open in Gmail/ });
144
- expect(link.getAttribute("href")).toBe("https://mail.google.com/mail/?authuser=dana%40handled.ai#drafts/msg-1");
145
- expect(link.getAttribute("target")).toBe("_blank");
146
- expect(link.getAttribute("rel")).toBe("noopener noreferrer");
324
+ const links = screen.getAllByRole("link", { name: /Open in Gmail/ });
325
+ expect(links.length).toBeGreaterThanOrEqual(1);
326
+ expect(links[0].getAttribute("href")).toBe("https://mail.google.com/mail/?authuser=dana%40handled.ai#drafts/msg-1");
327
+ expect(links[0].getAttribute("target")).toBe("_blank");
328
+ expect(links[0].getAttribute("rel")).toBe("noopener noreferrer");
147
329
  });
148
330
 
149
331
  it("auto-opens the first responded thread and renders its newest message as HTML", () => {
@@ -512,7 +694,7 @@ describe("ConversationPanel", () => {
512
694
  tenantName="Mercury OS"
513
695
  />,
514
696
  );
515
- expect(screen.getByText(/Follow-up actions stopped/i)).toBeDefined();
697
+ expect(screen.getAllByText(/Playbook stopped/i).length).toBeGreaterThanOrEqual(1);
516
698
  });
517
699
 
518
700
  it("opens the reply composer when Reply is clicked", () => {
@@ -80,4 +80,36 @@ describe("EmailBody", () => {
80
80
  expect(container.textContent).toContain("Line one & two\nLine 'three'")
81
81
  expectNoVisibleEscapeArtifacts(container.textContent ?? "")
82
82
  })
83
+
84
+ it("linkifies URLs and email addresses in plain text bodies after splitting details", () => {
85
+ const { container } = render(
86
+ <EmailBody
87
+ text={[
88
+ "Hi Dana,",
89
+ "",
90
+ "Please review https://example.com/report?case=123, then email ops@example.com.",
91
+ "",
92
+ "-- ",
93
+ "Jane Doe",
94
+ "https://example.com/signature",
95
+ ].join("\n")}
96
+ collapseDetails
97
+ />,
98
+ )
99
+
100
+ const body = container.querySelector('[data-slot="email-body-content"]')
101
+ const details = container.querySelector('[data-slot="email-body-details"]')
102
+
103
+ expect(body?.textContent).toContain("Please review https://example.com/report?case=123, then email ops@example.com.")
104
+ expect(body?.querySelector('a[href="https://example.com/report?case=123"]')?.textContent).toBe("https://example.com/report?case=123")
105
+ expect(body?.querySelector('a[href="mailto:ops@example.com"]')?.textContent).toBe("ops@example.com")
106
+ expect(body?.textContent).toContain("https://example.com/report?case=123,")
107
+ expect(details).toBeNull()
108
+
109
+ fireEvent.click(screen.getByRole("button", { name: "•••" }))
110
+
111
+ const expandedDetails = container.querySelector('[data-slot="email-body-details"]')
112
+ expect(expandedDetails?.textContent).toContain("Jane Doe")
113
+ expect(expandedDetails?.querySelector('a[href="https://example.com/signature"]')?.textContent).toBe("https://example.com/signature")
114
+ })
83
115
  })
@@ -298,19 +298,34 @@ function PersonAvatar({ person, size = "sm" }: { person: ConvParticipant; size?:
298
298
  }
299
299
 
300
300
  const STATUS_PILL: Record<ConvStatus, { label: string; cls: string }> = {
301
- responded: { label: "New reply", cls: "bg-status-active-bg text-status-active-fg border-status-active-border" },
301
+ responded: { label: "NEW REPLY", cls: "bg-status-warning-bg text-status-warning-fg border-status-warning-border" },
302
302
  draft: { label: "Draft", cls: "bg-background text-foreground/80 border-border" },
303
- awaiting: { label: "Awaiting", cls: "bg-status-pending-bg text-status-pending-fg border-status-pending-border" },
303
+ awaiting: { label: "SENT", cls: "bg-status-info-bg text-status-info-fg border-status-info-border" },
304
304
  viewing: { label: "Viewing", cls: "bg-muted text-muted-foreground border-border" },
305
305
  }
306
306
 
307
307
  const STATUS_DOT: Record<ConvStatus, string> = {
308
- responded: "bg-status-active-fg",
308
+ responded: "bg-status-warning-fg",
309
309
  draft: "bg-status-pending-fg",
310
- awaiting: "bg-status-pending-fg",
310
+ awaiting: "bg-status-info-fg",
311
311
  viewing: "bg-muted-foreground/50",
312
312
  }
313
313
 
314
+ const THREAD_ROW_ACCENT: Record<ConvStatus, string> = {
315
+ responded: "border-l-4 border-l-status-warning-border bg-status-warning-bg/25",
316
+ awaiting: "border-l-4 border-l-status-info-border bg-status-info-bg/25",
317
+ draft: "border-l-4 border-l-status-pending-border bg-status-pending-bg/20",
318
+ viewing: "",
319
+ }
320
+
321
+ const RECEIPT_CHIP: Record<NonNullable<ConvMessage["receipt"]>["kind"], string> = {
322
+ new: "border-status-warning-border bg-status-warning-bg text-status-warning-fg",
323
+ read: "border-status-info-border bg-status-info-bg text-status-info-fg",
324
+ opened: "border-status-info-border bg-status-info-bg text-status-info-fg",
325
+ sent: "border-status-info-border bg-status-info-bg text-status-info-fg",
326
+ draft: "border-status-pending-border bg-status-pending-bg text-status-pending-fg",
327
+ }
328
+
314
329
  function effectiveStatus(t: ConversationThread): ConvStatus {
315
330
  return t.canReply === false ? "viewing" : t.status
316
331
  }
@@ -432,10 +447,10 @@ function MessageView({
432
447
  </span>
433
448
  <span className="flex shrink-0 items-center gap-2">
434
449
  {message.receipt ? (
435
- <span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
450
+ <span className={cn("inline-flex items-center gap-1 rounded-md border px-1.5 py-px text-[10px] font-semibold leading-4", RECEIPT_CHIP[message.receipt.kind])}>
436
451
  {message.receipt.kind === "new" ? (
437
452
  <CornerUpLeft size={11} />
438
- ) : message.receipt.kind === "read" ? (
453
+ ) : message.receipt.kind === "read" || message.receipt.kind === "sent" ? (
439
454
  <CheckCheck size={11} />
440
455
  ) : message.receipt.kind === "draft" ? (
441
456
  <FilePenLine size={11} />
@@ -811,10 +826,10 @@ function ThreadBody({
811
826
  return (
812
827
  <div data-slot="conv-thread-body" className="space-y-2">
813
828
  {canReply && thread.paused ? (
814
- <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]">
829
+ <div className="border-status-warning-border bg-status-warning-bg text-status-warning-fg flex items-start gap-2 rounded-md border border-l-4 p-2.5 text-[12px]">
815
830
  <Pause size={13} className="mt-0.5 shrink-0" />
816
831
  <span>
817
- <b>Follow-up actions stopped.</b> Your {thread.paused.playbook} next steps won’t send
832
+ <b>Playbook stopped.</b> Follow-up actions for {thread.paused.playbook} won’t send
818
833
  automatically while this conversation is live. Continue it in {tenantName ?? "the app"} or Gmail.
819
834
  </span>
820
835
  </div>
@@ -930,7 +945,12 @@ function ThreadRow({
930
945
  const pill = STATUS_PILL[status]
931
946
 
932
947
  return (
933
- <div data-slot="conv-thread" data-open={open ? "true" : undefined} className="border-border border-b last:border-b-0">
948
+ <div
949
+ data-slot="conv-thread"
950
+ data-status={status}
951
+ data-open={open ? "true" : undefined}
952
+ className={cn("border-border border-b last:border-b-0", THREAD_ROW_ACCENT[status])}
953
+ >
934
954
  <button
935
955
  type="button"
936
956
  onClick={onToggleOpen}
@@ -991,12 +1011,21 @@ function ConversationPanel({
991
1011
  const draft = threads.filter((t) => effectiveStatus(t) === "draft").length
992
1012
  const awaiting = threads.filter((t) => effectiveStatus(t) === "awaiting").length
993
1013
  const anyPaused = threads.some((t) => t.paused)
1014
+ const prioritizedThread =
1015
+ threads.find((t) => t.status === "responded" && t.canReply !== false) ??
1016
+ threads.find((t) => effectiveStatus(t) === "draft") ??
1017
+ threads.find((t) => effectiveStatus(t) === "awaiting")
1018
+ const hubGmailThread =
1019
+ threads.find((t) => t.status === "responded" && t.canReply !== false && canOpenInGmail(t, onOpenInGmail)) ??
1020
+ threads.find((t) => effectiveStatus(t) === "draft" && canOpenInGmail(t, onOpenInGmail)) ??
1021
+ threads.find((t) => effectiveStatus(t) === "awaiting" && canOpenInGmail(t, onOpenInGmail)) ??
1022
+ threads.find((t) => canOpenInGmail(t, onOpenInGmail))
1023
+ const firstAwaiting = threads.find((t) => effectiveStatus(t) === "awaiting")
994
1024
 
995
1025
  const [hubOpen, setHubOpen] = React.useState(true)
996
1026
  const [openId, setOpenId] = React.useState<string | null>(() => {
997
1027
  if (defaultOpenThreadId) return defaultOpenThreadId
998
- const firstActionable = threads.find((t) => ["responded", "draft"].includes(t.status) && t.canReply !== false)
999
- return firstActionable ? firstActionable.threadId : null
1028
+ return prioritizedThread ? prioritizedThread.threadId : null
1000
1029
  })
1001
1030
 
1002
1031
  if (!threads.length) return null
@@ -1004,11 +1033,11 @@ function ConversationPanel({
1004
1033
  // Header badge state: a responded reply leads, then drafts to finish, then sent mail awaiting a reply.
1005
1034
  const badge =
1006
1035
  responded > 0
1007
- ? { label: "Email response detected", dot: "bg-status-active-fg", ring: "bg-status-active-fg/30" }
1036
+ ? { label: "Email response detected", dot: "bg-status-warning-fg", ring: "bg-status-warning-fg/30" }
1008
1037
  : draft > 0
1009
1038
  ? { label: "Draft ready", dot: "bg-status-pending-fg", ring: "bg-status-pending-fg/30" }
1010
1039
  : awaiting > 0
1011
- ? { label: "Awaiting response", dot: "bg-status-pending-fg", ring: "bg-status-pending-fg/30" }
1040
+ ? { label: "Email sent · awaiting reply", dot: "bg-status-info-fg", ring: "bg-status-info-fg/30" }
1012
1041
  : { label: "Conversations", dot: "bg-muted-foreground/50", ring: "bg-muted-foreground/20" }
1013
1042
 
1014
1043
  const headTitle =
@@ -1017,30 +1046,56 @@ function ConversationPanel({
1017
1046
  : draft > 0
1018
1047
  ? `Draft ready on ${draft} ${draft === 1 ? "thread" : "threads"}`
1019
1048
  : awaiting > 0
1020
- ? `Awaiting response on ${awaiting} ${awaiting === 1 ? "thread" : "threads"}`
1049
+ ? awaiting === 1 && firstAwaiting
1050
+ ? `Awaiting a response from ${firstName(displayParticipant(firstAwaiting.contact).name)}`
1051
+ : `Awaiting responses on ${awaiting} threads`
1021
1052
  : `${threads.length} email ${threads.length === 1 ? "thread" : "threads"}`
1022
1053
 
1054
+ const panelState = responded > 0 ? "responded" : draft > 0 ? "draft" : awaiting > 0 ? "awaiting" : "viewing"
1055
+
1023
1056
  return (
1024
1057
  <section
1025
1058
  data-slot="conversation-panel"
1026
1059
  data-responded={responded > 0 ? "true" : undefined}
1027
- className={cn("border-border bg-background overflow-hidden rounded-xl border", className)}
1060
+ data-state={panelState}
1061
+ className={cn(
1062
+ "bg-background overflow-hidden rounded-xl border",
1063
+ panelState === "responded"
1064
+ ? "border-status-warning-border"
1065
+ : panelState === "awaiting"
1066
+ ? "border-status-info-border"
1067
+ : "border-border",
1068
+ className,
1069
+ )}
1028
1070
  >
1029
- <button
1030
- type="button"
1031
- onClick={() => setHubOpen((v) => !v)}
1032
- aria-expanded={hubOpen}
1033
- className="flex w-full items-center gap-3 px-3 py-2.5 text-left"
1071
+ <div
1072
+ data-slot="conversation-panel-header"
1073
+ className={cn(
1074
+ "flex w-full items-center gap-2 px-3 py-2.5",
1075
+ panelState === "responded"
1076
+ ? "bg-status-warning-bg/45"
1077
+ : panelState === "awaiting"
1078
+ ? "bg-status-info-bg/55"
1079
+ : "bg-background",
1080
+ )}
1034
1081
  >
1082
+ <button
1083
+ type="button"
1084
+ onClick={() => setHubOpen((v) => !v)}
1085
+ aria-expanded={hubOpen}
1086
+ className="flex min-w-0 flex-1 items-center gap-3 text-left"
1087
+ >
1035
1088
  <span
1036
1089
  data-slot="conversation-badge"
1037
1090
  className={cn(
1038
1091
  "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold",
1039
1092
  responded > 0
1040
- ? "bg-status-active-bg text-status-active-fg"
1041
- : draft > 0 || awaiting > 0
1093
+ ? "bg-status-warning-bg text-status-warning-fg"
1094
+ : draft > 0
1042
1095
  ? "bg-status-pending-bg text-status-pending-fg"
1043
- : "bg-muted text-muted-foreground"
1096
+ : awaiting > 0
1097
+ ? "bg-status-info-bg text-status-info-fg"
1098
+ : "bg-muted text-muted-foreground"
1044
1099
  )}
1045
1100
  >
1046
1101
  <span className="relative inline-flex size-2">
@@ -1056,12 +1111,18 @@ function ConversationPanel({
1056
1111
  {anyPaused ? <> · <b>playbook stopped</b></> : null}
1057
1112
  </span>
1058
1113
  </span>
1059
- {hubOpen ? (
1060
- <ChevronUp size={16} className="text-muted-foreground shrink-0" />
1061
- ) : (
1062
- <ChevronDown size={16} className="text-muted-foreground shrink-0" />
1063
- )}
1064
- </button>
1114
+ {hubOpen ? (
1115
+ <ChevronUp size={16} className="text-muted-foreground shrink-0" />
1116
+ ) : (
1117
+ <ChevronDown size={16} className="text-muted-foreground shrink-0" />
1118
+ )}
1119
+ </button>
1120
+ {hubGmailThread ? (
1121
+ <div className="shrink-0" onClick={(event) => event.stopPropagation()}>
1122
+ <OpenInGmailButton thread={hubGmailThread} onOpenInGmail={onOpenInGmail} />
1123
+ </div>
1124
+ ) : null}
1125
+ </div>
1065
1126
 
1066
1127
  {hubOpen ? (
1067
1128
  <div className="border-border border-t">
@@ -33,10 +33,55 @@ const PROSE = cn(
33
33
  "[&_sup]:align-super [&_sup]:text-[0.75em] [&_sub]:align-sub [&_sub]:text-[0.75em]",
34
34
  )
35
35
 
36
+ const PLAIN_TEXT_LINK_RE = /https?:\/\/[^\s<>"']+|www\.[^\s<>"']+|[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
37
+ const TRAILING_LINK_PUNCTUATION_RE = /[),.;:!?]+$/
38
+
39
+ function plainTextHref(value: string): string | null {
40
+ if (/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
41
+ return `mailto:${value}`
42
+ }
43
+ const href = value.toLowerCase().startsWith("www.") ? `https://${value}` : value
44
+ try {
45
+ const url = new URL(href)
46
+ return url.protocol === "http:" || url.protocol === "https:" ? href : null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ function linkifyPlainText(text: string): React.ReactNode[] {
53
+ const nodes: React.ReactNode[] = []
54
+ let cursor = 0
55
+
56
+ for (const match of text.matchAll(PLAIN_TEXT_LINK_RE)) {
57
+ const value = match[0]
58
+ const index = match.index ?? 0
59
+ if (index > cursor) nodes.push(text.slice(cursor, index))
60
+
61
+ const trailing = value.match(TRAILING_LINK_PUNCTUATION_RE)?.[0] ?? ""
62
+ const linkText = trailing ? value.slice(0, -trailing.length) : value
63
+ const href = plainTextHref(linkText)
64
+ if (href) {
65
+ nodes.push(
66
+ <a key={`${index}-${linkText}`} href={href} target="_blank" rel="noreferrer noopener">
67
+ {linkText}
68
+ </a>,
69
+ )
70
+ } else {
71
+ nodes.push(linkText)
72
+ }
73
+ if (trailing) nodes.push(trailing)
74
+ cursor = index + value.length
75
+ }
76
+
77
+ if (cursor < text.length) nodes.push(text.slice(cursor))
78
+ return nodes
79
+ }
80
+
36
81
  function PlainTextBlock({ text, className, slot }: { text: string; className?: string; slot: string }) {
37
82
  return (
38
83
  <div data-slot={slot} className={cn(PROSE, "whitespace-pre-line", className)}>
39
- {decodeEmailDisplayText(text)}
84
+ {linkifyPlainText(decodeEmailDisplayText(text))}
40
85
  </div>
41
86
  )
42
87
  }