@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.
- package/README.md +126 -19
- package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
- package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
- package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
- package/dist/flows/index.js +3 -5
- package/dist/index.js +834 -764
- package/dist/participant/index.d.ts +18 -1
- package/dist/participant/index.js +1188 -313
- package/dist/preview/index.js +2 -3
- package/dist/styles.css +591 -19
- package/package.json +1 -1
- package/src/cssBlocks.test-helper.ts +31 -0
- package/src/legacyStyles.golden.test.ts +2 -31
- package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
- package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
- package/src/participant/ParticipantBackButton.tsx +14 -0
- package/src/participant/ParticipantBubbleStatus.tsx +67 -0
- package/src/participant/ParticipantChannelBadges.tsx +13 -4
- package/src/participant/ParticipantComposer.tsx +8 -40
- package/src/participant/ParticipantComposerControls.tsx +83 -0
- package/src/participant/ParticipantConversationAvatar.tsx +13 -0
- package/src/participant/ParticipantConversations.tsx +7 -0
- package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
- package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
- package/src/participant/ParticipantCopyableToken.tsx +51 -0
- package/src/participant/ParticipantDayDivider.tsx +18 -0
- package/src/participant/ParticipantInbox.tsx +3 -1
- package/src/participant/ParticipantInboxRow.tsx +6 -4
- package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
- package/src/participant/ParticipantMessageBubble.tsx +25 -81
- package/src/participant/ParticipantMessageNodes.tsx +54 -0
- package/src/participant/ParticipantMessageText.test.tsx +106 -0
- package/src/participant/ParticipantMessageText.tsx +52 -0
- package/src/participant/ParticipantProtocolBadge.tsx +15 -7
- package/src/participant/ParticipantProtocolView.test.tsx +4 -6
- package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
- package/src/participant/ParticipantScrollToLatest.tsx +40 -0
- package/src/participant/ParticipantThread.test.tsx +37 -0
- package/src/participant/ParticipantThread.tsx +45 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +73 -0
- package/src/participant/ParticipantThreadScreen.tsx +24 -10
- package/src/participant/ParticipantTimeline.tsx +103 -0
- package/src/participant/index.ts +1 -0
- package/src/participant/participantAutoGrow.test.ts +43 -0
- package/src/participant/participantAutoGrow.ts +12 -0
- package/src/participant/participantAvatar.test.ts +74 -0
- package/src/participant/participantAvatar.ts +48 -0
- package/src/participant/participantBubbleTail.test.tsx +107 -0
- package/src/participant/participantChatDesign.test.tsx +198 -0
- package/src/participant/participantCopyAnnouncement.test.ts +52 -0
- package/src/participant/participantCopyAnnouncement.ts +37 -0
- package/src/participant/participantCss.test-helper.ts +34 -0
- package/src/participant/participantDay.test.ts +22 -0
- package/src/participant/participantDay.ts +8 -0
- package/src/participant/participantDocuments.ts +45 -0
- package/src/participant/participantHeaderA11y.test.tsx +85 -0
- package/src/participant/participantHeaderDesign.test.tsx +111 -0
- package/src/participant/participantLabels.ts +11 -0
- package/src/participant/participantMessageAtoms.ts +53 -0
- package/src/participant/participantMessageCopyables.test.ts +141 -0
- package/src/participant/participantMessageCopyables.ts +76 -0
- package/src/participant/participantMessageFormat.test-helper.ts +21 -0
- package/src/participant/participantMessageFormat.test.ts +177 -0
- package/src/participant/participantMessageFormat.ts +135 -0
- package/src/participant/participantMessageFormat.types.ts +22 -0
- package/src/participant/participantMessageInline.test.ts +82 -0
- package/src/participant/participantMessageInline.ts +99 -0
- package/src/participant/participantMessageLinks.test.ts +100 -0
- package/src/participant/participantMessageLinks.ts +54 -0
- package/src/participant/participantMessageTextStyle.test.ts +137 -0
- package/src/participant/participantScrollFocus.test.ts +22 -0
- package/src/participant/participantScrollFocus.ts +10 -0
- package/src/participant/participantScrollView.test.ts +74 -0
- package/src/participant/participantScrollView.ts +38 -0
- package/src/participant/participantScrollbars.test.ts +156 -0
- package/src/participant/participantSubjectIcon.test.tsx +27 -0
- package/src/participant/participantSubjectIcon.ts +26 -0
- package/src/participant/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThreadScroll.ts +10 -2
- package/src/participant/useRevealActiveChip.ts +20 -0
- package/src/participant/useScrollAwayState.ts +24 -0
- package/src/participant/useStickToBottom.ts +16 -2
- package/src/participantLayoutShrink.test.ts +82 -0
- package/src/styles.css +140 -21
- package/dist/chunk-MRVFFJEH.js +0 -64
- 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 }
|