@adatechnology/conversations-ui 0.2.1 → 0.3.0

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.
@@ -16,8 +16,8 @@ import {
16
16
  mediaTypeOf,
17
17
  simulatorPanelLabelsOf,
18
18
  toConversationSimulatorClient
19
- } from "../chunk-NHQDQJL2.js";
20
- import "../chunk-WCBDXZ3X.js";
19
+ } from "../chunk-L4OE4ORQ.js";
20
+ import "../chunk-PJNR6UMN.js";
21
21
 
22
22
  // src/preview/previewStore.ts
23
23
  var GLOBAL_CHANNEL = "global";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -60,33 +60,31 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
60
60
  mostra os asteriscos literais, e formatá-los aqui deixaria o simulador mais bonito que o
61
61
  aparelho, que é o tipo de divergência que só aparece com o cliente na frente. */}
62
62
  {payload.header?.text ? (
63
- <p className={cn('text-sm font-semibold text-gray-900 dark:text-gray-100', FORMATTING_CLASSES)}>
63
+ <div className={cn('text-sm font-semibold text-gray-900 dark:text-gray-100', FORMATTING_CLASSES)}>
64
64
  {parseWhatsAppFormatting(payload.header.text)}
65
- </p>
65
+ </div>
66
66
  ) : null}
67
67
 
68
68
  {payload.body?.text ? (
69
- <p
70
- className={cn(
71
- 'whitespace-pre-wrap break-words text-sm text-gray-900 dark:text-gray-100',
72
- FORMATTING_CLASSES,
73
- )}
69
+ <div
70
+ className={cn('whitespace-pre-wrap break-words text-sm text-gray-900 dark:text-gray-100', FORMATTING_CLASSES)}
74
71
  >
75
72
  {parseWhatsAppFormatting(payload.body.text)}
76
- </p>
73
+ </div>
77
74
  ) : null}
78
75
 
79
76
  {payload.footer?.text ? (
80
- <p className={cn('text-xs text-gray-500 dark:text-gray-400', FORMATTING_CLASSES)}>
77
+ <div className={cn('text-xs text-gray-500 dark:text-gray-400', FORMATTING_CLASSES)}>
81
78
  {parseWhatsAppFormatting(payload.footer.text)}
82
- </p>
79
+ </div>
83
80
  ) : null}
84
81
 
85
82
  {buttons.length > 0 ? (
86
83
  <div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
87
84
  {buttons.map((button) => (
88
85
  <button
89
- data-cv-tooltip={button.title} aria-label={button.title}
86
+ data-cv-tooltip={button.title}
87
+ aria-label={button.title}
90
88
  key={button.id}
91
89
  type="button"
92
90
  disabled={!isInteractable}
@@ -102,7 +100,8 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
102
100
  {hasRows ? (
103
101
  <div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
104
102
  <button
105
- data-cv-tooltip={payload.action?.button ?? openListLabel} aria-label={payload.action?.button ?? openListLabel}
103
+ data-cv-tooltip={payload.action?.button ?? openListLabel}
104
+ aria-label={payload.action?.button ?? openListLabel}
106
105
  type="button"
107
106
  onClick={() => setIsListOpen((open) => !open)}
108
107
  aria-expanded={isListOpen}
@@ -122,7 +121,8 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
122
121
  ) : null}
123
122
  {(section.rows ?? []).map((row) => (
124
123
  <button
125
- data-cv-tooltip={row.title} aria-label={row.title}
124
+ data-cv-tooltip={row.title}
125
+ aria-label={row.title}
126
126
  key={row.id}
127
127
  type="button"
128
128
  disabled={!isInteractable}
@@ -30,9 +30,7 @@ export function MessageText({ message }: MessageTextProps) {
30
30
  onClick={handleCopy}
31
31
  className="text-[14.2px] leading-[19px] whitespace-pre-wrap break-words select-all [&_strong]:font-bold [&_em]:italic [&_del]:line-through"
32
32
  >
33
- <span>
34
- {parseWhatsAppFormatting(message.content ?? '')}
35
- </span>
33
+ <div>{parseWhatsAppFormatting(message.content ?? '')}</div>
36
34
  {copied && (
37
35
  <span className="absolute top-0 right-0 -translate-y-full bg-[#3b4a54] text-white text-[11px] px-1.5 py-0.5 rounded shadow-lg">
38
36
  Copiado!
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { InteractiveMessage } from './InteractiveMessage'
5
+ import { MessageText } from './MessageText'
6
+
7
+ const BLOCK_MESSAGE = 'Opções:\n- *um*\n1. dois\n> três'
8
+
9
+ /** `<ul>`/`<div>`/`<blockquote>` dentro de `<span>` ou `<p>` é HTML inválido — o navegador reordena. */
10
+ function hasBlockInsideInline(markup: string): boolean {
11
+ return /<(span|p)\b[^>]*>(?:(?!<\/\1>).)*<(ul|ol|div|blockquote)\b/s.test(markup)
12
+ }
13
+
14
+ describe('blocos da formatação do WhatsApp nos invólucros de mensagem', () => {
15
+ it('MessageText não põe lista nem citação dentro de elemento inline', () => {
16
+ const markup = renderToStaticMarkup(
17
+ <MessageText
18
+ message={{
19
+ id: 'm1',
20
+ type: 'text',
21
+ direction: 'inbound',
22
+ sender: 'customer',
23
+ timestamp: '2026-09-15T12:00:00.000Z',
24
+ content: BLOCK_MESSAGE,
25
+ }}
26
+ />,
27
+ )
28
+ expect(markup).toContain('<ul')
29
+ expect(markup).toContain('<blockquote')
30
+ expect(hasBlockInsideInline(markup)).toBe(false)
31
+ })
32
+
33
+ it('InteractiveMessage também não, no cabeçalho, corpo e rodapé', () => {
34
+ const markup = renderToStaticMarkup(
35
+ <InteractiveMessage
36
+ payload={{
37
+ type: 'button',
38
+ header: { text: BLOCK_MESSAGE },
39
+ body: { text: BLOCK_MESSAGE },
40
+ footer: { text: BLOCK_MESSAGE },
41
+ }}
42
+ />,
43
+ )
44
+ expect(markup).toContain('<ol')
45
+ expect(hasBlockInsideInline(markup)).toBe(false)
46
+ })
47
+ })
@@ -1,5 +1,6 @@
1
1
  import { List } from 'lucide-react'
2
2
  import { parseWhatsAppFormatting } from '../lib/whatsapp-formatting'
3
+ import { WhatsAppMessagePreview } from '../lib/WhatsAppMessagePreview'
3
4
  import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
4
5
  import { rendersAsButtons, WHATSAPP_LIMITS } from './flowGraph'
5
6
 
@@ -26,53 +27,50 @@ export function FlowWhatsAppPreview({
26
27
  const hasOptions = (options?.length ?? 0) > 0
27
28
 
28
29
  return (
29
- <div className="rounded-xl bg-[#e5ddd5] dark:bg-gray-900 p-3 space-y-1.5">
30
- <div className="max-w-[85%]">
31
- <div className="rounded-lg rounded-tl-none bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm whitespace-pre-wrap break-words">
32
- {body ? (
33
- parseWhatsAppFormatting(body)
34
- ) : (
35
- <span className="italic text-gray-400">{labels.previewEmptyBody}</span>
30
+ <WhatsAppMessagePreview
31
+ belowBubble={
32
+ <>
33
+ {hasOptions && usesButtons && (
34
+ <div className="mt-1 space-y-1">
35
+ {options!.map(([id, label]) => (
36
+ <div
37
+ key={id}
38
+ className="rounded-lg bg-white dark:bg-gray-700 py-1.5 text-center text-sm font-medium text-cyan-600 dark:text-cyan-400 shadow-sm"
39
+ >
40
+ {label || <span className="italic text-gray-400">{labels.previewEmptyOption}</span>}
41
+ </div>
42
+ ))}
43
+ </div>
36
44
  )}
45
+
37
46
  {hasOptions && !usesButtons && (
38
- <div className="mt-2 -mx-3 -mb-2 border-t border-gray-100 dark:border-gray-600">
39
- <div className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-cyan-600 dark:text-cyan-400">
40
- <List size={15} /> {labels.previewListButton}
41
- </div>
47
+ <div className="mt-1.5 rounded-lg bg-white dark:bg-gray-700 shadow-sm divide-y divide-gray-100 dark:divide-gray-600 overflow-hidden">
48
+ {options!.slice(0, WHATSAPP_LIMITS.MAX_LIST_ROWS).map(([id, label]) => (
49
+ <div key={id} className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-800 dark:text-gray-100">
50
+ <span className="h-3.5 w-3.5 rounded-full border border-gray-300 dark:border-gray-500 shrink-0" />
51
+ {label || <span className="italic text-gray-400">{labels.previewEmptyOption}</span>}
52
+ </div>
53
+ ))}
42
54
  </div>
43
55
  )}
44
- </div>
45
-
46
- {hasOptions && usesButtons && (
47
- <div className="mt-1 space-y-1">
48
- {options!.map(([id, label]) => (
49
- <div
50
- key={id}
51
- className="rounded-lg bg-white dark:bg-gray-700 py-1.5 text-center text-sm font-medium text-cyan-600 dark:text-cyan-400 shadow-sm"
52
- >
53
- {label || <span className="italic text-gray-400">{labels.previewEmptyOption}</span>}
54
- </div>
55
- ))}
56
+ </>
57
+ }
58
+ footer={
59
+ hasOptions && (
60
+ <p className="text-[11px] text-gray-500 dark:text-gray-400 px-1">
61
+ {usesButtons ? labels.previewModeButtons : labels.previewModeList}
62
+ </p>
63
+ )
64
+ }
65
+ >
66
+ {body ? parseWhatsAppFormatting(body) : <span className="italic text-gray-400">{labels.previewEmptyBody}</span>}
67
+ {hasOptions && !usesButtons && (
68
+ <div className="mt-2 -mx-3 -mb-2 border-t border-gray-100 dark:border-gray-600">
69
+ <div className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-cyan-600 dark:text-cyan-400">
70
+ <List size={15} /> {labels.previewListButton}
56
71
  </div>
57
- )}
58
-
59
- {hasOptions && !usesButtons && (
60
- <div className="mt-1.5 rounded-lg bg-white dark:bg-gray-700 shadow-sm divide-y divide-gray-100 dark:divide-gray-600 overflow-hidden">
61
- {options!.slice(0, WHATSAPP_LIMITS.MAX_LIST_ROWS).map(([id, label]) => (
62
- <div key={id} className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-800 dark:text-gray-100">
63
- <span className="h-3.5 w-3.5 rounded-full border border-gray-300 dark:border-gray-500 shrink-0" />
64
- {label || <span className="italic text-gray-400">{labels.previewEmptyOption}</span>}
65
- </div>
66
- ))}
67
- </div>
68
- )}
69
- </div>
70
-
71
- {hasOptions && (
72
- <p className="text-[11px] text-gray-500 dark:text-gray-400 px-1">
73
- {usesButtons ? labels.previewModeButtons : labels.previewModeList}
74
- </p>
72
+ </div>
75
73
  )}
76
- </div>
74
+ </WhatsAppMessagePreview>
77
75
  )
78
76
  }
package/src/index.ts CHANGED
@@ -174,6 +174,9 @@ export { useConversationRealtime, useGlobalRealtime } from './hooks/useConversat
174
174
  export { useConversationActions, useInboxActions } from './hooks/useConversationActions'
175
175
 
176
176
  export { parseWhatsAppFormatting, waToHTML, htmlToWA, waToHTMLInline } from './lib/whatsapp-formatting'
177
+ export { WhatsAppMessagePreview } from './lib/WhatsAppMessagePreview'
178
+ export type { WhatsAppMessagePreviewProps } from './lib/WhatsAppMessagePreview'
179
+ export { WHATSAPP_MARKER_BY_ACTION } from './lib/composer-formatting'
177
180
  export { formatPhone, phoneInitials } from './lib/phone'
178
181
  // `formatDateTime` e `isSameDay` já eram usados pelas bolhas e pelo divisor de data; exportá-los
179
182
  // evita que cada host mantenha a própria cópia e acabe com timeline e transcript divergindo.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ */
4
+
5
+ import type { ReactNode } from 'react'
6
+
7
+ import { cn } from './cn'
8
+
9
+ export interface WhatsAppMessagePreviewProps {
10
+ /** Conteúdo dentro do balão (texto já formatado, anexos, botão de lista). */
11
+ readonly children: ReactNode
12
+ /** Abaixo do balão, ainda na coluna dele — botões de resposta, linhas de lista. */
13
+ readonly belowBubble?: ReactNode
14
+ /** Rodapé fora da coluna do balão, sobre o fundo. */
15
+ readonly footer?: ReactNode
16
+ readonly className?: string
17
+ }
18
+
19
+ /** Balão recebido sobre o fundo do WhatsApp — o único estilo de pré-visualização do pacote. */
20
+ export function WhatsAppMessagePreview({ children, belowBubble, footer, className }: WhatsAppMessagePreviewProps) {
21
+ return (
22
+ <div className={cn('rounded-xl bg-[#e5ddd5] dark:bg-gray-900 p-3 space-y-1.5', className)}>
23
+ <div className="max-w-[85%]">
24
+ <div className="rounded-lg rounded-tl-none bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm whitespace-pre-wrap break-words">
25
+ {children}
26
+ </div>
27
+ {belowBubble}
28
+ </div>
29
+ {footer}
30
+ </div>
31
+ )
32
+ }
@@ -22,6 +22,14 @@ export const FORMATTING_ACTION = {
22
22
  } as const
23
23
  export type FormattingAction = (typeof FORMATTING_ACTION)[keyof typeof FORMATTING_ACTION]
24
24
 
25
+ /** Notação do WhatsApp de cada ação — a mesma que `parseWhatsAppFormatting` reconhece. */
26
+ export const WHATSAPP_MARKER_BY_ACTION = {
27
+ [FORMATTING_ACTION.BOLD]: '*',
28
+ [FORMATTING_ACTION.ITALIC]: '_',
29
+ [FORMATTING_ACTION.STRIKETHROUGH]: '~',
30
+ [FORMATTING_ACTION.MONOSPACE]: '```',
31
+ } as const satisfies Record<FormattingAction, string>
32
+
25
33
  /** Monoespaçado fica de fora: não existe comando nativo, o campo trata à mão. */
26
34
  const EXEC_COMMAND_BY_ACTION = {
27
35
  [FORMATTING_ACTION.BOLD]: 'bold',
@@ -30,8 +30,71 @@ describe('parseWhatsAppFormatting com marcador fechado', () => {
30
30
  })
31
31
 
32
32
  it('mantém negrito, itálico e tachado', () => {
33
- expect(render('*a* _b_ ~c~')).toBe(
34
- '<strong>a</strong><span> </span><em>b</em><span> </span><del>c</del>',
33
+ expect(render('*a* _b_ ~c~')).toBe('<strong>a</strong><span> </span><em>b</em><span> </span><del>c</del>')
34
+ })
35
+ })
36
+
37
+ describe('parseWhatsAppFormatting com quebras de linha', () => {
38
+ it('preserva quebra simples e linha em branco no texto sem bloco', () => {
39
+ expect(render('a\nb')).toBe('<span>a\nb</span>')
40
+ expect(render('a\n\nb')).toBe('<span>a\n\nb</span>')
41
+ })
42
+
43
+ it('não deixa o marcador atravessar a quebra de linha', () => {
44
+ expect(render('*a\nb*')).not.toContain('<strong>')
45
+ expect(render('_a\nb_')).not.toContain('<em>')
46
+ expect(render('~a\nb~')).not.toContain('<del>')
47
+ expect(render('`a\nb`')).not.toContain('<code')
48
+ })
49
+
50
+ it('continua formatando em cada linha', () => {
51
+ expect(render('*a*\n_b_')).toBe('<strong>a</strong><span>\n</span><em>b</em>')
52
+ })
53
+ })
54
+
55
+ describe('parseWhatsAppFormatting com blocos por linha', () => {
56
+ it('lista com * e - vira ul, com inline dentro', () => {
57
+ const markup = render('* *um*\n- dois')
58
+ expect(markup).toMatch(/^<ul[^>]*><li><strong>um<\/strong><\/li><li><span>dois<\/span><\/li><\/ul>$/)
59
+ })
60
+
61
+ it('lista numerada mantém o número digitado', () => {
62
+ const markup = render('1. a\n3. b')
63
+ expect(markup).toMatch(/^<ol[^>]*><li value="1"><span>a<\/span><\/li><li value="3"><span>b<\/span><\/li><\/ol>$/)
64
+ })
65
+
66
+ it('citação vira blockquote, uma linha por div', () => {
67
+ expect(render('> oi\n> _tchau_')).toMatch(
68
+ /^<blockquote[^>]*><div><span>oi<\/span><\/div><div><em>tchau<\/em><\/div><\/blockquote>$/,
69
+ )
70
+ })
71
+
72
+ it('preserva texto e linhas em branco em volta dos blocos', () => {
73
+ const markup = render('Olá\n\n- a\n\nTchau')
74
+ expect(markup).toBe(
75
+ '<div><span>Olá\n</span>\n</div><ul class="list-disc pl-5"><li><span>a</span></li></ul><div><span>\nTchau</span></div>',
76
+ )
77
+ })
78
+
79
+ it('asterisco sem espaço no início é negrito, não lista', () => {
80
+ expect(render('*oi*')).toBe('<strong>oi</strong>')
81
+ })
82
+
83
+ it('com bloco de código de várias linhas, fica só no inline', () => {
84
+ expect(render('- a\n```x\ny```')).not.toContain('<ul')
85
+ })
86
+ })
87
+
88
+ describe('parseWhatsAppFormatting — paridade com o WhatsApp em prosa', () => {
89
+ it('linha "- Oi" vira lista e "> x" vira citação, como no aparelho', () => {
90
+ expect(render('- Oi')).toContain('<ul')
91
+ expect(render('> x')).toContain('<blockquote')
92
+ })
93
+
94
+ it('texto, linha em branco, lista, linha em branco, texto mantém as duas linhas em branco', () => {
95
+ const markup = render('a\n\n- b\n\nc')
96
+ expect(markup).toBe(
97
+ '<div><span>a\n</span>\n</div><ul class="list-disc pl-5"><li><span>b</span></li></ul><div><span>\nc</span></div>',
35
98
  )
36
99
  })
37
100
  })
@@ -39,7 +39,7 @@ function tokenize(text: string): FormatToken[] {
39
39
  continue
40
40
  }
41
41
  // Check for inline code
42
- const inlineMatch = remaining.slice(i).match(/^`([^`]+)`/)
42
+ const inlineMatch = remaining.slice(i).match(/^`([^`\n]+)`/)
43
43
  if (inlineMatch && inlineMatch.index === 0) {
44
44
  if (buffer) {
45
45
  tokens.push(...parseInlineTokens(buffer))
@@ -66,19 +66,20 @@ function parseInlineTokens(text: string): FormatToken[] {
66
66
  let remaining = text
67
67
 
68
68
  while (remaining.length > 0) {
69
- const boldMatch = remaining.match(/^\*([^*]+)\*/)
69
+ // Os marcadores não atravessam quebra de linha — o WhatsApp deixa `*a\nb*` literal.
70
+ const boldMatch = remaining.match(/^\*([^*\n]+)\*/)
70
71
  if (boldMatch) {
71
72
  result.push({ type: 'bold', content: boldMatch[1] })
72
73
  remaining = remaining.slice(boldMatch[0].length)
73
74
  continue
74
75
  }
75
- const italicMatch = remaining.match(/^_([^_]+)_/)
76
+ const italicMatch = remaining.match(/^_([^_\n]+)_/)
76
77
  if (italicMatch) {
77
78
  result.push({ type: 'italic', content: italicMatch[1] })
78
79
  remaining = remaining.slice(italicMatch[0].length)
79
80
  continue
80
81
  }
81
- const strikeMatch = remaining.match(/^~([^~]+)~/)
82
+ const strikeMatch = remaining.match(/^~([^~\n]+)~/)
82
83
  if (strikeMatch) {
83
84
  result.push({ type: 'strikethrough', content: strikeMatch[1] })
84
85
  remaining = remaining.slice(strikeMatch[0].length)
@@ -105,9 +106,95 @@ function parseInlineTokens(text: string): FormatToken[] {
105
106
  return result
106
107
  }
107
108
 
109
+ const BULLETED_LINE = /^[*-] (.*)$/
110
+ const NUMBERED_LINE = /^(\d+)\. (.*)$/
111
+ const QUOTE_LINE = /^> (.*)$/
112
+
113
+ type BlockKind = 'plain' | 'bulleted' | 'numbered' | 'quote'
114
+
115
+ interface BlockGroup {
116
+ kind: BlockKind
117
+ lines: string[]
118
+ }
119
+
120
+ function blockKindOf(line: string): BlockKind {
121
+ if (BULLETED_LINE.test(line)) return 'bulleted'
122
+ if (NUMBERED_LINE.test(line)) return 'numbered'
123
+ if (QUOTE_LINE.test(line)) return 'quote'
124
+ return 'plain'
125
+ }
126
+
127
+ function groupLines(lines: string[]): BlockGroup[] {
128
+ const groups: BlockGroup[] = []
129
+ for (const line of lines) {
130
+ const kind = blockKindOf(line)
131
+ const last = groups[groups.length - 1]
132
+ if (last && last.kind === kind) last.lines.push(line)
133
+ else groups.push({ kind, lines: [line] })
134
+ }
135
+ return groups
136
+ }
137
+
138
+ function renderBlockGroup(group: BlockGroup, key: number): ReactNode {
139
+ if (group.kind === 'bulleted') {
140
+ return (
141
+ <ul key={key} className="list-disc pl-5">
142
+ {group.lines.map((line, index) => (
143
+ <li key={index}>{renderTokens(tokenize(line.replace(BULLETED_LINE, '$1')))}</li>
144
+ ))}
145
+ </ul>
146
+ )
147
+ }
148
+ if (group.kind === 'numbered') {
149
+ return (
150
+ <ol key={key} className="list-decimal pl-6">
151
+ {group.lines.map((line, index) => {
152
+ const [, number, content] = NUMBERED_LINE.exec(line) ?? []
153
+ return (
154
+ <li key={index} value={Number(number)}>
155
+ {renderTokens(tokenize(content ?? ''))}
156
+ </li>
157
+ )
158
+ })}
159
+ </ol>
160
+ )
161
+ }
162
+ if (group.kind === 'quote') {
163
+ return (
164
+ <blockquote
165
+ key={key}
166
+ className="border-l-4 border-gray-300 pl-2 text-gray-600 dark:border-gray-500 dark:text-gray-300"
167
+ >
168
+ {group.lines.map((line, index) => (
169
+ <div key={index}>{renderTokens(tokenize(line.replace(QUOTE_LINE, '$1')))}</div>
170
+ ))}
171
+ </blockquote>
172
+ )
173
+ }
174
+ const joined = group.lines.join('\n')
175
+ // Em `pre-wrap`, a última quebra de um bloco não desenha linha — a extra mantém a linha em branco.
176
+ const trailingBreak = group.lines[group.lines.length - 1] === '' ? '\n' : null
177
+ return (
178
+ <div key={key}>
179
+ {renderTokens(tokenize(joined))}
180
+ {trailingBreak}
181
+ </div>
182
+ )
183
+ }
184
+
185
+ /**
186
+ * Formatação do WhatsApp: inline (`*`, `_`, `~`, `` ` ``, ```` ``` ````) e, por linha, lista (`* `,
187
+ * `- `), lista numerada (`1. `) e citação (`> `). Sem linha de bloco, a saída é só inline, igual à de
188
+ * sempre. Com bloco de código de várias linhas, também — dividir por linha partiria o bloco.
189
+ */
108
190
  export function parseWhatsAppFormatting(text: string): ReactNode[] {
109
- const tokens = tokenize(text)
191
+ const lines = text.split('\n')
192
+ const hasBlockLine = lines.some((line) => blockKindOf(line) !== 'plain')
193
+ if (!hasBlockLine || text.includes('```')) return renderTokens(tokenize(text))
194
+ return groupLines(lines).map(renderBlockGroup)
195
+ }
110
196
 
197
+ function renderTokens(tokens: FormatToken[]): ReactNode[] {
111
198
  return tokens.map((token, index) => {
112
199
  switch (token.type) {
113
200
  case 'bold':
@@ -117,7 +204,11 @@ export function parseWhatsAppFormatting(text: string): ReactNode[] {
117
204
  case 'strikethrough':
118
205
  return <del key={index}>{token.content}</del>
119
206
  case 'monospace':
120
- return <code key={index} className="bg-gray-100 px-1 py-0.5 rounded text-sm">{token.content}</code>
207
+ return (
208
+ <code key={index} className="bg-gray-100 px-1 py-0.5 rounded text-sm">
209
+ {token.content}
210
+ </code>
211
+ )
121
212
  case 'codeblock':
122
213
  return (
123
214
  <pre key={index} className="bg-gray-100 p-2 rounded text-sm overflow-x-auto my-1">
@@ -135,7 +226,11 @@ function escapeHtml(text: string): string {
135
226
  }
136
227
 
137
228
  function unescapeHtml(text: string): string {
138
- return text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ')
229
+ return text
230
+ .replace(/&amp;/g, '&')
231
+ .replace(/&lt;/g, '<')
232
+ .replace(/&gt;/g, '>')
233
+ .replace(/&nbsp;/g, ' ')
139
234
  }
140
235
 
141
236
  // Marcador da Private Use Area do Unicode — praticamente impossível de colidir com
@@ -196,12 +291,14 @@ export function htmlToWA(html: string): string {
196
291
  // O `<code>` que o cursor abre nasce só com a âncora de largura zero. Sem nada digitado dentro,
197
292
  // ele viraria um par de crases vazias no texto enviado.
198
293
  text = text.replace(EMPTY_CODE_REGEX, '')
199
- text = text.replace(/<code data-wa="block"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
200
- `\`\`\`${unescapeHtml(inner.replace(/<br\s*\/?>/gi, '\n'))}\`\`\``
201
- ))
202
- text = text.replace(/<code data-wa="inline"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
203
- `\`${unescapeHtml(inner)}\``
204
- ))
294
+ text = text.replace(
295
+ /<code data-wa="block"[^>]*>([\s\S]*?)<\/code>/gi,
296
+ (_match, inner: string) => `\`\`\`${unescapeHtml(inner.replace(/<br\s*\/?>/gi, '\n'))}\`\`\``,
297
+ )
298
+ text = text.replace(
299
+ /<code data-wa="inline"[^>]*>([\s\S]*?)<\/code>/gi,
300
+ (_match, inner: string) => `\`${unescapeHtml(inner)}\``,
301
+ )
205
302
 
206
303
  text = text
207
304
  .replace(/<br\s*\/?>/gi, '\n')