@adatechnology/conversations-ui 0.6.1 → 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 +583 -19
- package/package.json +3 -3
- 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/styles.css +137 -21
- package/dist/chunk-MRVFFJEH.js +0 -64
- package/dist/chunk-OVFCLRQQ.js +0 -84
|
@@ -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 }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { show } from './participantMessageFormat.test-helper'
|
|
4
|
+
import { parseInline } from './participantMessageInline'
|
|
5
|
+
|
|
6
|
+
function inline(line: string): string {
|
|
7
|
+
return show(parseInline(line))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('parseInline - basic marks', () => {
|
|
11
|
+
it('formats bold, italic, strike and inline code', () => {
|
|
12
|
+
expect(inline('*a b*')).toBe('⟦s:a b⟧')
|
|
13
|
+
expect(inline('x *a* y')).toBe('x ⟦s:a⟧ y')
|
|
14
|
+
expect(inline('_a b_')).toBe('⟦e:a b⟧')
|
|
15
|
+
expect(inline('~a b~')).toBe('⟦d:a b⟧')
|
|
16
|
+
expect(inline('`a b`')).toBe('⟦c:a b⟧')
|
|
17
|
+
expect(inline('oi *tudo* _bem_ ~mal~ `ok`')).toBe('oi ⟦s:tudo⟧ ⟦e:bem⟧ ⟦d:mal⟧ ⟦c:ok⟧')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('formats marks that touch punctuation', () => {
|
|
21
|
+
expect(inline('(*a*)')).toBe('(⟦s:a⟧)')
|
|
22
|
+
expect(inline('*a*, _b_.')).toBe('⟦s:a⟧, ⟦e:b⟧.')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('treats triple backticks in the middle of a line as inline code', () => {
|
|
26
|
+
expect(inline('veja ```x y``` aqui')).toBe('veja ⟦c:x y⟧ aqui')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('parseInline - nesting', () => {
|
|
31
|
+
it('nests valid combinations in both orders', () => {
|
|
32
|
+
expect(inline('*_negrito itálico_*')).toBe('⟦s:⟦e:negrito itálico⟧⟧')
|
|
33
|
+
expect(inline('_*assim*_')).toBe('⟦e:⟦s:assim⟧⟧')
|
|
34
|
+
expect(inline('~*_x_*~')).toBe('⟦d:⟦s:⟦e:x⟧⟧⟧')
|
|
35
|
+
expect(inline('*a _b_ c*')).toBe('⟦s:a ⟦e:b⟧ c⟧')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not let crossing marks form invalid trees', () => {
|
|
39
|
+
expect(inline('*a _b* c_')).toBe('⟦s:a _b⟧ c_')
|
|
40
|
+
expect(inline('_a *b_ c*')).toBe('⟦e:a *b⟧ c*')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('keeps code inside marks and never formats inside code', () => {
|
|
44
|
+
expect(inline('*a `b` c*')).toBe('⟦s:a ⟦c:b⟧ c⟧')
|
|
45
|
+
expect(inline('`*a*`')).toBe('⟦c:*a*⟧')
|
|
46
|
+
expect(inline('`_a_ ~b~`')).toBe('⟦c:_a_ ~b~⟧')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('parseInline - loose markers', () => {
|
|
51
|
+
it('does not format when the marker is glued to whitespace', () => {
|
|
52
|
+
expect(inline('* a *')).toBe('* a *')
|
|
53
|
+
expect(inline('2 * 3 * 4')).toBe('2 * 3 * 4')
|
|
54
|
+
expect(inline('_ a _')).toBe('_ a _')
|
|
55
|
+
expect(inline('~ a ~')).toBe('~ a ~')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not format inside a word', () => {
|
|
59
|
+
expect(inline('snake_case_nome')).toBe('snake_case_nome')
|
|
60
|
+
expect(inline('a*b*c')).toBe('a*b*c')
|
|
61
|
+
expect(inline('2*3*4')).toBe('2*3*4')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('leaves unmatched and empty markers alone', () => {
|
|
65
|
+
expect(inline('*a')).toBe('*a')
|
|
66
|
+
expect(inline('a*')).toBe('a*')
|
|
67
|
+
expect(inline('**')).toBe('**')
|
|
68
|
+
expect(inline('__')).toBe('__')
|
|
69
|
+
expect(inline('``')).toBe('``')
|
|
70
|
+
expect(inline('`aberto')).toBe('`aberto')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('parseInline - emoji', () => {
|
|
75
|
+
it('keeps ZWJ sequences, flags, skin tones and variation selectors intact', () => {
|
|
76
|
+
expect(inline('*👨👩👧*')).toBe('⟦s:👨👩👧⟧')
|
|
77
|
+
expect(inline('🇧🇷 _oi_')).toBe('🇧🇷 ⟦e:oi⟧')
|
|
78
|
+
expect(inline('👍🏽*x*')).toBe('👍🏽⟦s:x⟧')
|
|
79
|
+
expect(inline('‼️ ~não~')).toBe('‼️ ⟦d:não⟧')
|
|
80
|
+
expect(inline('👨👩👧 🇧🇷 👍🏽 ‼️')).toBe('👨👩👧 🇧🇷 👍🏽 ‼️')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { findInlineAtoms } from './participantMessageAtoms'
|
|
2
|
+
import type { InlineAtom, InlineNode } from './participantMessageFormat.types'
|
|
3
|
+
|
|
4
|
+
type MarkKind = 'strong' | 'em' | 'del'
|
|
5
|
+
|
|
6
|
+
const MARKS: Readonly<Record<string, MarkKind>> = { '*': 'strong', _: 'em', '~': 'del' }
|
|
7
|
+
|
|
8
|
+
type LineContext = {
|
|
9
|
+
readonly text: string
|
|
10
|
+
readonly atomAt: ReadonlyMap<number, InlineAtom>
|
|
11
|
+
readonly isMasked: readonly boolean[]
|
|
12
|
+
readonly closers: Readonly<Record<string, readonly number[]>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isWord(character: string | undefined): boolean {
|
|
16
|
+
return character !== undefined && /[\p{L}\p{N}]/u.test(character)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isSpace(character: string | undefined): boolean {
|
|
20
|
+
return character === undefined || /\s/u.test(character)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function characterBefore(text: string, index: number): string | undefined {
|
|
24
|
+
if (index <= 0) return undefined
|
|
25
|
+
const code = text.charCodeAt(index - 1)
|
|
26
|
+
const isLowSurrogate = code >= 0xdc00 && code <= 0xdfff
|
|
27
|
+
if (isLowSurrogate && index >= 2) return String.fromCodePoint(text.codePointAt(index - 2) ?? code)
|
|
28
|
+
return text.charAt(index - 1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildContext(text: string): LineContext {
|
|
32
|
+
const atoms = findInlineAtoms(text)
|
|
33
|
+
const isMasked: boolean[] = new Array<boolean>(text.length).fill(false)
|
|
34
|
+
for (const atom of atoms) isMasked.fill(true, atom.start, atom.end)
|
|
35
|
+
const closers: Record<string, number[]> = { '*': [], _: [], '~': [] }
|
|
36
|
+
for (let index = 1; index < text.length; index += 1) {
|
|
37
|
+
const character = text.charAt(index)
|
|
38
|
+
const list = closers[character]
|
|
39
|
+
const isCloser = list !== undefined && !isMasked[index] && !isSpace(text[index - 1]) && !isWord(text[index + 1])
|
|
40
|
+
if (isCloser) list.push(index)
|
|
41
|
+
}
|
|
42
|
+
return { text, atomAt: new Map(atoms.map((atom) => [atom.start, atom])), isMasked, closers }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isOpener(context: LineContext, index: number): boolean {
|
|
46
|
+
const { text } = context
|
|
47
|
+
const next = text[index + 1]
|
|
48
|
+
if (context.isMasked[index] || next === undefined || isSpace(next) || next === text[index]) return false
|
|
49
|
+
return !isWord(characterBefore(text, index))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findCloser(context: LineContext, mark: string, from: number, limit: number): number | undefined {
|
|
53
|
+
const list = context.closers[mark] ?? []
|
|
54
|
+
let low = 0
|
|
55
|
+
let high = list.length
|
|
56
|
+
while (low < high) {
|
|
57
|
+
const middle = (low + high) >> 1
|
|
58
|
+
if ((list[middle] ?? 0) < from) low = middle + 1
|
|
59
|
+
else high = middle
|
|
60
|
+
}
|
|
61
|
+
const found = list[low]
|
|
62
|
+
return found !== undefined && found < limit ? found : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseRange(context: LineContext, start: number, end: number): InlineNode[] {
|
|
66
|
+
const nodes: InlineNode[] = []
|
|
67
|
+
let textStart = start
|
|
68
|
+
let index = start
|
|
69
|
+
const flush = (until: number): void => {
|
|
70
|
+
if (until > textStart) nodes.push({ kind: 'text', value: context.text.slice(textStart, until) })
|
|
71
|
+
}
|
|
72
|
+
while (index < end) {
|
|
73
|
+
const atom = context.atomAt.get(index)
|
|
74
|
+
const mark = MARKS[context.text.charAt(index)]
|
|
75
|
+
const closer =
|
|
76
|
+
mark !== undefined && !atom && isOpener(context, index)
|
|
77
|
+
? findCloser(context, context.text.charAt(index), index + 2, end)
|
|
78
|
+
: undefined
|
|
79
|
+
if (atom) {
|
|
80
|
+
flush(index)
|
|
81
|
+
nodes.push(atom.node)
|
|
82
|
+
index = atom.end
|
|
83
|
+
} else if (mark !== undefined && closer !== undefined) {
|
|
84
|
+
flush(index)
|
|
85
|
+
nodes.push({ kind: mark, children: parseRange(context, index + 1, closer) })
|
|
86
|
+
index = closer + 1
|
|
87
|
+
} else {
|
|
88
|
+
index += 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
textStart = index
|
|
92
|
+
}
|
|
93
|
+
flush(end)
|
|
94
|
+
return nodes
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseInline(line: string): InlineNode[] {
|
|
98
|
+
return parseRange(buildContext(line), 0, line.length)
|
|
99
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { show } from './participantMessageFormat.test-helper'
|
|
4
|
+
import { isAllowedLinkHref } from './participantMessageLinks'
|
|
5
|
+
import { parseInline } from './participantMessageInline'
|
|
6
|
+
|
|
7
|
+
function inline(line: string): string {
|
|
8
|
+
return show(parseInline(line))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('isAllowedLinkHref', () => {
|
|
12
|
+
it('accepts only http, https, mailto and tel', () => {
|
|
13
|
+
expect(isAllowedLinkHref('http://a.com')).toBe(true)
|
|
14
|
+
expect(isAllowedLinkHref('https://a.com/x?y=1')).toBe(true)
|
|
15
|
+
expect(isAllowedLinkHref('HTTPS://A.COM')).toBe(true)
|
|
16
|
+
expect(isAllowedLinkHref('mailto:a@b.co')).toBe(true)
|
|
17
|
+
expect(isAllowedLinkHref('tel:+5511987654321')).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('rejects every other protocol and non-URLs', () => {
|
|
21
|
+
expect(isAllowedLinkHref('javascript:alert(1)')).toBe(false)
|
|
22
|
+
expect(isAllowedLinkHref(' javascript:alert(1)')).toBe(false)
|
|
23
|
+
expect(isAllowedLinkHref('JaVaScRiPt:alert(1)')).toBe(false)
|
|
24
|
+
expect(isAllowedLinkHref('data:text/html,<b>x</b>')).toBe(false)
|
|
25
|
+
expect(isAllowedLinkHref('vbscript:x')).toBe(false)
|
|
26
|
+
expect(isAllowedLinkHref('file:///etc/passwd')).toBe(false)
|
|
27
|
+
expect(isAllowedLinkHref('ftp://a.com')).toBe(false)
|
|
28
|
+
expect(isAllowedLinkHref('//a.com')).toBe(false)
|
|
29
|
+
expect(isAllowedLinkHref('/relativo')).toBe(false)
|
|
30
|
+
expect(isAllowedLinkHref('')).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('autolink - http and https', () => {
|
|
35
|
+
it('links plain URLs and keeps the literal as the label', () => {
|
|
36
|
+
expect(inline('veja https://a.com/x agora')).toBe('veja ⟦l:https://a.com/x→https://a.com/x⟧ agora')
|
|
37
|
+
expect(inline('http://a.com')).toBe('⟦l:http://a.com→http://a.com⟧')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('keeps trailing punctuation outside the link', () => {
|
|
41
|
+
expect(inline('https://a.com.')).toBe('⟦l:https://a.com→https://a.com⟧.')
|
|
42
|
+
expect(inline('https://a.com,')).toBe('⟦l:https://a.com→https://a.com⟧,')
|
|
43
|
+
expect(inline('https://a.com;')).toBe('⟦l:https://a.com→https://a.com⟧;')
|
|
44
|
+
expect(inline('https://a.com:')).toBe('⟦l:https://a.com→https://a.com⟧:')
|
|
45
|
+
expect(inline('https://a.com!')).toBe('⟦l:https://a.com→https://a.com⟧!')
|
|
46
|
+
expect(inline('https://a.com?')).toBe('⟦l:https://a.com→https://a.com⟧?')
|
|
47
|
+
expect(inline('https://a.com/x...')).toBe('⟦l:https://a.com/x→https://a.com/x⟧...')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps balanced parentheses inside and drops the unbalanced one', () => {
|
|
51
|
+
expect(inline('(veja https://a.com/p)')).toBe('(veja ⟦l:https://a.com/p→https://a.com/p⟧)')
|
|
52
|
+
expect(inline('https://a.com/p_(x)')).toBe('⟦l:https://a.com/p_(x)→https://a.com/p_(x)⟧')
|
|
53
|
+
expect(inline('(https://a.com/p_(x))')).toBe('(⟦l:https://a.com/p_(x)→https://a.com/p_(x)⟧)')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps query strings and ampersands', () => {
|
|
57
|
+
expect(inline('https://a.com/?a=1&b=2')).toBe('⟦l:https://a.com/?a=1&b=2→https://a.com/?a=1&b=2⟧')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('accepts an uppercase scheme, normalising only the scheme of the href', () => {
|
|
61
|
+
expect(inline('HTTP://MAIUSCULO')).toBe('⟦l:HTTP://MAIUSCULO→http://MAIUSCULO⟧')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not format marks inside a URL, but a mark may wrap it', () => {
|
|
65
|
+
expect(inline('https://a.com/_x_/~y~/*z*')).toBe('⟦l:https://a.com/_x_/~y~/*z→https://a.com/_x_/~y~/*z⟧*')
|
|
66
|
+
expect(inline('*https://a.com*')).toBe('⟦s:⟦l:https://a.com→https://a.com⟧⟧')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('does not link a scheme with nothing after it, nor inside code', () => {
|
|
70
|
+
expect(inline('https://')).toBe('https://')
|
|
71
|
+
expect(inline('`https://a.com`')).toBe('⟦c:https://a.com⟧')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('autolink - unsafe protocols stay text', () => {
|
|
76
|
+
it('never links javascript, data or vbscript', () => {
|
|
77
|
+
expect(inline('javascript:alert(1)')).toBe('javascript:alert(1)')
|
|
78
|
+
expect(inline('data:text/html,<script>alert(1)</script>')).toBe('data:text/html,<script>alert(1)</script>')
|
|
79
|
+
expect(inline('vbscript:msgbox(1)')).toBe('vbscript:msgbox(1)')
|
|
80
|
+
expect(inline('clique: javascript://%0aalert(1)')).toBe('clique: javascript://%0aalert(1)')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('autolink - cost and userinfo', () => {
|
|
85
|
+
it('trims a long run of closing parentheses in linear time', () => {
|
|
86
|
+
const started = performance.now()
|
|
87
|
+
inline(`https://a${')'.repeat(7990)}`)
|
|
88
|
+
expect(performance.now() - started).toBeLessThan(20)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('still balances parentheses', () => {
|
|
92
|
+
expect(inline('https://a.com/x_(y))')).toBe('⟦l:https://a.com/x_(y)→https://a.com/x_(y)⟧)')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('leaves a link with credentials as text', () => {
|
|
96
|
+
expect(isAllowedLinkHref('https://user:pass@a.com')).toBe(false)
|
|
97
|
+
expect(isAllowedLinkHref('https://user@a.com')).toBe(false)
|
|
98
|
+
expect(inline('https://user:pass@a.com/x')).not.toContain('⟦l:')
|
|
99
|
+
})
|
|
100
|
+
})
|