@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.
Files changed (85) 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 +583 -19
  11. package/package.json +3 -3
  12. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  13. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  14. package/src/participant/ParticipantBackButton.tsx +14 -0
  15. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  16. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  17. package/src/participant/ParticipantComposer.tsx +8 -40
  18. package/src/participant/ParticipantComposerControls.tsx +83 -0
  19. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  20. package/src/participant/ParticipantConversations.tsx +7 -0
  21. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  22. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  23. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  24. package/src/participant/ParticipantDayDivider.tsx +18 -0
  25. package/src/participant/ParticipantInbox.tsx +3 -1
  26. package/src/participant/ParticipantInboxRow.tsx +6 -4
  27. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  28. package/src/participant/ParticipantMessageBubble.tsx +25 -81
  29. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  30. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  31. package/src/participant/ParticipantMessageText.tsx +52 -0
  32. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  33. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  34. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  35. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  36. package/src/participant/ParticipantThread.test.tsx +37 -0
  37. package/src/participant/ParticipantThread.tsx +45 -95
  38. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  39. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  40. package/src/participant/ParticipantThreadHeader.tsx +73 -0
  41. package/src/participant/ParticipantThreadScreen.tsx +24 -10
  42. package/src/participant/ParticipantTimeline.tsx +103 -0
  43. package/src/participant/index.ts +1 -0
  44. package/src/participant/participantAutoGrow.test.ts +43 -0
  45. package/src/participant/participantAutoGrow.ts +12 -0
  46. package/src/participant/participantAvatar.test.ts +74 -0
  47. package/src/participant/participantAvatar.ts +48 -0
  48. package/src/participant/participantBubbleTail.test.tsx +107 -0
  49. package/src/participant/participantChatDesign.test.tsx +198 -0
  50. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  51. package/src/participant/participantCopyAnnouncement.ts +37 -0
  52. package/src/participant/participantCss.test-helper.ts +34 -0
  53. package/src/participant/participantDay.test.ts +22 -0
  54. package/src/participant/participantDay.ts +8 -0
  55. package/src/participant/participantDocuments.ts +45 -0
  56. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  57. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  58. package/src/participant/participantLabels.ts +11 -0
  59. package/src/participant/participantMessageAtoms.ts +53 -0
  60. package/src/participant/participantMessageCopyables.test.ts +141 -0
  61. package/src/participant/participantMessageCopyables.ts +76 -0
  62. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  63. package/src/participant/participantMessageFormat.test.ts +177 -0
  64. package/src/participant/participantMessageFormat.ts +135 -0
  65. package/src/participant/participantMessageFormat.types.ts +22 -0
  66. package/src/participant/participantMessageInline.test.ts +82 -0
  67. package/src/participant/participantMessageInline.ts +99 -0
  68. package/src/participant/participantMessageLinks.test.ts +100 -0
  69. package/src/participant/participantMessageLinks.ts +54 -0
  70. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  71. package/src/participant/participantScrollFocus.test.ts +22 -0
  72. package/src/participant/participantScrollFocus.ts +10 -0
  73. package/src/participant/participantScrollView.test.ts +74 -0
  74. package/src/participant/participantScrollView.ts +38 -0
  75. package/src/participant/participantScrollbars.test.ts +156 -0
  76. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  77. package/src/participant/participantSubjectIcon.ts +26 -0
  78. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  79. package/src/participant/useParticipantThreadScroll.ts +10 -2
  80. package/src/participant/useRevealActiveChip.ts +20 -0
  81. package/src/participant/useScrollAwayState.ts +24 -0
  82. package/src/participant/useStickToBottom.ts +16 -2
  83. package/src/styles.css +137 -21
  84. package/dist/chunk-MRVFFJEH.js +0 -64
  85. 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
+ })