@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,37 @@
1
+ export type CopyAnnouncementState = { readonly text: string; readonly tick: number }
2
+
3
+ export type CopyAnnouncementAction = { readonly type: 'announce'; readonly text: string } | { readonly type: 'expired' }
4
+
5
+ export const INITIAL_COPY_ANNOUNCEMENT: CopyAnnouncementState = { text: '', tick: 0 }
6
+
7
+ const REPEAT_MARKER = '​'
8
+
9
+ /** The tick changes on every announcement so a repeat inside the window restarts the timer. */
10
+ export function copyAnnouncementReducer(
11
+ state: CopyAnnouncementState,
12
+ action: CopyAnnouncementAction,
13
+ ): CopyAnnouncementState {
14
+ if (action.type === 'announce') return { text: action.text, tick: state.tick + 1 }
15
+ return { ...state, text: '' }
16
+ }
17
+
18
+ export type LiveTextResolution = {
19
+ readonly text: string
20
+ /** The notice that was already spoken when the copy took over the region; undefined while it is not suppressed. */
21
+ readonly baselineNotice: string | undefined
22
+ }
23
+
24
+ /** A zero-width character alternates so identical consecutive announcements still change the region. */
25
+ export function resolveLiveText(params: {
26
+ readonly copy: CopyAnnouncementState
27
+ readonly notice: string
28
+ readonly baselineNotice: string | undefined
29
+ }): LiveTextResolution {
30
+ const { copy, notice, baselineNotice } = params
31
+ if (copy.text !== '') {
32
+ const marker = copy.tick % 2 === 0 ? REPEAT_MARKER : ''
33
+ return { text: `${copy.text}${marker}`, baselineNotice: baselineNotice ?? notice }
34
+ }
35
+ if (baselineNotice !== undefined && baselineNotice === notice) return { text: '', baselineNotice }
36
+ return { text: notice, baselineNotice: undefined }
37
+ }
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { splitBlocks } from '../cssBlocks.test-helper'
5
+
6
+ export const PARTICIPANT_CSS = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8').replace(
7
+ /\/\*[\s\S]*?\*\//g,
8
+ '',
9
+ )
10
+
11
+ /** Declarations of every top-level rule whose selector list contains `selector`, or of the rules inside `scope`. */
12
+ export function declarationsIn(selector: string, scope: string = PARTICIPANT_CSS): Map<string, string> {
13
+ const declarations = new Map<string, string>()
14
+ for (const { prelude, body } of splitBlocks(scope)) {
15
+ if (body === undefined || prelude.startsWith('@')) continue
16
+ if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
17
+ for (const declaration of body.split(';')) {
18
+ const separator = declaration.indexOf(':')
19
+ if (separator === -1) continue
20
+ declarations.set(declaration.slice(0, separator).trim().toLowerCase(), declaration.slice(separator + 1).trim())
21
+ }
22
+ }
23
+ return declarations
24
+ }
25
+
26
+ /** Inner CSS of the `@media <query>` block, or undefined when it does not exist. */
27
+ export function mediaBody(query: string): string | undefined {
28
+ return splitBlocks(PARTICIPANT_CSS).find(({ prelude }) => prelude === `@media ${query}`)?.body
29
+ }
30
+
31
+ export function toRem(value: string | undefined): number {
32
+ const match = /^(-?\d*\.?\d+)rem$/.exec(value ?? '')
33
+ return match ? Number(match[1]) : 0
34
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { toDateTimeAttribute } from './participantDay'
4
+
5
+ describe('toDateTimeAttribute', () => {
6
+ it('writes the local calendar day, zero padded', () => {
7
+ expect(toDateTimeAttribute(new Date(2026, 0, 5, 23, 59))).toBe('2026-01-05')
8
+ expect(toDateTimeAttribute(new Date(2026, 9, 12, 0, 1))).toBe('2026-10-12')
9
+ })
10
+
11
+ it('keeps the local day when the instant already crosses midnight UTC, in any timezone', () => {
12
+ const lateLocalEvening = new Date(2026, 9, 8, 23, 30)
13
+ const earlyLocalMorning = new Date(2026, 9, 9, 0, 30)
14
+ expect(toDateTimeAttribute(lateLocalEvening)).toBe('2026-10-08')
15
+ expect(toDateTimeAttribute(earlyLocalMorning)).toBe('2026-10-09')
16
+ })
17
+
18
+ it('never reads the UTC day, even in a timezone where it equals the local one', () => {
19
+ const date = Object.assign(new Date(2026, 9, 8, 23, 30), { toISOString: () => '2026-10-09T02:30:00.000Z' })
20
+ expect(toDateTimeAttribute(date)).toBe('2026-10-08')
21
+ })
22
+ })
@@ -0,0 +1,8 @@
1
+ function pad(value: number): string {
2
+ return String(value).padStart(2, '0')
3
+ }
4
+
5
+ /** Local calendar day as the `datetime` of a `<time>`: the label shows the local day, so must the machine value. */
6
+ export function toDateTimeAttribute(date: Date): string {
7
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
8
+ }
@@ -0,0 +1,45 @@
1
+ const CNPJ_FIRST_WEIGHTS = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] as const
2
+ const CNPJ_SECOND_WEIGHTS = [6, ...CNPJ_FIRST_WEIGHTS] as const
3
+
4
+ function charValue(character: string): number {
5
+ return character.charCodeAt(0) - 48
6
+ }
7
+
8
+ function isRepeatedSequence(value: string): boolean {
9
+ return new Set(value).size === 1
10
+ }
11
+
12
+ function weightedSum(characters: string, weights: readonly number[]): number {
13
+ let sum = 0
14
+ for (let index = 0; index < weights.length; index += 1) {
15
+ sum += charValue(characters.charAt(index)) * (weights[index] ?? 0)
16
+ }
17
+ return sum
18
+ }
19
+
20
+ function cnpjCheckDigit(characters: string, weights: readonly number[]): number {
21
+ const remainder = weightedSum(characters, weights) % 11
22
+ return remainder < 2 ? 0 : 11 - remainder
23
+ }
24
+
25
+ function cpfCheckDigit(digits: string, length: number): number {
26
+ const weights = Array.from({ length }, (_, index) => length + 1 - index)
27
+ return ((weightedSum(digits, weights) * 10) % 11) % 10
28
+ }
29
+
30
+ /** `digits` is the bare 11-digit CPF; repeated sequences such as 111.111.111-11 are rejected. */
31
+ export function isValidCpf(digits: string): boolean {
32
+ if (!/^\d{11}$/.test(digits) || isRepeatedSequence(digits)) return false
33
+ return (
34
+ cpfCheckDigit(digits, 9) === charValue(digits.charAt(9)) &&
35
+ cpfCheckDigit(digits, 10) === charValue(digits.charAt(10))
36
+ )
37
+ }
38
+
39
+ /** `characters` is the bare 14-position CNPJ, numeric or alphanumeric ([A-Z0-9]{12}\d{2}). */
40
+ export function isValidCnpj(characters: string): boolean {
41
+ if (!/^[A-Z0-9]{12}\d{2}$/.test(characters) || isRepeatedSequence(characters)) return false
42
+ const first = cnpjCheckDigit(characters, CNPJ_FIRST_WEIGHTS)
43
+ if (first !== charValue(characters.charAt(12))) return false
44
+ return cnpjCheckDigit(characters, CNPJ_SECOND_WEIGHTS) === charValue(characters.charAt(13))
45
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { declarationsIn, mediaBody, toRem } from './participantCss.test-helper'
5
+ import { buildConversation } from './participantFixtures.test-helper'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
+ import { ParticipantThreadHeader } from './ParticipantThreadHeader'
8
+
9
+ function render(onOpenSubject?: () => void): string {
10
+ return renderToStaticMarkup(
11
+ <ParticipantThreadHeader
12
+ conversation={buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521', protocol: 'CV-2026-0001' })}
13
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
14
+ onOpenSubject={onOpenSubject}
15
+ />,
16
+ )
17
+ }
18
+
19
+ describe('clickable title focus ring', () => {
20
+ it('clamps the text in one inner span, inside the button and inside the plain heading', () => {
21
+ expect(render(() => undefined)).toMatch(
22
+ /<button[^>]*cv-p-thread__title-button[^>]*><span class="cv-p-thread__title-text">Invoice 4521<\/span><\/button>/,
23
+ )
24
+ expect(render()).toContain(
25
+ '<h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2>',
26
+ )
27
+ })
28
+
29
+ it('puts the only clamp on the inner span', () => {
30
+ const text = declarationsIn('.cv-p-thread__title-text')
31
+ expect(text.get('-webkit-line-clamp')).toBe('1')
32
+ expect(text.get('overflow')).toBe('hidden')
33
+ for (const selector of ['.cv-p-thread__title', '.cv-p-thread__title-button']) {
34
+ expect(declarationsIn(selector).has('-webkit-line-clamp')).toBe(false)
35
+ }
36
+ })
37
+
38
+ it('never clips the outline: no ancestor of the button hides overflow', () => {
39
+ const ancestors = [
40
+ '.cv-p-thread__title',
41
+ '.cv-p-thread__title-button',
42
+ '.cv-p-thread__heading',
43
+ '.cv-p-thread__header',
44
+ ]
45
+ for (const selector of ancestors) {
46
+ const overflow = declarationsIn(selector).get('overflow')
47
+ expect(overflow === undefined || overflow === 'visible').toBe(true)
48
+ }
49
+ })
50
+ })
51
+
52
+ describe('copy touch area', () => {
53
+ it('is at least 2.75rem wide and 2rem tall', () => {
54
+ const copy = declarationsIn('.cv-p-protocol__copy')
55
+ expect(toRem(copy.get('min-width'))).toBeGreaterThanOrEqual(2.75)
56
+ expect(toRem(copy.get('min-height'))).toBeGreaterThanOrEqual(2)
57
+ })
58
+
59
+ it('extends above the button no further than the padding the meta line reserves', () => {
60
+ const negativeTop = Math.abs(
61
+ Math.min(0, toRem(declarationsIn('.cv-p-protocol__copy').get('margin')?.split(/\s+/)[0])),
62
+ )
63
+ const reservedTop = toRem(declarationsIn('.cv-p-thread__meta').get('padding-top'))
64
+ expect(negativeTop).toBeGreaterThan(0)
65
+ expect(negativeTop).toBeLessThanOrEqual(reservedTop)
66
+ })
67
+ })
68
+
69
+ describe('bubble tail in forced colors and print', () => {
70
+ it('drops both triangles and restores the radius under forced-colors', () => {
71
+ const body = mediaBody('(forced-colors: active)') ?? ''
72
+ expect(body).not.toBe('')
73
+ const tails = declarationsIn('.cv-p-bubble::before', body)
74
+ expect(tails.get('content')).toBe('none')
75
+ expect(declarationsIn('.cv-p-bubble::after', body).get('content')).toBe('none')
76
+ expect(declarationsIn('.cv-p-bubble', body).get('border-radius')).toBe('var(--cv-p-i-radius)')
77
+ expect(declarationsIn('.cv-p-bubble--mine', body).get('border-radius')).toBe('var(--cv-p-i-radius)')
78
+ })
79
+
80
+ it('drops both triangles when printing', () => {
81
+ const body = mediaBody('print') ?? ''
82
+ expect(declarationsIn('.cv-p-bubble::before', body).get('content')).toBe('none')
83
+ expect(declarationsIn('.cv-p-bubble::after', body).get('content')).toBe('none')
84
+ })
85
+ })
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { declarationsIn, toRem } from './participantCss.test-helper'
5
+ import { buildConversation } from './participantFixtures.test-helper'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
+ import { ParticipantThreadHeader, type ParticipantThreadHeaderProps } from './ParticipantThreadHeader'
8
+
9
+ const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
10
+
11
+ function render(overrides: Partial<ParticipantThreadHeaderProps> = {}): string {
12
+ return renderToStaticMarkup(
13
+ <ParticipantThreadHeader
14
+ conversation={buildConversation({ subjectId: '1', subjectLabel: 'Item 4521' })}
15
+ labels={LABELS}
16
+ {...overrides}
17
+ />,
18
+ )
19
+ }
20
+
21
+ function backButtonOf(markup: string): string {
22
+ const start = markup.indexOf('<button')
23
+ return markup.slice(start, markup.indexOf('</button>', start))
24
+ }
25
+
26
+ describe('back button', () => {
27
+ const markup = render({ onBack: () => undefined })
28
+
29
+ it('is an icon with the back label as its accessible name and no visible text or glyph', () => {
30
+ const button = backButtonOf(markup)
31
+ expect(button).toContain('class="cv-p-thread__back"')
32
+ expect(button).toContain('aria-label="Back"')
33
+ expect(button).toContain('<svg')
34
+ expect(button.replace(/<[^>]+>/g, '')).toBe('')
35
+ expect(markup).not.toContain('‹')
36
+ })
37
+
38
+ it('is not the boxed secondary button any more', () => {
39
+ expect(markup).not.toContain('cv-p-button')
40
+ const back = declarationsIn('.cv-p-thread__back')
41
+ expect(back.get('border')).toBe('0')
42
+ expect(back.get('background')).toBe('transparent')
43
+ })
44
+
45
+ it('has a 44px touch area and keeps the focus ring', () => {
46
+ const back = declarationsIn('.cv-p-thread__back')
47
+ expect(back.get('min-width')).toBe('var(--cv-p-i-touch)')
48
+ expect(back.get('min-height')).toBe('var(--cv-p-i-touch)')
49
+ expect(declarationsIn('.cv-p :focus-visible').get('outline')).toContain('2px solid')
50
+ })
51
+
52
+ it('is absent without onBack', () => {
53
+ expect(render()).not.toContain('<button')
54
+ })
55
+
56
+ it('comes first, before the avatar and the title block', () => {
57
+ const withIcon = render({ onBack: () => undefined, renderSubjectIcon: () => <i /> })
58
+ expect(withIcon.indexOf('cv-p-thread__back')).toBeLessThan(withIcon.indexOf('cv-p-thread__avatar'))
59
+ expect(withIcon.indexOf('cv-p-thread__avatar')).toBeLessThan(withIcon.indexOf('cv-p-thread__heading'))
60
+ })
61
+ })
62
+
63
+ describe('conversation avatar', () => {
64
+ const group = { subjectType: 'invoice', label: 'Invoices', icon: <i>group</i> }
65
+
66
+ it('uses the host icon over the group icon', () => {
67
+ const markup = render({ subjectGroups: [group], renderSubjectIcon: () => <b>host</b> })
68
+ expect(markup).toContain('<span class="cv-p-thread__avatar" aria-hidden="true"><b>host</b></span>')
69
+ expect(markup).not.toContain('<i>group</i>')
70
+ })
71
+
72
+ it('falls back to the group icon when the host draws nothing', () => {
73
+ const markup = render({ subjectGroups: [group], renderSubjectIcon: () => null })
74
+ expect(markup).toContain('<span class="cv-p-thread__avatar" aria-hidden="true"><i>group</i></span>')
75
+ })
76
+
77
+ it('is absent without any icon, and the group label takes its place as the eyebrow', () => {
78
+ const markup = render({ subjectGroups: [{ subjectType: 'invoice', label: 'Invoices' }] })
79
+ expect(markup).not.toContain('cv-p-thread__avatar')
80
+ expect(markup).toContain('<p class="cv-p-thread__eyebrow">Invoices</p>')
81
+ expect(render()).not.toContain('cv-p-thread__avatar')
82
+ })
83
+
84
+ it('replaces the eyebrow: the tile stands for the group', () => {
85
+ expect(render({ subjectGroups: [group] })).not.toContain('cv-p-thread__eyebrow')
86
+ })
87
+
88
+ it('is a 2.5rem tile that follows the radius token', () => {
89
+ const avatar = declarationsIn('.cv-p-thread__avatar')
90
+ expect(toRem(avatar.get('width'))).toBe(2.5)
91
+ expect(toRem(avatar.get('height'))).toBe(2.5)
92
+ expect(avatar.get('border-radius')).toBe('var(--cv-p-i-radius)')
93
+ expect(avatar.get('flex')).toBe('0 0 auto')
94
+ })
95
+ })
96
+
97
+ describe('bar', () => {
98
+ it('is a raised surface with a 1px bottom border and a 4rem floor', () => {
99
+ const header = declarationsIn('.cv-p-thread__header')
100
+ expect(header.get('background')).toBe('var(--cv-p-i-surface-raised)')
101
+ expect(header.get('border-bottom')).toBe('1px solid var(--cv-p-i-border)')
102
+ expect(header.get('align-items')).toBe('center')
103
+ })
104
+
105
+ it('lets the title block shrink so nothing overflows at 320px', () => {
106
+ const heading = declarationsIn('.cv-p-thread__heading')
107
+ expect(heading.get('min-width')).toBe('0')
108
+ expect(declarationsIn('.cv-p-thread__meta').get('flex-wrap')).toBe('wrap')
109
+ expect(declarationsIn('.cv-p-thread__title-text').get('text-overflow')).toBe('ellipsis')
110
+ })
111
+ })
@@ -13,6 +13,8 @@ export type ParticipantConversationsLabels = {
13
13
  readonly me: string
14
14
  readonly newMessages: string
15
15
  readonly loadOlder: string
16
+ /** Accessible name of the floating button that returns to the end of the conversation. */
17
+ readonly scrollToLatest: string
16
18
  readonly closedNotice: string
17
19
  readonly messageInputLabel: string
18
20
  readonly messageInputPlaceholder: string
@@ -53,6 +55,11 @@ export type ParticipantConversationsLabels = {
53
55
  readonly channelEmail: string
54
56
  readonly channelPortal: string
55
57
  readonly channelWebchat: string
58
+ /** Spoken after a link that opens in a new tab. */
59
+ readonly externalLinkHint: string
60
+ /** Prefix of the accessible name of the copy buttons: "Copy 529.982.247-25". */
61
+ readonly copyValue: string
62
+ readonly valueCopied: string
56
63
  }
57
64
 
58
65
  export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsLabels = {
@@ -69,6 +76,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
69
76
  me: 'Me',
70
77
  newMessages: 'New messages',
71
78
  loadOlder: 'Load older messages',
79
+ scrollToLatest: 'Scroll to latest message',
72
80
  closedNotice: 'This conversation is closed',
73
81
  messageInputLabel: 'Message',
74
82
  messageInputPlaceholder: 'Write a message…',
@@ -108,6 +116,9 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
108
116
  channelEmail: 'Email',
109
117
  channelPortal: 'Portal',
110
118
  channelWebchat: 'Web chat',
119
+ externalLinkHint: 'opens in a new tab',
120
+ copyValue: 'Copy',
121
+ valueCopied: 'Copied',
111
122
  }
112
123
 
113
124
  export function formatParticipantLabel(template: string, count: number): string {
@@ -0,0 +1,53 @@
1
+ import { findCopyableAtoms } from './participantMessageCopyables'
2
+ import type { InlineAtom } from './participantMessageFormat.types'
3
+ import { findLinkAtoms } from './participantMessageLinks'
4
+
5
+ const TRIPLE_FENCE = '```'
6
+
7
+ function spanAt(
8
+ text: string,
9
+ open: number,
10
+ ): { readonly contentStart: number; readonly close: number; readonly width: number } | undefined {
11
+ const isTriple = text.startsWith(TRIPLE_FENCE, open)
12
+ const width = isTriple ? 3 : 1
13
+ const close = text.indexOf(isTriple ? TRIPLE_FENCE : '`', open + width)
14
+ if (close === -1) return undefined
15
+ return { contentStart: open + width, close, width }
16
+ }
17
+
18
+ export function findCodeAtoms(text: string): InlineAtom[] {
19
+ const atoms: InlineAtom[] = []
20
+ let cursor = text.indexOf('`')
21
+ while (cursor !== -1) {
22
+ const span = spanAt(text, cursor)
23
+ if (span === undefined) {
24
+ if (!text.startsWith(TRIPLE_FENCE, cursor)) break
25
+ cursor = text.indexOf('`', cursor + 3)
26
+ continue
27
+ }
28
+ if (span.close === span.contentStart) {
29
+ cursor = text.indexOf('`', cursor + span.width)
30
+ continue
31
+ }
32
+ const end = span.close + span.width
33
+ atoms.push({ start: cursor, end, node: { kind: 'code', value: text.slice(span.contentStart, span.close) } })
34
+ cursor = text.indexOf('`', end)
35
+ }
36
+ return atoms
37
+ }
38
+
39
+ /** Code wins over links, links over copyable tokens; overlapping losers are dropped. */
40
+ export function findInlineAtoms(text: string): InlineAtom[] {
41
+ const candidates = [...findCodeAtoms(text), ...findLinkAtoms(text), ...findCopyableAtoms(text)]
42
+ const ordered = candidates
43
+ .map((atom, rank) => ({ atom, rank }))
44
+ .sort((a, b) => a.atom.start - b.atom.start || a.rank - b.rank)
45
+ const accepted: InlineAtom[] = []
46
+ let lastEnd = 0
47
+ for (const { atom } of ordered) {
48
+ if (atom.start < lastEnd) continue
49
+ accepted.push(atom)
50
+ lastEnd = atom.end
51
+ }
52
+ return accepted
53
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { isValidCnpj, isValidCpf } from './participantDocuments'
4
+ import { show } from './participantMessageFormat.test-helper'
5
+ import { parseInline } from './participantMessageInline'
6
+
7
+ function inline(line: string): string {
8
+ return show(parseInline(line))
9
+ }
10
+
11
+ function copyable(kind: string, value: string): string {
12
+ return `⟦k:${kind}:${value}⟧`
13
+ }
14
+
15
+ describe('check digits', () => {
16
+ it('validates CPF', () => {
17
+ expect(isValidCpf('52998224725')).toBe(true)
18
+ expect(isValidCpf('52998224726')).toBe(false)
19
+ expect(isValidCpf('11111111111')).toBe(false)
20
+ expect(isValidCpf('1234567890')).toBe(false)
21
+ })
22
+
23
+ it('validates numeric and alphanumeric CNPJ', () => {
24
+ expect(isValidCnpj('11222333000181')).toBe(true)
25
+ expect(isValidCnpj('11222333000182')).toBe(false)
26
+ expect(isValidCnpj('12ABC34501DE35')).toBe(true)
27
+ expect(isValidCnpj('12ABC34501DE36')).toBe(false)
28
+ expect(isValidCnpj('00000000000000')).toBe(false)
29
+ })
30
+ })
31
+
32
+ describe('copyable - CPF', () => {
33
+ it('detects valid CPF with and without mask', () => {
34
+ expect(inline('CPF 529.982.247-25 ok')).toBe(`CPF ${copyable('cpf', '529.982.247-25')} ok`)
35
+ expect(inline('52998224725')).toBe(copyable('cpf', '52998224725'))
36
+ expect(inline('CPF: 529.982.247-25.')).toBe(`CPF: ${copyable('cpf', '529.982.247-25')}.`)
37
+ })
38
+
39
+ it('ignores repeated sequences and wrong check digits', () => {
40
+ expect(inline('111.111.111-11')).toBe('111.111.111-11')
41
+ expect(inline('11111111111')).toBe('11111111111')
42
+ expect(inline('529.982.247-26')).toBe('529.982.247-26')
43
+ })
44
+ })
45
+
46
+ describe('copyable - CNPJ', () => {
47
+ it('detects valid numeric CNPJ with and without mask', () => {
48
+ expect(inline('11.222.333/0001-81')).toBe(copyable('cnpj', '11.222.333/0001-81'))
49
+ expect(inline('11222333000181')).toBe(copyable('cnpj', '11222333000181'))
50
+ })
51
+
52
+ it('detects the new alphanumeric CNPJ', () => {
53
+ expect(inline('12.ABC.345/01DE-35')).toBe(copyable('cnpj', '12.ABC.345/01DE-35'))
54
+ expect(inline('12ABC34501DE35')).toBe(copyable('cnpj', '12ABC34501DE35'))
55
+ })
56
+
57
+ it('ignores invalid CNPJ and does not take it for a phone', () => {
58
+ expect(inline('11.222.333/0001-82')).toBe('11.222.333/0001-82')
59
+ expect(inline('12ABC34501DE36')).toBe('12ABC34501DE36')
60
+ })
61
+ })
62
+
63
+ describe('copyable - access key', () => {
64
+ const KEY = '35260112345678000195550010000012341000012345'
65
+
66
+ it('detects exactly 44 digits, bare or in groups of four', () => {
67
+ expect(KEY).toHaveLength(44)
68
+ expect(inline(`chave ${KEY}`)).toBe(`chave ${copyable('accessKey', KEY)}`)
69
+ const grouped = KEY.match(/\d{4}/g)?.join(' ') ?? ''
70
+ expect(inline(grouped)).toBe(copyable('accessKey', grouped))
71
+ })
72
+
73
+ it('ignores 43 and 45 digit runs', () => {
74
+ expect(inline(KEY.slice(1))).toBe(KEY.slice(1))
75
+ expect(inline(`${KEY}1`)).toBe(`${KEY}1`)
76
+ })
77
+
78
+ it('ignores a repeated sequence', () => {
79
+ expect(inline('1'.repeat(44))).toBe('1'.repeat(44))
80
+ })
81
+ })
82
+
83
+ describe('copyable - phone', () => {
84
+ it('detects five formats and keeps the mask as typed', () => {
85
+ expect(inline('(16) 99999-0000')).toBe(copyable('phone', '(16) 99999-0000'))
86
+ expect(inline('+55 16 99999-0000')).toBe(copyable('phone', '+55 16 99999-0000'))
87
+ expect(inline('16 99999-0000')).toBe(copyable('phone', '16 99999-0000'))
88
+ expect(inline('(16) 3333-4444')).toBe(copyable('phone', '(16) 3333-4444'))
89
+ expect(inline('+1 415 555 2671')).toBe(copyable('phone', '+1 415 555 2671'))
90
+ })
91
+
92
+ it('does not take bare 10 or 11 digits for a phone', () => {
93
+ expect(inline('pedido 1234567890')).toBe('pedido 1234567890')
94
+ expect(inline('16999990000')).toBe('16999990000')
95
+ expect(inline('1633334444')).toBe('1633334444')
96
+ })
97
+
98
+ it('accepts a bare number only with +55, a bracketed DDD or a separator after the DDD', () => {
99
+ expect(inline('+5516999990000')).toBe(copyable('phone', '+5516999990000'))
100
+ expect(inline('(16)999990000')).toBe(copyable('phone', '(16)999990000'))
101
+ expect(inline('16-999990000')).toBe(copyable('phone', '16-999990000'))
102
+ })
103
+
104
+ it('keeps the final dot outside', () => {
105
+ expect(inline('ligue (16) 99999-0000.')).toBe(`ligue ${copyable('phone', '(16) 99999-0000')}.`)
106
+ })
107
+ })
108
+
109
+ describe('copyable - e-mail', () => {
110
+ it('detects e-mail addresses', () => {
111
+ expect(inline('fale a@b.co.')).toBe(`fale ${copyable('email', 'a@b.co')}.`)
112
+ expect(inline('ana.souza+x@mail.example.com.br')).toBe(copyable('email', 'ana.souza+x@mail.example.com.br'))
113
+ })
114
+
115
+ it('ignores malformed addresses', () => {
116
+ expect(inline('@ana')).toBe('@ana')
117
+ expect(inline('a@b')).toBe('a@b')
118
+ expect(inline('a@.co')).toBe('a@.co')
119
+ })
120
+ })
121
+
122
+ describe('copyable - false positives avoided', () => {
123
+ it('does not highlight money, dates, short numbers or labels', () => {
124
+ expect(inline('R$ 1.234,56')).toBe('R$ 1.234,56')
125
+ expect(inline('10/10/2026')).toBe('10/10/2026')
126
+ expect(inline('2026-10-01')).toBe('2026-10-01')
127
+ expect(inline('12345')).toBe('12345')
128
+ expect(inline('NF 4521')).toBe('NF 4521')
129
+ expect(inline('total 1234567')).toBe('total 1234567')
130
+ })
131
+
132
+ it('does not detect inside inline code or inside a URL', () => {
133
+ expect(inline('`529.982.247-25`')).toBe('⟦c:529.982.247-25⟧')
134
+ expect(inline('`a@b.co`')).toBe('⟦c:a@b.co⟧')
135
+ expect(inline('https://a.com/52998224725')).toBe('⟦l:https://a.com/52998224725→https://a.com/52998224725⟧')
136
+ })
137
+
138
+ it('lets a mark wrap a token', () => {
139
+ expect(inline('*529.982.247-25*')).toBe(`⟦s:${copyable('cpf', '529.982.247-25')}⟧`)
140
+ })
141
+ })
@@ -0,0 +1,76 @@
1
+ import { isValidCnpj, isValidCpf } from './participantDocuments'
2
+ import type { CopyableKind, InlineAtom } from './participantMessageFormat.types'
3
+
4
+ type Scanner = { readonly copyKind: CopyableKind; readonly pattern: RegExp; readonly isValid: (raw: string) => boolean }
5
+
6
+ const DOCUMENT_SEPARATORS = /[.\-/\s]/g
7
+ const ACCESS_KEY_PATTERN = /\d{44}|\d{4}(?: \d{4}){10}/gu
8
+ const CNPJ_PATTERN = /[A-Z0-9]{2}\.[A-Z0-9]{3}\.[A-Z0-9]{3}\/[A-Z0-9]{4}-\d{2}|[A-Z0-9]{12}\d{2}/gu
9
+ const CPF_PATTERN = /\d{3}\.\d{3}\.\d{3}-\d{2}|\d{11}/gu
10
+ const BRAZIL_PHONE_PATTERN = /(?:\+?55[\s.-]?)?(?:\([1-9][1-9]\)|[1-9][1-9])[\s.-]?(?:9\d{4}|[2-5]\d{3})[\s.-]?\d{4}/gu
11
+ const INTERNATIONAL_PHONE_PATTERN = /\+\d{1,3}(?:[\s.-]?\d{2,5}){2,4}/gu
12
+ const EMAIL_LOCAL = /^[\p{L}\p{N}._%+-]+$/u
13
+ const EMAIL_DOMAIN = /^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}$/u
14
+
15
+ function bare(raw: string): string {
16
+ return raw.replace(DOCUMENT_SEPARATORS, '')
17
+ }
18
+
19
+ function isWordCharacter(character: string | undefined): boolean {
20
+ return character !== undefined && /[\p{L}\p{N}]/u.test(character)
21
+ }
22
+
23
+ function hasPhoneDigitCount(raw: string): boolean {
24
+ const count = raw.replace(/\D/g, '').length
25
+ return count >= 10 && count <= 15
26
+ }
27
+
28
+ /** Bare 10/11 digits are order numbers as often as phones: demand +country, a bracketed DDD or a separator after it. */
29
+ function isFormattedBrazilPhone(raw: string): boolean {
30
+ return /^\+|\(|^\d{2}[\s.-]/u.test(raw) && hasPhoneDigitCount(raw)
31
+ }
32
+
33
+ const SCANNERS: readonly Scanner[] = [
34
+ { copyKind: 'accessKey', pattern: ACCESS_KEY_PATTERN, isValid: (raw) => !/^(\d)\1+$/.test(bare(raw)) },
35
+ { copyKind: 'cnpj', pattern: CNPJ_PATTERN, isValid: (raw) => isValidCnpj(bare(raw)) },
36
+ { copyKind: 'cpf', pattern: CPF_PATTERN, isValid: (raw) => isValidCpf(bare(raw)) },
37
+ { copyKind: 'phone', pattern: BRAZIL_PHONE_PATTERN, isValid: isFormattedBrazilPhone },
38
+ {
39
+ copyKind: 'phone',
40
+ pattern: INTERNATIONAL_PHONE_PATTERN,
41
+ isValid: (raw) => hasPhoneDigitCount(raw) && !raw.startsWith('+55'),
42
+ },
43
+ ]
44
+
45
+ function scan(text: string, scanner: Scanner): InlineAtom[] {
46
+ const atoms: InlineAtom[] = []
47
+ for (const match of text.matchAll(scanner.pattern)) {
48
+ const [value] = match
49
+ const start = match.index
50
+ const end = start + value.length
51
+ if (isWordCharacter(text[start - 1]) || isWordCharacter(text[end])) continue
52
+ if (!scanner.isValid(value)) continue
53
+ atoms.push({ start, end, node: { kind: 'copyable', copyKind: scanner.copyKind, value } })
54
+ }
55
+ return atoms
56
+ }
57
+
58
+ function findEmailAtoms(text: string): InlineAtom[] {
59
+ const atoms: InlineAtom[] = []
60
+ for (let at = text.indexOf('@'); at !== -1; at = text.indexOf('@', at + 1)) {
61
+ let start = at
62
+ while (start > 0 && /[\p{L}\p{N}._%+-]/u.test(text[start - 1] ?? '')) start -= 1
63
+ let end = at + 1
64
+ while (end < text.length && /[\p{L}\p{N}.-]/u.test(text[end] ?? '')) end += 1
65
+ while (end > at + 1 && /[.-]/.test(text[end - 1] ?? '')) end -= 1
66
+ const isValid = EMAIL_LOCAL.test(text.slice(start, at)) && EMAIL_DOMAIN.test(text.slice(at + 1, end))
67
+ if (isValid) {
68
+ atoms.push({ start, end, node: { kind: 'copyable', copyKind: 'email', value: text.slice(start, end) } })
69
+ }
70
+ }
71
+ return atoms
72
+ }
73
+
74
+ export function findCopyableAtoms(text: string): InlineAtom[] {
75
+ return [...SCANNERS.flatMap((scanner) => scan(text, scanner)), ...findEmailAtoms(text)]
76
+ }