@adatechnology/conversations-ui 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -19
- package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
- package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
- package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
- package/dist/flows/index.js +3 -5
- package/dist/index.js +834 -764
- package/dist/participant/index.d.ts +18 -1
- package/dist/participant/index.js +1188 -313
- package/dist/preview/index.js +2 -3
- package/dist/styles.css +583 -19
- package/package.json +3 -3
- package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
- package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
- package/src/participant/ParticipantBackButton.tsx +14 -0
- package/src/participant/ParticipantBubbleStatus.tsx +67 -0
- package/src/participant/ParticipantChannelBadges.tsx +13 -4
- package/src/participant/ParticipantComposer.tsx +8 -40
- package/src/participant/ParticipantComposerControls.tsx +83 -0
- package/src/participant/ParticipantConversationAvatar.tsx +13 -0
- package/src/participant/ParticipantConversations.tsx +7 -0
- package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
- package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
- package/src/participant/ParticipantCopyableToken.tsx +51 -0
- package/src/participant/ParticipantDayDivider.tsx +18 -0
- package/src/participant/ParticipantInbox.tsx +3 -1
- package/src/participant/ParticipantInboxRow.tsx +6 -4
- package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
- package/src/participant/ParticipantMessageBubble.tsx +25 -81
- package/src/participant/ParticipantMessageNodes.tsx +54 -0
- package/src/participant/ParticipantMessageText.test.tsx +106 -0
- package/src/participant/ParticipantMessageText.tsx +52 -0
- package/src/participant/ParticipantProtocolBadge.tsx +15 -7
- package/src/participant/ParticipantProtocolView.test.tsx +4 -6
- package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
- package/src/participant/ParticipantScrollToLatest.tsx +40 -0
- package/src/participant/ParticipantThread.test.tsx +37 -0
- package/src/participant/ParticipantThread.tsx +45 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +73 -0
- package/src/participant/ParticipantThreadScreen.tsx +24 -10
- package/src/participant/ParticipantTimeline.tsx +103 -0
- package/src/participant/index.ts +1 -0
- package/src/participant/participantAutoGrow.test.ts +43 -0
- package/src/participant/participantAutoGrow.ts +12 -0
- package/src/participant/participantAvatar.test.ts +74 -0
- package/src/participant/participantAvatar.ts +48 -0
- package/src/participant/participantBubbleTail.test.tsx +107 -0
- package/src/participant/participantChatDesign.test.tsx +198 -0
- package/src/participant/participantCopyAnnouncement.test.ts +52 -0
- package/src/participant/participantCopyAnnouncement.ts +37 -0
- package/src/participant/participantCss.test-helper.ts +34 -0
- package/src/participant/participantDay.test.ts +22 -0
- package/src/participant/participantDay.ts +8 -0
- package/src/participant/participantDocuments.ts +45 -0
- package/src/participant/participantHeaderA11y.test.tsx +85 -0
- package/src/participant/participantHeaderDesign.test.tsx +111 -0
- package/src/participant/participantLabels.ts +11 -0
- package/src/participant/participantMessageAtoms.ts +53 -0
- package/src/participant/participantMessageCopyables.test.ts +141 -0
- package/src/participant/participantMessageCopyables.ts +76 -0
- package/src/participant/participantMessageFormat.test-helper.ts +21 -0
- package/src/participant/participantMessageFormat.test.ts +177 -0
- package/src/participant/participantMessageFormat.ts +135 -0
- package/src/participant/participantMessageFormat.types.ts +22 -0
- package/src/participant/participantMessageInline.test.ts +82 -0
- package/src/participant/participantMessageInline.ts +99 -0
- package/src/participant/participantMessageLinks.test.ts +100 -0
- package/src/participant/participantMessageLinks.ts +54 -0
- package/src/participant/participantMessageTextStyle.test.ts +137 -0
- package/src/participant/participantScrollFocus.test.ts +22 -0
- package/src/participant/participantScrollFocus.ts +10 -0
- package/src/participant/participantScrollView.test.ts +74 -0
- package/src/participant/participantScrollView.ts +38 -0
- package/src/participant/participantScrollbars.test.ts +156 -0
- package/src/participant/participantSubjectIcon.test.tsx +27 -0
- package/src/participant/participantSubjectIcon.ts +26 -0
- package/src/participant/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThreadScroll.ts +10 -2
- package/src/participant/useRevealActiveChip.ts +20 -0
- package/src/participant/useScrollAwayState.ts +24 -0
- package/src/participant/useStickToBottom.ts +16 -2
- package/src/styles.css +137 -21
- package/dist/chunk-MRVFFJEH.js +0 -64
- package/dist/chunk-OVFCLRQQ.js +0 -84
|
@@ -0,0 +1,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
|
+
}
|