@adatechnology/conversations-ui 0.2.0 → 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.
- package/dist/{chunk-DKPXKQGC.js → chunk-HMCOTZAX.js} +18 -1
- package/dist/{chunk-NHQDQJL2.js → chunk-L4OE4ORQ.js} +18 -19
- package/dist/{chunk-WCBDXZ3X.js → chunk-PJNR6UMN.js} +73 -8
- package/dist/flows/index.js +32 -28
- package/dist/index.d.ts +42 -3
- package/dist/index.js +586 -295
- package/dist/preview/index.js +2 -2
- package/package.json +1 -1
- package/src/InteractiveMessage.tsx +13 -13
- package/src/MessageText.tsx +1 -3
- package/src/blockFormattingNesting.test.tsx +47 -0
- package/src/flows/FlowWhatsAppPreview.tsx +40 -42
- package/src/index.ts +3 -0
- package/src/lib/WhatsAppMessagePreview.tsx +32 -0
- package/src/lib/composer-formatting.ts +8 -0
- package/src/lib/whatsapp-formatting.test.tsx +65 -2
- package/src/lib/whatsapp-formatting.tsx +110 -13
- package/src/quickReplies/QuickRepliesWorkspace.tsx +249 -164
- package/src/quickReplies/QuickReplyFormattingToolbar.tsx +62 -0
- package/src/quickReplies/QuickReplyWhatsAppPreview.tsx +57 -0
- package/src/quickReplies/labels.ts +27 -1
- package/src/quickReplies/quickReplyAttachments.test.ts +71 -0
- package/src/quickReplies/quickReplyAttachments.ts +39 -0
- package/src/quickReplies/quickReplyFormatting.test.tsx +336 -0
- package/src/quickReplies/quickReplyFormatting.ts +232 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +1 -1
- package/src/workspace/useComposerAttachmentRetry.ts +14 -0
- package/src/workspace/useComposerQueue.ts +17 -8
package/dist/preview/index.js
CHANGED
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
mediaTypeOf,
|
|
17
17
|
simulatorPanelLabelsOf,
|
|
18
18
|
toConversationSimulatorClient
|
|
19
|
-
} from "../chunk-
|
|
20
|
-
import "../chunk-
|
|
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
|
@@ -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
|
-
<
|
|
63
|
+
<div className={cn('text-sm font-semibold text-gray-900 dark:text-gray-100', FORMATTING_CLASSES)}>
|
|
64
64
|
{parseWhatsAppFormatting(payload.header.text)}
|
|
65
|
-
</
|
|
65
|
+
</div>
|
|
66
66
|
) : null}
|
|
67
67
|
|
|
68
68
|
{payload.body?.text ? (
|
|
69
|
-
<
|
|
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
|
-
</
|
|
73
|
+
</div>
|
|
77
74
|
) : null}
|
|
78
75
|
|
|
79
76
|
{payload.footer?.text ? (
|
|
80
|
-
<
|
|
77
|
+
<div className={cn('text-xs text-gray-500 dark:text-gray-400', FORMATTING_CLASSES)}>
|
|
81
78
|
{parseWhatsAppFormatting(payload.footer.text)}
|
|
82
|
-
</
|
|
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}
|
|
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}
|
|
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}
|
|
124
|
+
data-cv-tooltip={row.title}
|
|
125
|
+
aria-label={row.title}
|
|
126
126
|
key={row.id}
|
|
127
127
|
type="button"
|
|
128
128
|
disabled={!isInteractable}
|
package/src/MessageText.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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-
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
229
|
+
return text
|
|
230
|
+
.replace(/&/g, '&')
|
|
231
|
+
.replace(/</g, '<')
|
|
232
|
+
.replace(/>/g, '>')
|
|
233
|
+
.replace(/ /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(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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')
|