@adatechnology/conversations-ui 0.6.0 → 0.7.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.
Files changed (88) hide show
  1. package/README.md +126 -19
  2. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  3. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  4. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  5. package/dist/flows/index.js +3 -5
  6. package/dist/index.js +834 -764
  7. package/dist/participant/index.d.ts +18 -1
  8. package/dist/participant/index.js +1188 -313
  9. package/dist/preview/index.js +2 -3
  10. package/dist/styles.css +591 -19
  11. package/package.json +1 -1
  12. package/src/cssBlocks.test-helper.ts +31 -0
  13. package/src/legacyStyles.golden.test.ts +2 -31
  14. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  15. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  16. package/src/participant/ParticipantBackButton.tsx +14 -0
  17. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  18. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  19. package/src/participant/ParticipantComposer.tsx +8 -40
  20. package/src/participant/ParticipantComposerControls.tsx +83 -0
  21. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  22. package/src/participant/ParticipantConversations.tsx +7 -0
  23. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  24. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  25. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  26. package/src/participant/ParticipantDayDivider.tsx +18 -0
  27. package/src/participant/ParticipantInbox.tsx +3 -1
  28. package/src/participant/ParticipantInboxRow.tsx +6 -4
  29. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  30. package/src/participant/ParticipantMessageBubble.tsx +25 -81
  31. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  32. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  33. package/src/participant/ParticipantMessageText.tsx +52 -0
  34. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  35. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  36. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  37. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  38. package/src/participant/ParticipantThread.test.tsx +37 -0
  39. package/src/participant/ParticipantThread.tsx +45 -95
  40. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  41. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  42. package/src/participant/ParticipantThreadHeader.tsx +73 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +24 -10
  44. package/src/participant/ParticipantTimeline.tsx +103 -0
  45. package/src/participant/index.ts +1 -0
  46. package/src/participant/participantAutoGrow.test.ts +43 -0
  47. package/src/participant/participantAutoGrow.ts +12 -0
  48. package/src/participant/participantAvatar.test.ts +74 -0
  49. package/src/participant/participantAvatar.ts +48 -0
  50. package/src/participant/participantBubbleTail.test.tsx +107 -0
  51. package/src/participant/participantChatDesign.test.tsx +198 -0
  52. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  53. package/src/participant/participantCopyAnnouncement.ts +37 -0
  54. package/src/participant/participantCss.test-helper.ts +34 -0
  55. package/src/participant/participantDay.test.ts +22 -0
  56. package/src/participant/participantDay.ts +8 -0
  57. package/src/participant/participantDocuments.ts +45 -0
  58. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  59. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  60. package/src/participant/participantLabels.ts +11 -0
  61. package/src/participant/participantMessageAtoms.ts +53 -0
  62. package/src/participant/participantMessageCopyables.test.ts +141 -0
  63. package/src/participant/participantMessageCopyables.ts +76 -0
  64. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  65. package/src/participant/participantMessageFormat.test.ts +177 -0
  66. package/src/participant/participantMessageFormat.ts +135 -0
  67. package/src/participant/participantMessageFormat.types.ts +22 -0
  68. package/src/participant/participantMessageInline.test.ts +82 -0
  69. package/src/participant/participantMessageInline.ts +99 -0
  70. package/src/participant/participantMessageLinks.test.ts +100 -0
  71. package/src/participant/participantMessageLinks.ts +54 -0
  72. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  73. package/src/participant/participantScrollFocus.test.ts +22 -0
  74. package/src/participant/participantScrollFocus.ts +10 -0
  75. package/src/participant/participantScrollView.test.ts +74 -0
  76. package/src/participant/participantScrollView.ts +38 -0
  77. package/src/participant/participantScrollbars.test.ts +156 -0
  78. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  79. package/src/participant/participantSubjectIcon.ts +26 -0
  80. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  81. package/src/participant/useParticipantThreadScroll.ts +10 -2
  82. package/src/participant/useRevealActiveChip.ts +20 -0
  83. package/src/participant/useScrollAwayState.ts +24 -0
  84. package/src/participant/useStickToBottom.ts +16 -2
  85. package/src/participantLayoutShrink.test.ts +82 -0
  86. package/src/styles.css +140 -21
  87. package/dist/chunk-MRVFFJEH.js +0 -64
  88. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -0,0 +1,53 @@
1
+ import { findCopyableAtoms } from './participantMessageCopyables'
2
+ import type { InlineAtom } from './participantMessageFormat.types'
3
+ import { findLinkAtoms } from './participantMessageLinks'
4
+
5
+ const TRIPLE_FENCE = '```'
6
+
7
+ function spanAt(
8
+ text: string,
9
+ open: number,
10
+ ): { readonly contentStart: number; readonly close: number; readonly width: number } | undefined {
11
+ const isTriple = text.startsWith(TRIPLE_FENCE, open)
12
+ const width = isTriple ? 3 : 1
13
+ const close = text.indexOf(isTriple ? TRIPLE_FENCE : '`', open + width)
14
+ if (close === -1) return undefined
15
+ return { contentStart: open + width, close, width }
16
+ }
17
+
18
+ export function findCodeAtoms(text: string): InlineAtom[] {
19
+ const atoms: InlineAtom[] = []
20
+ let cursor = text.indexOf('`')
21
+ while (cursor !== -1) {
22
+ const span = spanAt(text, cursor)
23
+ if (span === undefined) {
24
+ if (!text.startsWith(TRIPLE_FENCE, cursor)) break
25
+ cursor = text.indexOf('`', cursor + 3)
26
+ continue
27
+ }
28
+ if (span.close === span.contentStart) {
29
+ cursor = text.indexOf('`', cursor + span.width)
30
+ continue
31
+ }
32
+ const end = span.close + span.width
33
+ atoms.push({ start: cursor, end, node: { kind: 'code', value: text.slice(span.contentStart, span.close) } })
34
+ cursor = text.indexOf('`', end)
35
+ }
36
+ return atoms
37
+ }
38
+
39
+ /** Code wins over links, links over copyable tokens; overlapping losers are dropped. */
40
+ export function findInlineAtoms(text: string): InlineAtom[] {
41
+ const candidates = [...findCodeAtoms(text), ...findLinkAtoms(text), ...findCopyableAtoms(text)]
42
+ const ordered = candidates
43
+ .map((atom, rank) => ({ atom, rank }))
44
+ .sort((a, b) => a.atom.start - b.atom.start || a.rank - b.rank)
45
+ const accepted: InlineAtom[] = []
46
+ let lastEnd = 0
47
+ for (const { atom } of ordered) {
48
+ if (atom.start < lastEnd) continue
49
+ accepted.push(atom)
50
+ lastEnd = atom.end
51
+ }
52
+ return accepted
53
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { isValidCnpj, isValidCpf } from './participantDocuments'
4
+ import { show } from './participantMessageFormat.test-helper'
5
+ import { parseInline } from './participantMessageInline'
6
+
7
+ function inline(line: string): string {
8
+ return show(parseInline(line))
9
+ }
10
+
11
+ function copyable(kind: string, value: string): string {
12
+ return `⟦k:${kind}:${value}⟧`
13
+ }
14
+
15
+ describe('check digits', () => {
16
+ it('validates CPF', () => {
17
+ expect(isValidCpf('52998224725')).toBe(true)
18
+ expect(isValidCpf('52998224726')).toBe(false)
19
+ expect(isValidCpf('11111111111')).toBe(false)
20
+ expect(isValidCpf('1234567890')).toBe(false)
21
+ })
22
+
23
+ it('validates numeric and alphanumeric CNPJ', () => {
24
+ expect(isValidCnpj('11222333000181')).toBe(true)
25
+ expect(isValidCnpj('11222333000182')).toBe(false)
26
+ expect(isValidCnpj('12ABC34501DE35')).toBe(true)
27
+ expect(isValidCnpj('12ABC34501DE36')).toBe(false)
28
+ expect(isValidCnpj('00000000000000')).toBe(false)
29
+ })
30
+ })
31
+
32
+ describe('copyable - CPF', () => {
33
+ it('detects valid CPF with and without mask', () => {
34
+ expect(inline('CPF 529.982.247-25 ok')).toBe(`CPF ${copyable('cpf', '529.982.247-25')} ok`)
35
+ expect(inline('52998224725')).toBe(copyable('cpf', '52998224725'))
36
+ expect(inline('CPF: 529.982.247-25.')).toBe(`CPF: ${copyable('cpf', '529.982.247-25')}.`)
37
+ })
38
+
39
+ it('ignores repeated sequences and wrong check digits', () => {
40
+ expect(inline('111.111.111-11')).toBe('111.111.111-11')
41
+ expect(inline('11111111111')).toBe('11111111111')
42
+ expect(inline('529.982.247-26')).toBe('529.982.247-26')
43
+ })
44
+ })
45
+
46
+ describe('copyable - CNPJ', () => {
47
+ it('detects valid numeric CNPJ with and without mask', () => {
48
+ expect(inline('11.222.333/0001-81')).toBe(copyable('cnpj', '11.222.333/0001-81'))
49
+ expect(inline('11222333000181')).toBe(copyable('cnpj', '11222333000181'))
50
+ })
51
+
52
+ it('detects the new alphanumeric CNPJ', () => {
53
+ expect(inline('12.ABC.345/01DE-35')).toBe(copyable('cnpj', '12.ABC.345/01DE-35'))
54
+ expect(inline('12ABC34501DE35')).toBe(copyable('cnpj', '12ABC34501DE35'))
55
+ })
56
+
57
+ it('ignores invalid CNPJ and does not take it for a phone', () => {
58
+ expect(inline('11.222.333/0001-82')).toBe('11.222.333/0001-82')
59
+ expect(inline('12ABC34501DE36')).toBe('12ABC34501DE36')
60
+ })
61
+ })
62
+
63
+ describe('copyable - access key', () => {
64
+ const KEY = '35260112345678000195550010000012341000012345'
65
+
66
+ it('detects exactly 44 digits, bare or in groups of four', () => {
67
+ expect(KEY).toHaveLength(44)
68
+ expect(inline(`chave ${KEY}`)).toBe(`chave ${copyable('accessKey', KEY)}`)
69
+ const grouped = KEY.match(/\d{4}/g)?.join(' ') ?? ''
70
+ expect(inline(grouped)).toBe(copyable('accessKey', grouped))
71
+ })
72
+
73
+ it('ignores 43 and 45 digit runs', () => {
74
+ expect(inline(KEY.slice(1))).toBe(KEY.slice(1))
75
+ expect(inline(`${KEY}1`)).toBe(`${KEY}1`)
76
+ })
77
+
78
+ it('ignores a repeated sequence', () => {
79
+ expect(inline('1'.repeat(44))).toBe('1'.repeat(44))
80
+ })
81
+ })
82
+
83
+ describe('copyable - phone', () => {
84
+ it('detects five formats and keeps the mask as typed', () => {
85
+ expect(inline('(16) 99999-0000')).toBe(copyable('phone', '(16) 99999-0000'))
86
+ expect(inline('+55 16 99999-0000')).toBe(copyable('phone', '+55 16 99999-0000'))
87
+ expect(inline('16 99999-0000')).toBe(copyable('phone', '16 99999-0000'))
88
+ expect(inline('(16) 3333-4444')).toBe(copyable('phone', '(16) 3333-4444'))
89
+ expect(inline('+1 415 555 2671')).toBe(copyable('phone', '+1 415 555 2671'))
90
+ })
91
+
92
+ it('does not take bare 10 or 11 digits for a phone', () => {
93
+ expect(inline('pedido 1234567890')).toBe('pedido 1234567890')
94
+ expect(inline('16999990000')).toBe('16999990000')
95
+ expect(inline('1633334444')).toBe('1633334444')
96
+ })
97
+
98
+ it('accepts a bare number only with +55, a bracketed DDD or a separator after the DDD', () => {
99
+ expect(inline('+5516999990000')).toBe(copyable('phone', '+5516999990000'))
100
+ expect(inline('(16)999990000')).toBe(copyable('phone', '(16)999990000'))
101
+ expect(inline('16-999990000')).toBe(copyable('phone', '16-999990000'))
102
+ })
103
+
104
+ it('keeps the final dot outside', () => {
105
+ expect(inline('ligue (16) 99999-0000.')).toBe(`ligue ${copyable('phone', '(16) 99999-0000')}.`)
106
+ })
107
+ })
108
+
109
+ describe('copyable - e-mail', () => {
110
+ it('detects e-mail addresses', () => {
111
+ expect(inline('fale a@b.co.')).toBe(`fale ${copyable('email', 'a@b.co')}.`)
112
+ expect(inline('ana.souza+x@mail.example.com.br')).toBe(copyable('email', 'ana.souza+x@mail.example.com.br'))
113
+ })
114
+
115
+ it('ignores malformed addresses', () => {
116
+ expect(inline('@ana')).toBe('@ana')
117
+ expect(inline('a@b')).toBe('a@b')
118
+ expect(inline('a@.co')).toBe('a@.co')
119
+ })
120
+ })
121
+
122
+ describe('copyable - false positives avoided', () => {
123
+ it('does not highlight money, dates, short numbers or labels', () => {
124
+ expect(inline('R$ 1.234,56')).toBe('R$ 1.234,56')
125
+ expect(inline('10/10/2026')).toBe('10/10/2026')
126
+ expect(inline('2026-10-01')).toBe('2026-10-01')
127
+ expect(inline('12345')).toBe('12345')
128
+ expect(inline('NF 4521')).toBe('NF 4521')
129
+ expect(inline('total 1234567')).toBe('total 1234567')
130
+ })
131
+
132
+ it('does not detect inside inline code or inside a URL', () => {
133
+ expect(inline('`529.982.247-25`')).toBe('⟦c:529.982.247-25⟧')
134
+ expect(inline('`a@b.co`')).toBe('⟦c:a@b.co⟧')
135
+ expect(inline('https://a.com/52998224725')).toBe('⟦l:https://a.com/52998224725→https://a.com/52998224725⟧')
136
+ })
137
+
138
+ it('lets a mark wrap a token', () => {
139
+ expect(inline('*529.982.247-25*')).toBe(`⟦s:${copyable('cpf', '529.982.247-25')}⟧`)
140
+ })
141
+ })
@@ -0,0 +1,76 @@
1
+ import { isValidCnpj, isValidCpf } from './participantDocuments'
2
+ import type { CopyableKind, InlineAtom } from './participantMessageFormat.types'
3
+
4
+ type Scanner = { readonly copyKind: CopyableKind; readonly pattern: RegExp; readonly isValid: (raw: string) => boolean }
5
+
6
+ const DOCUMENT_SEPARATORS = /[.\-/\s]/g
7
+ const ACCESS_KEY_PATTERN = /\d{44}|\d{4}(?: \d{4}){10}/gu
8
+ const CNPJ_PATTERN = /[A-Z0-9]{2}\.[A-Z0-9]{3}\.[A-Z0-9]{3}\/[A-Z0-9]{4}-\d{2}|[A-Z0-9]{12}\d{2}/gu
9
+ const CPF_PATTERN = /\d{3}\.\d{3}\.\d{3}-\d{2}|\d{11}/gu
10
+ const BRAZIL_PHONE_PATTERN = /(?:\+?55[\s.-]?)?(?:\([1-9][1-9]\)|[1-9][1-9])[\s.-]?(?:9\d{4}|[2-5]\d{3})[\s.-]?\d{4}/gu
11
+ const INTERNATIONAL_PHONE_PATTERN = /\+\d{1,3}(?:[\s.-]?\d{2,5}){2,4}/gu
12
+ const EMAIL_LOCAL = /^[\p{L}\p{N}._%+-]+$/u
13
+ const EMAIL_DOMAIN = /^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}$/u
14
+
15
+ function bare(raw: string): string {
16
+ return raw.replace(DOCUMENT_SEPARATORS, '')
17
+ }
18
+
19
+ function isWordCharacter(character: string | undefined): boolean {
20
+ return character !== undefined && /[\p{L}\p{N}]/u.test(character)
21
+ }
22
+
23
+ function hasPhoneDigitCount(raw: string): boolean {
24
+ const count = raw.replace(/\D/g, '').length
25
+ return count >= 10 && count <= 15
26
+ }
27
+
28
+ /** Bare 10/11 digits are order numbers as often as phones: demand +country, a bracketed DDD or a separator after it. */
29
+ function isFormattedBrazilPhone(raw: string): boolean {
30
+ return /^\+|\(|^\d{2}[\s.-]/u.test(raw) && hasPhoneDigitCount(raw)
31
+ }
32
+
33
+ const SCANNERS: readonly Scanner[] = [
34
+ { copyKind: 'accessKey', pattern: ACCESS_KEY_PATTERN, isValid: (raw) => !/^(\d)\1+$/.test(bare(raw)) },
35
+ { copyKind: 'cnpj', pattern: CNPJ_PATTERN, isValid: (raw) => isValidCnpj(bare(raw)) },
36
+ { copyKind: 'cpf', pattern: CPF_PATTERN, isValid: (raw) => isValidCpf(bare(raw)) },
37
+ { copyKind: 'phone', pattern: BRAZIL_PHONE_PATTERN, isValid: isFormattedBrazilPhone },
38
+ {
39
+ copyKind: 'phone',
40
+ pattern: INTERNATIONAL_PHONE_PATTERN,
41
+ isValid: (raw) => hasPhoneDigitCount(raw) && !raw.startsWith('+55'),
42
+ },
43
+ ]
44
+
45
+ function scan(text: string, scanner: Scanner): InlineAtom[] {
46
+ const atoms: InlineAtom[] = []
47
+ for (const match of text.matchAll(scanner.pattern)) {
48
+ const [value] = match
49
+ const start = match.index
50
+ const end = start + value.length
51
+ if (isWordCharacter(text[start - 1]) || isWordCharacter(text[end])) continue
52
+ if (!scanner.isValid(value)) continue
53
+ atoms.push({ start, end, node: { kind: 'copyable', copyKind: scanner.copyKind, value } })
54
+ }
55
+ return atoms
56
+ }
57
+
58
+ function findEmailAtoms(text: string): InlineAtom[] {
59
+ const atoms: InlineAtom[] = []
60
+ for (let at = text.indexOf('@'); at !== -1; at = text.indexOf('@', at + 1)) {
61
+ let start = at
62
+ while (start > 0 && /[\p{L}\p{N}._%+-]/u.test(text[start - 1] ?? '')) start -= 1
63
+ let end = at + 1
64
+ while (end < text.length && /[\p{L}\p{N}.-]/u.test(text[end] ?? '')) end += 1
65
+ while (end > at + 1 && /[.-]/.test(text[end - 1] ?? '')) end -= 1
66
+ const isValid = EMAIL_LOCAL.test(text.slice(start, at)) && EMAIL_DOMAIN.test(text.slice(at + 1, end))
67
+ if (isValid) {
68
+ atoms.push({ start, end, node: { kind: 'copyable', copyKind: 'email', value: text.slice(start, end) } })
69
+ }
70
+ }
71
+ return atoms
72
+ }
73
+
74
+ export function findCopyableAtoms(text: string): InlineAtom[] {
75
+ return [...SCANNERS.flatMap((scanner) => scan(text, scanner)), ...findEmailAtoms(text)]
76
+ }
@@ -0,0 +1,21 @@
1
+ import type { InlineNode } from './participantMessageFormat.types'
2
+
3
+ /** Compact notation to make the case tables readable: s=strong, e=em, d=del, c=code, l=link, t=text. */
4
+ export function show(nodes: readonly InlineNode[]): string {
5
+ return nodes.map(showNode).join('')
6
+ }
7
+
8
+ function showNode(node: InlineNode): string {
9
+ switch (node.kind) {
10
+ case 'text':
11
+ return node.value
12
+ case 'code':
13
+ return `⟦c:${node.value}⟧`
14
+ case 'copyable':
15
+ return `⟦k:${node.copyKind}:${node.value}⟧`
16
+ case 'link':
17
+ return `⟦l:${node.label}→${node.href}⟧`
18
+ default:
19
+ return `⟦${node.kind[0]}:${show(node.children)}⟧`
20
+ }
21
+ }
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { MAX_FORMATTED_LENGTH, parseParticipantMessage } from './participantMessageFormat'
4
+ import { show } from './participantMessageFormat.test-helper'
5
+ import type { FormatBlock } from './participantMessageFormat.types'
6
+
7
+ function showBlock(block: FormatBlock): string {
8
+ if (block.kind === 'paragraph') return `P[${show(block.children)}]`
9
+ if (block.kind === 'pre') return `PRE[${block.value}]`
10
+ if (block.kind === 'quote') return `Q[${show(block.children)}]`
11
+ const tag = block.ordered ? `OL${block.start}` : 'UL'
12
+ return `${tag}[${block.items.map(show).join('|')}]`
13
+ }
14
+
15
+ function blocks(text: string | null | undefined): string {
16
+ return parseParticipantMessage(text).map(showBlock).join(' ')
17
+ }
18
+
19
+ describe('parseParticipantMessage - input', () => {
20
+ it('returns no blocks for empty, blank or missing input', () => {
21
+ expect(blocks('')).toBe('')
22
+ expect(blocks(' ')).toBe('')
23
+ expect(blocks('\n \n')).toBe('')
24
+ expect(blocks(null)).toBe('')
25
+ expect(blocks(undefined)).toBe('')
26
+ })
27
+
28
+ it('keeps plain text as one paragraph with one text node', () => {
29
+ expect(parseParticipantMessage('Olá, tudo bem?')).toEqual([
30
+ { kind: 'paragraph', children: [{ kind: 'text', value: 'Olá, tudo bem?' }] },
31
+ ])
32
+ })
33
+
34
+ it('preserves typed line breaks, including blank lines', () => {
35
+ expect(blocks('a\nb')).toBe('P[a\nb]')
36
+ expect(blocks('a\n\nb')).toBe('P[a\n\nb]')
37
+ expect(blocks('a\r\nb')).toBe('P[a\nb]')
38
+ })
39
+
40
+ it('formats each line on its own: marks never cross a line break', () => {
41
+ expect(blocks('*a\nb*')).toBe('P[*a\nb*]')
42
+ expect(blocks('*a*\n_b_')).toBe('P[⟦s:a⟧\n⟦e:b⟧]')
43
+ })
44
+ })
45
+
46
+ describe('parseParticipantMessage - size limit', () => {
47
+ it('formats up to the limit', () => {
48
+ const text = `*a*${' '.repeat(MAX_FORMATTED_LENGTH - 3)}`
49
+ expect(text).toHaveLength(MAX_FORMATTED_LENGTH)
50
+ expect(blocks(text).startsWith('P[⟦s:a⟧')).toBe(true)
51
+ })
52
+
53
+ it('treats anything above the limit as one plain text node', () => {
54
+ const text = `*a* https://a.com\n> b\n${'x'.repeat(MAX_FORMATTED_LENGTH)}`
55
+ expect(parseParticipantMessage(text)).toEqual([{ kind: 'paragraph', children: [{ kind: 'text', value: text }] }])
56
+ const justOver = `*a*${' '.repeat(MAX_FORMATTED_LENGTH - 2)}`
57
+ expect(justOver).toHaveLength(MAX_FORMATTED_LENGTH + 1)
58
+ expect(parseParticipantMessage(justOver)).toHaveLength(1)
59
+ expect(blocks(justOver)).not.toContain('⟦s:')
60
+ })
61
+ })
62
+
63
+ describe('parseParticipantMessage - code blocks', () => {
64
+ it('parses a multi-line block without a stray blank line', () => {
65
+ expect(blocks('```\nconst a = 1\nconst b = 2\n```')).toBe('PRE[const a = 1\nconst b = 2]')
66
+ })
67
+
68
+ it('parses a single-line block and one that shares the fence lines', () => {
69
+ expect(blocks('```x *y*```')).toBe('PRE[x *y*]')
70
+ expect(blocks('```a\nb```')).toBe('PRE[a\nb]')
71
+ expect(blocks('```js\nconst a = 1\n```')).toBe('PRE[const a = 1]')
72
+ expect(blocks('```two words\nx\n```')).toBe('PRE[two words\nx]')
73
+ })
74
+
75
+ it('keeps inner blank lines and never formats inside', () => {
76
+ expect(blocks('```\n*a*\n\n_b_ https://a.com 529.982.247-25\n```')).toBe(
77
+ 'PRE[*a*\n\n_b_ https://a.com 529.982.247-25]',
78
+ )
79
+ })
80
+
81
+ it('keeps text around the block as paragraphs', () => {
82
+ expect(blocks('antes\n```\nx\n```\ndepois')).toBe('P[antes] PRE[x] P[depois]')
83
+ })
84
+
85
+ it('treats an unclosed fence as plain text', () => {
86
+ expect(blocks('```\nabc')).toBe('P[```\nabc]')
87
+ })
88
+ })
89
+
90
+ describe('parseParticipantMessage - quotes', () => {
91
+ it('turns consecutive quote lines into one block', () => {
92
+ expect(blocks('> a\n> b')).toBe('Q[a\nb]')
93
+ expect(blocks('> a\n> b\nc')).toBe('Q[a\nb] P[c]')
94
+ })
95
+
96
+ it('formats inline marks inside the quote', () => {
97
+ expect(blocks('> *a* e _b_')).toBe('Q[⟦s:a⟧ e ⟦e:b⟧]')
98
+ })
99
+
100
+ it('needs the space after the marker and the marker at the start of the line', () => {
101
+ expect(blocks('>a')).toBe('P[>a]')
102
+ expect(blocks('a > b')).toBe('P[a > b]')
103
+ })
104
+
105
+ it('splits two quotes separated by a normal line', () => {
106
+ expect(blocks('> a\nx\n> b')).toBe('Q[a] P[x] Q[b]')
107
+ })
108
+ })
109
+
110
+ describe('parseParticipantMessage - lists', () => {
111
+ it('parses bullet lists with the three markers', () => {
112
+ expect(blocks('- a\n- b')).toBe('UL[a|b]')
113
+ expect(blocks('* a\n* b')).toBe('UL[a|b]')
114
+ expect(blocks('• a\n• b')).toBe('UL[a|b]')
115
+ })
116
+
117
+ it('requires the space after the marker', () => {
118
+ expect(blocks('-a')).toBe('P[-a]')
119
+ expect(blocks('*a*')).toBe('P[⟦s:a⟧]')
120
+ expect(blocks('•a')).toBe('P[•a]')
121
+ expect(blocks('- ')).toBe('P[- ]')
122
+ })
123
+
124
+ it('parses numbered lists with dot or parenthesis and keeps the first number', () => {
125
+ expect(blocks('1. a\n2. b')).toBe('OL1[a|b]')
126
+ expect(blocks('1) a\n2) b')).toBe('OL1[a|b]')
127
+ expect(blocks('3. a\n4. b')).toBe('OL3[a|b]')
128
+ expect(blocks('1.a')).toBe('P[1.a]')
129
+ })
130
+
131
+ it('formats inline marks inside items', () => {
132
+ expect(blocks('- *a*\n- _b_ https://a.com')).toBe('UL[⟦s:a⟧|⟦e:b⟧ ⟦l:https://a.com→https://a.com⟧]')
133
+ })
134
+
135
+ it('splits different list kinds and keeps surrounding text', () => {
136
+ expect(blocks('- a\n1. b')).toBe('UL[a] OL1[b]')
137
+ expect(blocks('título\n- a\n- b\nfim')).toBe('P[título] UL[a|b] P[fim]')
138
+ })
139
+
140
+ it('does not take a sentence with a number as a list', () => {
141
+ expect(blocks('2 * 3 * 4')).toBe('P[2 * 3 * 4]')
142
+ expect(blocks('2026. foi bom')).toBe('P[2026. foi bom]')
143
+ })
144
+ })
145
+
146
+ describe('parseParticipantMessage - emoji', () => {
147
+ it('does not touch ZWJ sequences, flags, skin tones or variation selectors', () => {
148
+ const text = '👨‍👩‍👧 🇧🇷 👍🏽 ‼️'
149
+ expect(parseParticipantMessage(text)).toEqual([{ kind: 'paragraph', children: [{ kind: 'text', value: text }] }])
150
+ expect(blocks('- 👨‍👩‍👧\n- 🇧🇷')).toBe('UL[👨‍👩‍👧|🇧🇷]')
151
+ })
152
+ })
153
+
154
+ describe('parseParticipantMessage - pathological input', () => {
155
+ const CASES: readonly (readonly [string, string])[] = [
156
+ ['alternating marks', '*_'.repeat(4000)],
157
+ ['open brackets', '['.repeat(8000)],
158
+ ['unmatched openers', '*a '.repeat(2666)],
159
+ ['backticks', '`'.repeat(8000)],
160
+ ['mixed marks', '~_*'.repeat(2666)],
161
+ ['at signs', 'a@'.repeat(4000)],
162
+ ['schemes', 'http://'.repeat(1100)],
163
+ ['digits', '1'.repeat(8000)],
164
+ ['many lines', '> *a\n- _b\n'.repeat(800)],
165
+ ['fences', '```\n'.repeat(1600)],
166
+ ]
167
+
168
+ it('parses the worst cases under 50ms each', () => {
169
+ for (const [name, input] of CASES) {
170
+ parseParticipantMessage(input.slice(0, 200))
171
+ const startedAt = performance.now()
172
+ parseParticipantMessage(input.slice(0, 8000))
173
+ const elapsed = performance.now() - startedAt
174
+ expect({ name, fast: elapsed < 50 }).toEqual({ name, fast: true })
175
+ }
176
+ })
177
+ })
@@ -0,0 +1,135 @@
1
+ import type { FormatBlock, InlineNode } from './participantMessageFormat.types'
2
+ import { parseInline } from './participantMessageInline'
3
+
4
+ /** Above this size the text is shown as typed: formatting is a courtesy, not a cost. */
5
+ export const MAX_FORMATTED_LENGTH = 8000
6
+
7
+ const FENCE = '```'
8
+ const LANGUAGE_LABEL = /^[a-z][a-z0-9+#.-]{0,19}$/i
9
+ const QUOTE_PREFIX = /^> /
10
+ const BULLET_PREFIX = /^[-*•] +(?=\S)/
11
+ const ORDERED_PREFIX = /^(\d{1,3})[.)] +(?=\S)/
12
+
13
+ type ListBlock = Extract<FormatBlock, { kind: 'list' }>
14
+
15
+ function joinLines(lines: readonly string[]): InlineNode[] {
16
+ const nodes: InlineNode[] = []
17
+ lines.forEach((line, index) => {
18
+ if (index > 0) nodes.push({ kind: 'text', value: '\n' })
19
+ nodes.push(...parseInline(line))
20
+ })
21
+ return mergeText(nodes)
22
+ }
23
+
24
+ function mergeText(nodes: readonly InlineNode[]): InlineNode[] {
25
+ const merged: InlineNode[] = []
26
+ for (const node of nodes) {
27
+ const previous = merged[merged.length - 1]
28
+ if (node.kind === 'text' && previous?.kind === 'text') {
29
+ merged[merged.length - 1] = { kind: 'text', value: previous.value + node.value }
30
+ } else {
31
+ merged.push(node)
32
+ }
33
+ }
34
+ return merged
35
+ }
36
+
37
+ function plainParagraph(text: string): FormatBlock[] {
38
+ return [{ kind: 'paragraph', children: [{ kind: 'text', value: text }] }]
39
+ }
40
+
41
+ type Fence = { readonly value: string; readonly nextIndex: number }
42
+
43
+ function readFence(lines: readonly string[], index: number): Fence | undefined {
44
+ const first = (lines[index] ?? '').trimStart()
45
+ if (!first.startsWith(FENCE)) return undefined
46
+ const inner = first.slice(FENCE.length)
47
+ if (inner.trimEnd().endsWith(FENCE) && inner.trim() !== FENCE) {
48
+ return { value: inner.trimEnd().slice(0, -FENCE.length), nextIndex: index + 1 }
49
+ }
50
+ const hasLabel = LANGUAGE_LABEL.test(inner.trim())
51
+ const body: string[] = inner === '' || hasLabel ? [] : [inner]
52
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
53
+ const line = (lines[cursor] ?? '').trimEnd()
54
+ if (!line.endsWith(FENCE)) {
55
+ body.push(line)
56
+ continue
57
+ }
58
+ const last = line.slice(0, -FENCE.length)
59
+ if (last !== '') body.push(last)
60
+ if (last !== '' && hasLabel && body.length === 1) body.unshift(inner)
61
+ return { value: body.join('\n'), nextIndex: cursor + 1 }
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ function startsSpecialBlock(line: string): boolean {
67
+ const trimmed = line.trimStart()
68
+ return trimmed.startsWith(FENCE) || QUOTE_PREFIX.test(line) || BULLET_PREFIX.test(line) || ORDERED_PREFIX.test(line)
69
+ }
70
+
71
+ function readQuote(lines: readonly string[], index: number): { block: FormatBlock; nextIndex: number } {
72
+ const quoted: string[] = []
73
+ let cursor = index
74
+ while (cursor < lines.length && QUOTE_PREFIX.test(lines[cursor] ?? '')) {
75
+ quoted.push((lines[cursor] ?? '').slice(2))
76
+ cursor += 1
77
+ }
78
+ return { block: { kind: 'quote', children: joinLines(quoted) }, nextIndex: cursor }
79
+ }
80
+
81
+ function readList(lines: readonly string[], index: number): { block: ListBlock; nextIndex: number } {
82
+ const ordered = ORDERED_PREFIX.test(lines[index] ?? '')
83
+ const prefix = ordered ? ORDERED_PREFIX : BULLET_PREFIX
84
+ const start = ordered ? Number(ORDERED_PREFIX.exec(lines[index] ?? '')?.[1] ?? 1) : 1
85
+ const items: InlineNode[][] = []
86
+ let cursor = index
87
+ while (cursor < lines.length && prefix.test(lines[cursor] ?? '')) {
88
+ items.push(parseInline((lines[cursor] ?? '').replace(prefix, '')))
89
+ cursor += 1
90
+ }
91
+ return { block: { kind: 'list', ordered, start, items }, nextIndex: cursor }
92
+ }
93
+
94
+ function readParagraph(lines: readonly string[], index: number): { block: FormatBlock; nextIndex: number } {
95
+ const collected: string[] = [lines[index] ?? '']
96
+ let cursor = index + 1
97
+ while (cursor < lines.length && !startsSpecialBlock(lines[cursor] ?? '')) {
98
+ collected.push(lines[cursor] ?? '')
99
+ cursor += 1
100
+ }
101
+ return { block: { kind: 'paragraph', children: joinLines(collected) }, nextIndex: cursor }
102
+ }
103
+
104
+ function readBlock(
105
+ lines: readonly string[],
106
+ index: number,
107
+ canFence: boolean,
108
+ ): { block: FormatBlock; nextIndex: number } {
109
+ const line = lines[index] ?? ''
110
+ const fence = canFence ? readFence(lines, index) : undefined
111
+ if (fence) return { block: { kind: 'pre', value: fence.value }, nextIndex: fence.nextIndex }
112
+ if (QUOTE_PREFIX.test(line)) return readQuote(lines, index)
113
+ if (BULLET_PREFIX.test(line) || ORDERED_PREFIX.test(line)) return readList(lines, index)
114
+ return readParagraph(lines, index)
115
+ }
116
+
117
+ export function parseParticipantMessage(text: string | null | undefined): readonly FormatBlock[] {
118
+ if (typeof text !== 'string' || text.trim() === '') return []
119
+ if (text.length > MAX_FORMATTED_LENGTH) return plainParagraph(text)
120
+ const lines = text.split(/\r\n?|\n/)
121
+ const blocks: FormatBlock[] = []
122
+ let canFence = true
123
+ let index = 0
124
+ while (index < lines.length) {
125
+ const { block, nextIndex } = readBlock(lines, index, canFence)
126
+ if (block.kind === 'paragraph' && startsFenceLine(lines[index])) canFence = false
127
+ blocks.push(block)
128
+ index = nextIndex
129
+ }
130
+ return blocks
131
+ }
132
+
133
+ function startsFenceLine(line: string | undefined): boolean {
134
+ return (line ?? '').trimStart().startsWith(FENCE)
135
+ }
@@ -0,0 +1,22 @@
1
+ export type CopyableKind = 'phone' | 'email' | 'cpf' | 'cnpj' | 'accessKey'
2
+
3
+ export type InlineNode =
4
+ | { readonly kind: 'text'; readonly value: string }
5
+ | { readonly kind: 'strong' | 'em' | 'del'; readonly children: readonly InlineNode[] }
6
+ | { readonly kind: 'code'; readonly value: string }
7
+ | { readonly kind: 'link'; readonly href: string; readonly label: string }
8
+ | { readonly kind: 'copyable'; readonly copyKind: CopyableKind; readonly value: string }
9
+
10
+ export type FormatBlock =
11
+ | { readonly kind: 'paragraph'; readonly children: readonly InlineNode[] }
12
+ | { readonly kind: 'pre'; readonly value: string }
13
+ | { readonly kind: 'quote'; readonly children: readonly InlineNode[] }
14
+ | {
15
+ readonly kind: 'list'
16
+ readonly ordered: boolean
17
+ readonly start: number
18
+ readonly items: readonly (readonly InlineNode[])[]
19
+ }
20
+
21
+ /** A span of the line that is atomic: nothing inside it is formatted. */
22
+ export type InlineAtom = { readonly start: number; readonly end: number; readonly node: InlineNode }