@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,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
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { InlineAtom } from './participantMessageFormat.types'
|
|
2
|
+
|
|
3
|
+
const ALLOWED_LINK_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:', 'mailto:', 'tel:'])
|
|
4
|
+
const URL_PATTERN = /https?:\/\/[^\s<>`\u0000-\u001f]+/giu
|
|
5
|
+
const TRAILING_PUNCTUATION = '.,;:!?*_~\'"]}>'
|
|
6
|
+
|
|
7
|
+
export function isAllowedLinkHref(href: string): boolean {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(href)
|
|
10
|
+
return ALLOWED_LINK_PROTOCOLS.has(url.protocol) && url.username === '' && url.password === ''
|
|
11
|
+
} catch {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isAlphanumeric(character: string | undefined): boolean {
|
|
17
|
+
return character !== undefined && /[\p{L}\p{N}]/u.test(character)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function countOf(text: string, character: string): number {
|
|
21
|
+
let count = 0
|
|
22
|
+
for (const current of text) if (current === character) count += 1
|
|
23
|
+
return count
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function trimUrl(raw: string): string {
|
|
27
|
+
let openCount = countOf(raw, '(')
|
|
28
|
+
let closeCount = countOf(raw, ')')
|
|
29
|
+
let end = raw.length
|
|
30
|
+
while (end > 0) {
|
|
31
|
+
const last = raw.charAt(end - 1)
|
|
32
|
+
if (last === ')' && closeCount > openCount) closeCount -= 1
|
|
33
|
+
else if (!TRAILING_PUNCTUATION.includes(last)) break
|
|
34
|
+
end -= 1
|
|
35
|
+
}
|
|
36
|
+
return raw.slice(0, end)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toLinkAtom(text: string, start: number, raw: string): InlineAtom | undefined {
|
|
40
|
+
const label = trimUrl(raw)
|
|
41
|
+
if (/^https?:\/\/$/i.test(label) || isAlphanumeric(text[start - 1])) return undefined
|
|
42
|
+
const href = label.replace(/^https?/i, (scheme) => scheme.toLowerCase())
|
|
43
|
+
if (!isAllowedLinkHref(href)) return undefined
|
|
44
|
+
return { start, end: start + label.length, node: { kind: 'link', href, label } }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findLinkAtoms(text: string): InlineAtom[] {
|
|
48
|
+
const atoms: InlineAtom[] = []
|
|
49
|
+
for (const match of text.matchAll(URL_PATTERN)) {
|
|
50
|
+
const atom = toLinkAtom(text, match.index, match[0])
|
|
51
|
+
if (atom) atoms.push(atom)
|
|
52
|
+
}
|
|
53
|
+
return atoms
|
|
54
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { splitBlocks } from '../cssBlocks.test-helper'
|
|
6
|
+
import { PARTICIPANT_CSS, declarationsIn } from './participantCss.test-helper'
|
|
7
|
+
|
|
8
|
+
const TEXT_SELECTOR = /\.cv-p-(text|copyable)/
|
|
9
|
+
|
|
10
|
+
function collectBlocks(css: string): string[] {
|
|
11
|
+
return splitBlocks(css).flatMap(({ prelude, body }) => {
|
|
12
|
+
if (body === undefined) return []
|
|
13
|
+
if (prelude.startsWith('@media')) return collectBlocks(body)
|
|
14
|
+
return TEXT_SELECTOR.test(prelude) ? [`${prelude} {${body}}`] : []
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function forcedColorsBody(): string {
|
|
19
|
+
return splitBlocks(PARTICIPANT_CSS)
|
|
20
|
+
.filter(({ prelude }) => prelude === '@media (forced-colors: active)')
|
|
21
|
+
.map(({ body }) => body ?? '')
|
|
22
|
+
.join('\n')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TEXT_CSS = collectBlocks(PARTICIPANT_CSS).join('\n')
|
|
26
|
+
|
|
27
|
+
describe('participant message text - stylesheet', () => {
|
|
28
|
+
it('has rules for the text, the code, the block, the quote and the tokens', () => {
|
|
29
|
+
expect(collectBlocks(PARTICIPANT_CSS).length).toBeGreaterThan(12)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('uses no fixed colour and no url()', () => {
|
|
33
|
+
expect(TEXT_CSS).not.toMatch(/#[0-9a-f]{3,8}\b/i)
|
|
34
|
+
expect(TEXT_CSS).not.toMatch(/\b(rgb|rgba|hsl|hsla)\(/i)
|
|
35
|
+
expect(TEXT_CSS).not.toContain('url(')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('keeps the text selectable and the typography of the old text', () => {
|
|
39
|
+
const text = declarationsIn('.cv-p-text')
|
|
40
|
+
expect(text.get('user-select')).toBe('text')
|
|
41
|
+
expect(text.get('white-space')).toBe('pre-wrap')
|
|
42
|
+
expect(text.get('font-size')).toBe('14.2px')
|
|
43
|
+
expect(text.get('line-height')).toBe('19px')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('styles inline code and the block from the tokens', () => {
|
|
47
|
+
expect(declarationsIn('.cv-p-text__code').get('background')).toBe(
|
|
48
|
+
declarationsIn('.cv-p-text__pre').get('background'),
|
|
49
|
+
)
|
|
50
|
+
expect(declarationsIn('.cv-p-text__code').get('background')).toContain('color-mix(in srgb, var(--cv-p-i-border)')
|
|
51
|
+
expect(declarationsIn('.cv-p-text__code').get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
52
|
+
const pre = declarationsIn('.cv-p-text__pre')
|
|
53
|
+
expect(pre.get('overflow-x')).toBe('auto')
|
|
54
|
+
expect(pre.get('white-space')).toBe('pre')
|
|
55
|
+
expect(pre.get('border-inline-start')).toBe('2px solid var(--cv-p-i-accent)')
|
|
56
|
+
expect(pre.get('scrollbar-color')).toBe('var(--cv-p-i-scrollbar-thumb) transparent')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('styles the quote, the lists and the links from the tokens', () => {
|
|
60
|
+
const quote = declarationsIn('.cv-p-text__quote')
|
|
61
|
+
expect(quote.get('border-inline-start')).toBe('3px solid var(--cv-p-i-border)')
|
|
62
|
+
expect(quote.get('color')).toBe('var(--cv-p-i-text-muted)')
|
|
63
|
+
expect(declarationsIn('.cv-p-text__list').get('padding-inline-start')).toBe('1.25rem')
|
|
64
|
+
const link = declarationsIn('.cv-p-text__link')
|
|
65
|
+
expect(link.get('color')).toBe('var(--cv-p-i-accent)')
|
|
66
|
+
expect(link.get('text-decoration')).toBe('underline')
|
|
67
|
+
expect(link.get('overflow-wrap')).toBe('anywhere')
|
|
68
|
+
expect(declarationsIn('.cv-p-text__link:focus-visible').get('outline')).toBe('2px solid var(--cv-p-i-accent)')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('highlights tokens discreetly with the accent', () => {
|
|
72
|
+
const token = declarationsIn('.cv-p-copyable')
|
|
73
|
+
expect(token.get('background')).toBe('color-mix(in srgb, var(--cv-p-i-accent) 14%, transparent)')
|
|
74
|
+
expect(token.get('border-bottom')).toBe('1px dotted var(--cv-p-i-accent)')
|
|
75
|
+
expect(token.get('font-family')).toContain('monospace')
|
|
76
|
+
expect(token.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
77
|
+
expect(token.get('user-select')).toBe('text')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('keeps the tap target of tokens and the copy icon from invading neighbouring lines or the link text', () => {
|
|
81
|
+
expect(declarationsIn('.cv-p').get('--cv-p-i-touch')).toBe('2.75rem')
|
|
82
|
+
for (const selector of ['.cv-p-copyable::after', '.cv-p-text__link-copy::after']) {
|
|
83
|
+
expect(declarationsIn(selector).get('min-height')).toBe('1.5rem')
|
|
84
|
+
}
|
|
85
|
+
const icon = declarationsIn('.cv-p-text__link-copy::after')
|
|
86
|
+
expect(icon.get('inset-inline')).toBe('0 auto')
|
|
87
|
+
expect(icon.get('min-width')).toBe('var(--cv-p-i-touch)')
|
|
88
|
+
expect(icon.get('margin-inline-start')).toBeUndefined()
|
|
89
|
+
expect(declarationsIn('.cv-p-copyable').get('display')).toBe('inline')
|
|
90
|
+
expect(declarationsIn('.cv-p-copyable').get('position')).toBe('relative')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('gets borders instead of backgrounds under forced colors', () => {
|
|
94
|
+
const forced = forcedColorsBody()
|
|
95
|
+
expect(forced).toContain('.cv-p-copyable')
|
|
96
|
+
expect(forced).toContain('.cv-p-text__pre')
|
|
97
|
+
expect(declarationsIn('.cv-p-copyable', forced).get('border')).toBe('1px solid')
|
|
98
|
+
expect(declarationsIn('.cv-p-copyable', forced).get('background')).toBe('none')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('participant message text - hygiene', () => {
|
|
103
|
+
const FILES = [
|
|
104
|
+
'participantMessageFormat.ts',
|
|
105
|
+
'participantMessageInline.ts',
|
|
106
|
+
'participantMessageAtoms.ts',
|
|
107
|
+
'participantMessageLinks.ts',
|
|
108
|
+
'participantMessageCopyables.ts',
|
|
109
|
+
'participantDocuments.ts',
|
|
110
|
+
'ParticipantMessageText.tsx',
|
|
111
|
+
'ParticipantMessageNodes.tsx',
|
|
112
|
+
'ParticipantCopyableToken.tsx',
|
|
113
|
+
'ParticipantCopyAnnouncer.tsx',
|
|
114
|
+
'useParticipantCopyFeedback.ts',
|
|
115
|
+
] as const
|
|
116
|
+
const sources = FILES.map((file) => ({ file, source: readFileSync(join(import.meta.dir, file), 'utf8') }))
|
|
117
|
+
|
|
118
|
+
it('stays within 200 lines per file', () => {
|
|
119
|
+
for (const { file, source } of sources) {
|
|
120
|
+
expect({ file, ok: source.split('\n').length <= 200 }).toEqual({ file, ok: true })
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('never sets inner HTML, never uses any, never reuses the shared MessageText', () => {
|
|
125
|
+
for (const { source } of sources) {
|
|
126
|
+
expect(source).not.toMatch(/innerHTML|dangerouslySetInnerHTML|:\s*any\b|as any|MessageText\b.*from '\.\.\//)
|
|
127
|
+
expect(source).not.toContain('whatsapp-formatting')
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('has no Tailwind utilities and no vocabulary from a specific product', () => {
|
|
132
|
+
for (const { source } of sources) {
|
|
133
|
+
expect(source).not.toMatch(/className="[^"]*\b(flex|grid|p-\d|m-\d|text-(sm|xs|lg)|bg-\w+)\b/)
|
|
134
|
+
expect(source).not.toMatch(/\b(carga|entrega|frete|cte|nf-?e|transport\w*|motorista|viagem|trip|driver)\b/i)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { SCROLLER_SELECTOR, focusScrollerOf } from './participantScrollFocus'
|
|
4
|
+
|
|
5
|
+
describe('focusScrollerOf', () => {
|
|
6
|
+
it('moves focus to the scroller ancestor without scrolling it', () => {
|
|
7
|
+
const calls: unknown[] = []
|
|
8
|
+
const selectors: string[] = []
|
|
9
|
+
focusScrollerOf({
|
|
10
|
+
closest: (selector) => {
|
|
11
|
+
selectors.push(selector)
|
|
12
|
+
return { focus: (options) => void calls.push(options) }
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
expect(selectors).toEqual([SCROLLER_SELECTOR])
|
|
16
|
+
expect(calls).toEqual([{ preventScroll: true }])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('does nothing when there is no scroller', () => {
|
|
20
|
+
expect(() => focusScrollerOf({ closest: () => null })).not.toThrow()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type FocusableAncestorSource = {
|
|
2
|
+
readonly closest: (selector: string) => { readonly focus: (options: { preventScroll: boolean }) => void } | null
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const SCROLLER_SELECTOR = '.cv-p-thread__scroll'
|
|
6
|
+
|
|
7
|
+
/** The button unmounts on click; moving focus first keeps it from falling to the body. */
|
|
8
|
+
export function focusScrollerOf(button: FocusableAncestorSource): void {
|
|
9
|
+
button.closest(SCROLLER_SELECTOR)?.focus({ preventScroll: true })
|
|
10
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { resolveScrollToLatestView, scrollIntoViewOptions, scrollToLatestOptions } from './participantScrollView'
|
|
6
|
+
|
|
7
|
+
describe('resolveScrollToLatestView', () => {
|
|
8
|
+
it('is hidden while the reader is near the end', () => {
|
|
9
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: false, newMessagesCount: 0, hasMessages: true })).toEqual({
|
|
10
|
+
isVisible: false,
|
|
11
|
+
badge: undefined,
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('is hidden when there is nothing to scroll to', () => {
|
|
16
|
+
expect(
|
|
17
|
+
resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 0, hasMessages: false }).isVisible,
|
|
18
|
+
).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('is visible without a badge when away and nothing new arrived', () => {
|
|
22
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 0, hasMessages: true })).toEqual({
|
|
23
|
+
isVisible: true,
|
|
24
|
+
badge: undefined,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('carries the count of new incoming messages as the badge', () => {
|
|
29
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 3, hasMessages: true }).badge).toBe(
|
|
30
|
+
'3',
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('caps a large count', () => {
|
|
35
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 100, hasMessages: true }).badge).toBe(
|
|
36
|
+
'99+',
|
|
37
|
+
)
|
|
38
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 99, hasMessages: true }).badge).toBe(
|
|
39
|
+
'99',
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('scroll options', () => {
|
|
45
|
+
it('scrolls to the end of the list, smooth only without reduced motion', () => {
|
|
46
|
+
expect(scrollToLatestOptions({ scrollHeight: 900, isReducedMotion: false })).toEqual({
|
|
47
|
+
top: 900,
|
|
48
|
+
behavior: 'smooth',
|
|
49
|
+
})
|
|
50
|
+
expect(scrollToLatestOptions({ scrollHeight: 900, isReducedMotion: true })).toEqual({ top: 900, behavior: 'auto' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reveals the active chip with the nearest rule and no animation under reduced motion', () => {
|
|
54
|
+
expect(scrollIntoViewOptions(false)).toEqual({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
|
|
55
|
+
expect(scrollIntoViewOptions(true)).toEqual({ block: 'nearest', inline: 'nearest', behavior: 'auto' })
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('the near-the-end threshold has one owner', () => {
|
|
60
|
+
it('is declared only in participantScroll.ts', () => {
|
|
61
|
+
const sources = readdirSync(import.meta.dir).filter(
|
|
62
|
+
(name) => /\.tsx?$/.test(name) && !/\.test(-helper)?\.tsx?$/.test(name),
|
|
63
|
+
)
|
|
64
|
+
const declaring = sources.filter((name) =>
|
|
65
|
+
/NEAR_BOTTOM_THRESHOLD_PX\s*=/.test(readFileSync(join(import.meta.dir, name), 'utf8')),
|
|
66
|
+
)
|
|
67
|
+
expect(declaring).toEqual(['participantScroll.ts'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('is not re-derived by the scroll-to-latest rule', () => {
|
|
71
|
+
const source = readFileSync(join(import.meta.dir, 'participantScrollView.ts'), 'utf8')
|
|
72
|
+
expect(source).not.toMatch(/scrollTop|clientHeight|threshold/i)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const BADGE_CAP = 99
|
|
2
|
+
|
|
3
|
+
export type ScrollToLatestViewParams = {
|
|
4
|
+
/** Already decided by shouldStickToBottom; this rule never looks at pixels. */
|
|
5
|
+
readonly isAwayFromBottom: boolean
|
|
6
|
+
readonly newMessagesCount: number
|
|
7
|
+
readonly hasMessages: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ScrollToLatestView = {
|
|
11
|
+
readonly isVisible: boolean
|
|
12
|
+
readonly badge: string | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ScrollToLatestOptionsParams = {
|
|
16
|
+
readonly scrollHeight: number
|
|
17
|
+
readonly isReducedMotion: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveScrollToLatestView(params: ScrollToLatestViewParams): ScrollToLatestView {
|
|
21
|
+
const { isAwayFromBottom, newMessagesCount, hasMessages } = params
|
|
22
|
+
if (!isAwayFromBottom || !hasMessages) return { isVisible: false, badge: undefined }
|
|
23
|
+
if (newMessagesCount <= 0) return { isVisible: true, badge: undefined }
|
|
24
|
+
return { isVisible: true, badge: newMessagesCount > BADGE_CAP ? `${BADGE_CAP}+` : String(newMessagesCount) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function scrollToLatestOptions(params: ScrollToLatestOptionsParams): ScrollToOptions {
|
|
28
|
+
return { top: params.scrollHeight, behavior: params.isReducedMotion ? 'auto' : 'smooth' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scrollIntoViewOptions(isReducedMotion: boolean): ScrollIntoViewOptions {
|
|
32
|
+
return { block: 'nearest', inline: 'nearest', behavior: isReducedMotion ? 'auto' : 'smooth' }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readPrefersReducedMotion(): boolean {
|
|
36
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
37
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
38
|
+
}
|