@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,103 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { channelCapabilityFor } from '../channelCapability'
|
|
4
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
5
|
+
import { isSameDay } from '../lib/format'
|
|
6
|
+
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
7
|
+
import { ParticipantAuthorAvatar, type ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
8
|
+
import { ParticipantDayDivider } from './ParticipantDayDivider'
|
|
9
|
+
import { ParticipantMessageBubble } from './ParticipantMessageBubble'
|
|
10
|
+
import { shouldShowAvatar, type ParticipantAvatarAuthor } from './participantAvatar'
|
|
11
|
+
import {
|
|
12
|
+
bindPendingActions,
|
|
13
|
+
resolveParticipantBubbleActions,
|
|
14
|
+
type ParticipantPendingActions,
|
|
15
|
+
} from './participantBubbleActions'
|
|
16
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
17
|
+
import { isOwnMessage, type ParticipantTimelineItem } from './participantMessages'
|
|
18
|
+
|
|
19
|
+
export type ParticipantTimelineProps = {
|
|
20
|
+
readonly items: readonly ParticipantTimelineItem[]
|
|
21
|
+
readonly labels: ParticipantConversationsLabels
|
|
22
|
+
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
23
|
+
readonly channel?: ConversationChannel
|
|
24
|
+
readonly locale?: string
|
|
25
|
+
readonly pendingActions?: ParticipantPendingActions
|
|
26
|
+
readonly avatars?: 'initials'
|
|
27
|
+
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
28
|
+
readonly tail?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createdAtOf(item: ParticipantTimelineItem): string {
|
|
32
|
+
return item.kind === 'server' ? item.message.createdAt : item.pending.createdAt
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function keyOf(item: ParticipantTimelineItem): string {
|
|
36
|
+
return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type AuthorOfItem = { readonly isMine: boolean; readonly author: ParticipantAvatarAuthor }
|
|
40
|
+
|
|
41
|
+
function authorOf(item: ParticipantTimelineItem): AuthorOfItem {
|
|
42
|
+
if (item.kind === 'pending') return { isMine: true, author: null }
|
|
43
|
+
return { isMine: isOwnMessage(item.message), author: item.message.authorName ?? null }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveActions(item: ParticipantTimelineItem, pendingActions?: ParticipantPendingActions) {
|
|
47
|
+
if (!pendingActions || item.kind === 'server') return {}
|
|
48
|
+
return resolveParticipantBubbleActions(item, bindPendingActions(pendingActions, item.pending.clientMessageId))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type AvatarSlotParams = {
|
|
52
|
+
readonly isEnabled: boolean
|
|
53
|
+
readonly isMine: boolean
|
|
54
|
+
readonly showAvatar: boolean
|
|
55
|
+
readonly author: ParticipantAvatarAuthor
|
|
56
|
+
readonly render?: ParticipantAuthorAvatarRenderer
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveAvatarSlot({ isEnabled, isMine, showAvatar, author, render }: AvatarSlotParams): ReactNode {
|
|
60
|
+
if (!isEnabled || isMine) return undefined
|
|
61
|
+
return showAvatar ? <ParticipantAuthorAvatar name={author} render={render} /> : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ParticipantTimeline(props: ParticipantTimelineProps) {
|
|
65
|
+
const { items, labels, locale, resolveAttachmentUrl, pendingActions } = props
|
|
66
|
+
const confirmsRead = channelCapabilityFor(props.channel ?? 'app').confirmsRead
|
|
67
|
+
const isAvatarEnabled = props.avatars === 'initials' || props.renderAuthorAvatar !== undefined
|
|
68
|
+
const nodes: ReactNode[] = []
|
|
69
|
+
let previousDay: Date | undefined
|
|
70
|
+
let previousAuthor: ParticipantAvatarAuthor | undefined
|
|
71
|
+
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
const day = new Date(createdAtOf(item))
|
|
74
|
+
if (previousDay === undefined || !isSameDay(previousDay, day)) {
|
|
75
|
+
nodes.push(<ParticipantDayDivider key={`day:${keyOf(item)}`} day={day} locale={locale} />)
|
|
76
|
+
previousAuthor = undefined
|
|
77
|
+
}
|
|
78
|
+
previousDay = day
|
|
79
|
+
const { isMine, author } = authorOf(item)
|
|
80
|
+
const showAvatar = shouldShowAvatar({ previousAuthor, author, isMine })
|
|
81
|
+
previousAuthor = isMine ? undefined : author
|
|
82
|
+
const avatar = resolveAvatarSlot({
|
|
83
|
+
isEnabled: isAvatarEnabled,
|
|
84
|
+
isMine,
|
|
85
|
+
showAvatar,
|
|
86
|
+
author,
|
|
87
|
+
render: props.renderAuthorAvatar,
|
|
88
|
+
})
|
|
89
|
+
nodes.push(
|
|
90
|
+
<ParticipantMessageBubble
|
|
91
|
+
key={keyOf(item)}
|
|
92
|
+
item={item}
|
|
93
|
+
labels={labels}
|
|
94
|
+
confirmsRead={confirmsRead}
|
|
95
|
+
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
96
|
+
tail={props.tail}
|
|
97
|
+
avatar={avatar}
|
|
98
|
+
{...resolveActions(item, pendingActions)}
|
|
99
|
+
/>,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
return <div className="cv-p-thread__messages">{nodes}</div>
|
|
103
|
+
}
|
package/src/participant/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ParticipantConversations, type ParticipantConversationsProps } from './ParticipantConversations'
|
|
2
|
+
export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
2
3
|
export { groupParticipantConversations } from './participantGrouping'
|
|
3
4
|
export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
4
5
|
export type {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { fitTextareaHeight, type AutoGrowTarget } from './participantAutoGrow'
|
|
4
|
+
|
|
5
|
+
describe('fitTextareaHeight', () => {
|
|
6
|
+
it('measures after resetting, so the field can also shrink', () => {
|
|
7
|
+
const heights: string[] = []
|
|
8
|
+
const target: AutoGrowTarget = {
|
|
9
|
+
style: {
|
|
10
|
+
get height() {
|
|
11
|
+
return heights.at(-1) ?? ''
|
|
12
|
+
},
|
|
13
|
+
set height(value: string) {
|
|
14
|
+
heights.push(value)
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
scrollHeight: 96,
|
|
18
|
+
offsetHeight: 96,
|
|
19
|
+
clientHeight: 96,
|
|
20
|
+
}
|
|
21
|
+
fitTextareaHeight(target)
|
|
22
|
+
expect(heights).toEqual(['auto', '96px'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('adds the border to the content height, so a border-box field does not overflow', () => {
|
|
26
|
+
const heights: string[] = []
|
|
27
|
+
const target: AutoGrowTarget = {
|
|
28
|
+
style: {
|
|
29
|
+
get height() {
|
|
30
|
+
return heights.at(-1) ?? ''
|
|
31
|
+
},
|
|
32
|
+
set height(value: string) {
|
|
33
|
+
heights.push(value)
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
scrollHeight: 61,
|
|
37
|
+
offsetHeight: 63,
|
|
38
|
+
clientHeight: 61,
|
|
39
|
+
}
|
|
40
|
+
fitTextareaHeight(target)
|
|
41
|
+
expect(heights.at(-1)).toBe('63px')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AutoGrowTarget = {
|
|
2
|
+
readonly style: { height: string }
|
|
3
|
+
readonly scrollHeight: number
|
|
4
|
+
readonly offsetHeight: number
|
|
5
|
+
readonly clientHeight: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Fits the field to its content plus its border (border-box), so no phantom scrollbar; the CSS max-height caps it. */
|
|
9
|
+
export function fitTextareaHeight(target: AutoGrowTarget): void {
|
|
10
|
+
target.style.height = 'auto'
|
|
11
|
+
target.style.height = `${target.scrollHeight + (target.offsetHeight - target.clientHeight)}px`
|
|
12
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { authorInitials, shouldShowAvatar } from './participantAvatar'
|
|
4
|
+
|
|
5
|
+
describe('authorInitials', () => {
|
|
6
|
+
it('takes the first letters of the first and last names', () => {
|
|
7
|
+
expect(authorInitials('Ana Souza')).toBe('AS')
|
|
8
|
+
expect(authorInitials('maria ana souza lima')).toBe('ML')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('uses a single initial for a single name', () => {
|
|
12
|
+
expect(authorInitials('Ana')).toBe('A')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('ignores prepositions and punctuation', () => {
|
|
16
|
+
expect(authorInitials('Maria da Silva')).toBe('MS')
|
|
17
|
+
expect(authorInitials('João de Souza e Silva')).toBe('JS')
|
|
18
|
+
expect(authorInitials(' (Ana) - Souza. ')).toBe('AS')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('keeps an accented initial and upper-cases it', () => {
|
|
22
|
+
expect(authorInitials('élida ávila')).toBe('ÉÁ')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('keeps the first word even when it looks like a particle', () => {
|
|
26
|
+
expect(authorInitials('Le Minh')).toBe('LM')
|
|
27
|
+
expect(authorInitials('Van Morrison')).toBe('VM')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('composes accents before taking the initial', () => {
|
|
31
|
+
expect(authorInitials('e\u0301lida')).toBe('\u00c9')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('falls back to the first word when only prepositions remain', () => {
|
|
35
|
+
expect(authorInitials('de da')).toBe('D')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns undefined for missing, empty, question-mark or emoji-only names', () => {
|
|
39
|
+
expect(authorInitials(null)).toBeUndefined()
|
|
40
|
+
expect(authorInitials(undefined)).toBeUndefined()
|
|
41
|
+
expect(authorInitials('')).toBeUndefined()
|
|
42
|
+
expect(authorInitials(' ')).toBeUndefined()
|
|
43
|
+
expect(authorInitials('?')).toBeUndefined()
|
|
44
|
+
expect(authorInitials('😀')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('ignores a trailing emoji', () => {
|
|
48
|
+
expect(authorInitials('Ana 😀')).toBe('A')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('shouldShowAvatar', () => {
|
|
53
|
+
it('shows it on the first message of a sequence', () => {
|
|
54
|
+
expect(shouldShowAvatar({ previousAuthor: undefined, author: 'Ana', isMine: false })).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('hides it while the same author keeps talking', () => {
|
|
58
|
+
expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Ana', isMine: false })).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('shows it again when the author changes', () => {
|
|
62
|
+
expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Bruno', isMine: false })).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('treats consecutive unnamed authors as one sequence', () => {
|
|
66
|
+
expect(shouldShowAvatar({ previousAuthor: null, author: null, isMine: false })).toBe(false)
|
|
67
|
+
expect(shouldShowAvatar({ previousAuthor: 'Ana', author: null, isMine: false })).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('never shows it on an own message', () => {
|
|
71
|
+
expect(shouldShowAvatar({ previousAuthor: undefined, author: null, isMine: true })).toBe(false)
|
|
72
|
+
expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Ana', isMine: true })).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const NAME_PARTICLES = new Set([
|
|
2
|
+
'a',
|
|
3
|
+
'e',
|
|
4
|
+
'o',
|
|
5
|
+
'da',
|
|
6
|
+
'das',
|
|
7
|
+
'de',
|
|
8
|
+
'del',
|
|
9
|
+
'di',
|
|
10
|
+
'do',
|
|
11
|
+
'dos',
|
|
12
|
+
'du',
|
|
13
|
+
'la',
|
|
14
|
+
'le',
|
|
15
|
+
'van',
|
|
16
|
+
'von',
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
export type ParticipantAvatarAuthor = string | null
|
|
20
|
+
|
|
21
|
+
export type ShouldShowAvatarParams = {
|
|
22
|
+
/** Author of the previous message; undefined at the start of the list or right after an own message. */
|
|
23
|
+
readonly previousAuthor: ParticipantAvatarAuthor | undefined
|
|
24
|
+
readonly author: ParticipantAvatarAuthor
|
|
25
|
+
readonly isMine: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lettersOf(word: string): string {
|
|
29
|
+
return word.replace(/[^\p{L}\p{M}]/gu, '')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function firstLetter(word: string): string {
|
|
33
|
+
return (Array.from(word.normalize('NFC'))[0] ?? '').toUpperCase()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Up to two initials; undefined when the name has no letters, so the caller draws the neutral icon. */
|
|
37
|
+
export function authorInitials(name: string | null | undefined): string | undefined {
|
|
38
|
+
const [first, ...rest] = (name ?? '').split(/\s+/).map(lettersOf).filter(Boolean)
|
|
39
|
+
if (first === undefined) return undefined
|
|
40
|
+
const last = rest.filter((word) => !NAME_PARTICLES.has(word.toLowerCase())).pop()
|
|
41
|
+
return `${firstLetter(first)}${last === undefined ? '' : firstLetter(last)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function shouldShowAvatar({ previousAuthor, author, isMine }: ShouldShowAvatarParams): boolean {
|
|
45
|
+
if (isMine) return false
|
|
46
|
+
if (previousAuthor === undefined) return true
|
|
47
|
+
return previousAuthor !== author
|
|
48
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
|
|
6
|
+
import { splitBlocks } from '../cssBlocks.test-helper'
|
|
7
|
+
import { buildMessage, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { ParticipantMessageBubble, type ParticipantMessageBubbleProps } from './ParticipantMessageBubble'
|
|
10
|
+
|
|
11
|
+
const CSS = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
12
|
+
|
|
13
|
+
function declarationsOf(selector: string): Map<string, string> {
|
|
14
|
+
const declarations = new Map<string, string>()
|
|
15
|
+
for (const { prelude, body } of splitBlocks(CSS)) {
|
|
16
|
+
if (body === undefined || prelude.startsWith('@')) continue
|
|
17
|
+
if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
|
|
18
|
+
for (const declaration of body.split(';')) {
|
|
19
|
+
const separator = declaration.indexOf(':')
|
|
20
|
+
if (separator === -1) continue
|
|
21
|
+
declarations.set(declaration.slice(0, separator).trim().toLowerCase(), declaration.slice(separator + 1).trim())
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return declarations
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function render(direction: 'inbound' | 'outbound', extra: Partial<ParticipantMessageBubbleProps> = {}): string {
|
|
28
|
+
return renderToStaticMarkup(
|
|
29
|
+
<ParticipantMessageBubble
|
|
30
|
+
item={{ kind: 'server', message: buildMessage({ id: 'm1', direction }) }}
|
|
31
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
32
|
+
confirmsRead
|
|
33
|
+
resolveAttachmentUrl={async () => 'https://files.example/a.png'}
|
|
34
|
+
{...extra}
|
|
35
|
+
/>,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('bubble tail CSS', () => {
|
|
40
|
+
it('draws two decorative triangles that never catch touches', () => {
|
|
41
|
+
for (const pseudo of ['::before', '::after']) {
|
|
42
|
+
const tail = declarationsOf(`.cv-p-bubble${pseudo}`)
|
|
43
|
+
expect(tail.get('content')).toBe("''")
|
|
44
|
+
expect(tail.get('pointer-events')).toBe('none')
|
|
45
|
+
expect(tail.get('position')).toBe('absolute')
|
|
46
|
+
expect(tail.get('border-style')).toBe('solid')
|
|
47
|
+
}
|
|
48
|
+
expect(declarationsOf('.cv-p-bubble').get('position')).toBe('relative')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('puts the received tail on the left, edge colour under fill colour', () => {
|
|
52
|
+
expect(declarationsOf('.cv-p-bubble::before').get('left')).toBe('-10px')
|
|
53
|
+
expect(declarationsOf('.cv-p-bubble::after').get('left')).toBe('-8px')
|
|
54
|
+
expect(declarationsOf('.cv-p-bubble::before').get('border-bottom-color')).toBe('var(--cv-p-tail-edge)')
|
|
55
|
+
expect(declarationsOf('.cv-p-bubble::after').get('border-bottom-color')).toBe('var(--cv-p-tail-fill)')
|
|
56
|
+
expect(declarationsOf('.cv-p-bubble').get('border-bottom-left-radius')).toBe('0')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('mirrors the own tail to the right with the own tokens', () => {
|
|
60
|
+
expect(declarationsOf('.cv-p-bubble--mine::before').get('right')).toBe('-10px')
|
|
61
|
+
expect(declarationsOf('.cv-p-bubble--mine::after').get('right')).toBe('-8px')
|
|
62
|
+
const mine = declarationsOf('.cv-p-bubble--mine')
|
|
63
|
+
expect(mine.get('border-bottom-right-radius')).toBe('0')
|
|
64
|
+
expect(mine.get('--cv-p-tail-edge')).toBe('var(--cv-p-i-accent)')
|
|
65
|
+
expect(mine.get('--cv-p-tail-fill')).toBe('var(--cv-p-i-highlight)')
|
|
66
|
+
expect(declarationsOf('.cv-p-bubble').get('--cv-p-tail-edge')).toBe('var(--cv-p-i-border)')
|
|
67
|
+
expect(declarationsOf('.cv-p-bubble').get('--cv-p-tail-fill')).toBe('var(--cv-p-i-surface-raised)')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('turns the tail off and restores the corner with --no-tail', () => {
|
|
71
|
+
expect(declarationsOf('.cv-p-bubble--no-tail::before').get('content')).toBe('none')
|
|
72
|
+
expect(declarationsOf('.cv-p-bubble--no-tail::after').get('content')).toBe('none')
|
|
73
|
+
expect(declarationsOf('.cv-p-bubble--no-tail').get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('uses no fixed colour for the tail', () => {
|
|
77
|
+
const tailRules = splitBlocks(CSS).filter(({ prelude }) => /cv-p-bubble[^,{]*::(before|after)/.test(prelude))
|
|
78
|
+
expect(tailRules.length).toBeGreaterThan(0)
|
|
79
|
+
for (const { body } of tailRules) expect(body ?? '').not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('bubble tail markup', () => {
|
|
84
|
+
it('keeps the default markup free of any new class', () => {
|
|
85
|
+
expect(render('outbound')).toContain('<div class="cv-p-bubble">')
|
|
86
|
+
expect(render('inbound')).toContain('<div class="cv-p-bubble cv-p-bubble--mine">')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('adds --no-tail only when tail is false, on both sides', () => {
|
|
90
|
+
expect(render('outbound', { tail: false })).toContain('class="cv-p-bubble cv-p-bubble--no-tail"')
|
|
91
|
+
expect(render('inbound', { tail: false })).toContain('class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"')
|
|
92
|
+
expect(render('outbound', { tail: true })).not.toContain('--no-tail')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('keeps the content and the avatar row unchanged by the tail flag', () => {
|
|
96
|
+
const withAvatar = render('outbound', { avatar: <b>A</b>, tail: false })
|
|
97
|
+
expect(withAvatar).toContain('cv-p-bubble-row')
|
|
98
|
+
expect(withAvatar).toContain('cv-p-bubble--no-tail')
|
|
99
|
+
expect(render('outbound', { tail: false })).toContain('cv-p-bubble__meta')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('stays inside the participant namespace', () => {
|
|
103
|
+
const markup = render('outbound', { tail: false })
|
|
104
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
105
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { declarationsIn, mediaBody } from './participantCss.test-helper'
|
|
5
|
+
import {
|
|
6
|
+
buildConversation,
|
|
7
|
+
buildMessage,
|
|
8
|
+
findForeignClassTokens,
|
|
9
|
+
findUtilityClassTokens,
|
|
10
|
+
} from './participantFixtures.test-helper'
|
|
11
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
12
|
+
import { ParticipantComposer } from './ParticipantComposer'
|
|
13
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
14
|
+
|
|
15
|
+
const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
16
|
+
|
|
17
|
+
function renderThread(): string {
|
|
18
|
+
return renderToStaticMarkup(
|
|
19
|
+
<ParticipantThread
|
|
20
|
+
conversation={buildConversation({ subjectId: '1' })}
|
|
21
|
+
items={[
|
|
22
|
+
{ kind: 'server', message: buildMessage({ id: 'a', text: 'Hello', createdAt: '2026-10-01T10:00:00.000Z' }) },
|
|
23
|
+
{ kind: 'server', message: buildMessage({ id: 'b', text: 'Again', createdAt: '2026-10-02T10:00:00.000Z' }) },
|
|
24
|
+
]}
|
|
25
|
+
hasMore={false}
|
|
26
|
+
labels={LABELS}
|
|
27
|
+
resolveAttachmentUrl={async () => 'https://files.example/a'}
|
|
28
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
29
|
+
onSend={() => undefined}
|
|
30
|
+
status="ready"
|
|
31
|
+
refresh={() => undefined}
|
|
32
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
33
|
+
newMessagesCount={0}
|
|
34
|
+
isSending={false}
|
|
35
|
+
/>,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderComposer(overrides: { value?: string } = {}): string {
|
|
40
|
+
return renderToStaticMarkup(
|
|
41
|
+
<ParticipantComposer
|
|
42
|
+
value={overrides.value ?? ''}
|
|
43
|
+
onChange={() => undefined}
|
|
44
|
+
files={[]}
|
|
45
|
+
onFilesChange={() => undefined}
|
|
46
|
+
onSend={() => undefined}
|
|
47
|
+
labels={LABELS}
|
|
48
|
+
quickReplies={[{ id: '1', title: 'Hi', shortcut: 'hi', body: 'Hi' }]}
|
|
49
|
+
/>,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('wallpaper', () => {
|
|
54
|
+
it('is drawn on the message scroller from the token, defaulting to a CSS-only pattern', () => {
|
|
55
|
+
const scroller = declarationsIn('.cv-p-thread__scroll')
|
|
56
|
+
expect(scroller.get('background-image')).toBe('var(--cv-p-wallpaper, var(--cv-p-i-wallpaper))')
|
|
57
|
+
const root = declarationsIn('.cv-p')
|
|
58
|
+
expect(root.get('--cv-p-i-wallpaper')).toContain('radial-gradient')
|
|
59
|
+
expect(root.get('--cv-p-i-wallpaper')).not.toContain('url(')
|
|
60
|
+
expect(root.get('--cv-p-i-wallpaper-ink')).toBe('var(--cv-p-i-border)')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('is switched off in forced colors and in print', () => {
|
|
64
|
+
for (const query of ['(forced-colors: active)', 'print']) {
|
|
65
|
+
expect(declarationsIn('.cv-p-thread__scroll', mediaBody(query) ?? '').get('background-image')).toBe('none')
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('uses logical properties for the text, the meta and the title button, so RTL mirrors', () => {
|
|
70
|
+
expect(declarationsIn('.cv-p-bubble > .cv-p-text').get('margin-inline-end')).toBe('auto')
|
|
71
|
+
expect(declarationsIn('.cv-p-thread__title-button').get('text-align')).toBe('start')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('restores the bubble corner radius in print, where the tail is off', () => {
|
|
75
|
+
const rule = declarationsIn('.cv-p-bubble', mediaBody('print') ?? '')
|
|
76
|
+
expect(rule.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
77
|
+
expect(declarationsIn('.cv-p-bubble--mine', mediaBody('print') ?? '').get('border-radius')).toBe(
|
|
78
|
+
'var(--cv-p-i-radius)',
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('never reaches the composer, the header or the bubbles', () => {
|
|
83
|
+
for (const selector of ['.cv-p-composer', '.cv-p-thread__header', '.cv-p-bubble']) {
|
|
84
|
+
expect(declarationsIn(selector).has('background-image')).toBe(false)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('day pill', () => {
|
|
90
|
+
it('is a separator holding a time with a machine date', () => {
|
|
91
|
+
expect(renderThread()).toMatch(
|
|
92
|
+
/<div class="cv-p-day" role="separator" aria-label="[^"]+"><time class="cv-p-day__label" dateTime="2026-10-01">/,
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('is a centred raised pill with a border and the radius token', () => {
|
|
97
|
+
const day = declarationsIn('.cv-p-day')
|
|
98
|
+
expect(day.get('align-self')).toBe('center')
|
|
99
|
+
expect(day.get('background')).toBe('var(--cv-p-i-surface-raised)')
|
|
100
|
+
expect(day.get('border')).toBe('1px solid var(--cv-p-i-border)')
|
|
101
|
+
expect(day.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
102
|
+
expect(day.get('font')).toContain('monospace')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('time and ticks at the foot of the bubble', () => {
|
|
107
|
+
it('keeps time and status together in the last element of the bubble', () => {
|
|
108
|
+
const markup = renderToStaticMarkup(
|
|
109
|
+
<ParticipantThread
|
|
110
|
+
conversation={buildConversation({ subjectId: '1' })}
|
|
111
|
+
items={[{ kind: 'server', message: buildMessage({ id: 'a', direction: 'inbound', text: 'Hello' }) }]}
|
|
112
|
+
hasMore={false}
|
|
113
|
+
labels={LABELS}
|
|
114
|
+
resolveAttachmentUrl={async () => ''}
|
|
115
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
116
|
+
onSend={() => undefined}
|
|
117
|
+
status="ready"
|
|
118
|
+
refresh={() => undefined}
|
|
119
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
120
|
+
newMessagesCount={0}
|
|
121
|
+
isSending={false}
|
|
122
|
+
/>,
|
|
123
|
+
)
|
|
124
|
+
const meta = markup.slice(
|
|
125
|
+
markup.indexOf('cv-p-bubble__meta'),
|
|
126
|
+
markup.indexOf('</span></div>', markup.indexOf('cv-p-bubble__meta')),
|
|
127
|
+
)
|
|
128
|
+
expect(meta).toContain('<time')
|
|
129
|
+
expect(meta).toContain('cv-p-bubble__status')
|
|
130
|
+
expect(meta.indexOf('<time')).toBeLessThan(meta.indexOf('cv-p-bubble__status'))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('lets the text and the meta share a line, and the meta fall to the right when it does not fit', () => {
|
|
134
|
+
const bubble = declarationsIn('.cv-p-bubble')
|
|
135
|
+
expect(bubble.get('display')).toBe('flex')
|
|
136
|
+
expect(bubble.get('flex-wrap')).toBe('wrap')
|
|
137
|
+
expect(bubble.get('align-items')).toBe('flex-end')
|
|
138
|
+
expect(bubble.get('max-width')).toBe('85%')
|
|
139
|
+
const meta = declarationsIn('.cv-p-bubble__meta')
|
|
140
|
+
expect(meta.get('margin-inline-start')).toBe('auto')
|
|
141
|
+
expect(meta.get('flex')).toBe('0 0 auto')
|
|
142
|
+
expect(declarationsIn('.cv-p-bubble > .cv-p-text').get('min-width')).toBe('0')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('keeps author, attachments and failed actions on their own full-width row', () => {
|
|
146
|
+
expect(declarationsIn('.cv-p-bubble__author').get('flex')).toBe('1 0 100%')
|
|
147
|
+
expect(declarationsIn('.cv-p-bubble > .cv-p-attachment').get('flex')).toBe('1 0 100%')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('composer', () => {
|
|
152
|
+
it('draws attach and send as lucide icons with accessible names and no text glyphs', () => {
|
|
153
|
+
const markup = renderComposer({ value: 'x' })
|
|
154
|
+
const attach = markup.slice(
|
|
155
|
+
markup.indexOf('cv-p-composer__attach'),
|
|
156
|
+
markup.indexOf('</button>', markup.indexOf('cv-p-composer__attach')),
|
|
157
|
+
)
|
|
158
|
+
expect(attach).toContain('aria-label="Attach"')
|
|
159
|
+
expect(attach).toContain('<svg')
|
|
160
|
+
const send = markup.slice(
|
|
161
|
+
markup.indexOf('type="submit"'),
|
|
162
|
+
markup.indexOf('</button>', markup.indexOf('type="submit"')),
|
|
163
|
+
)
|
|
164
|
+
expect(send).toContain('aria-label="Send"')
|
|
165
|
+
expect(send).toContain('<svg')
|
|
166
|
+
for (const glyph of ['+', '➤', '+']) expect(markup).not.toContain(glyph)
|
|
167
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
168
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('orders attach, field and send in one row, with the chips above it', () => {
|
|
172
|
+
const markup = renderComposer()
|
|
173
|
+
const order = ['cv-p-quick', 'cv-p-composer__row', 'cv-p-composer__attach', '<textarea', 'type="submit"']
|
|
174
|
+
const positions = order.map((token) => markup.indexOf(token))
|
|
175
|
+
expect(positions).toEqual([...positions].sort((a, b) => a - b))
|
|
176
|
+
expect(positions.every((position) => position >= 0)).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('aligns the row by the base, with a field that grows up to four lines and keeps 44px', () => {
|
|
180
|
+
expect(declarationsIn('.cv-p-composer__row').get('align-items')).toBe('flex-end')
|
|
181
|
+
const input = declarationsIn('.cv-p-composer__input')
|
|
182
|
+
expect(input.get('min-height')).toBe('var(--cv-p-i-touch)')
|
|
183
|
+
expect(input.get('max-height')).toBe('6.5rem')
|
|
184
|
+
expect(input.get('overflow-y')).toBe('auto')
|
|
185
|
+
expect(input.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
186
|
+
for (const selector of ['.cv-p-composer__attach']) {
|
|
187
|
+
expect(declarationsIn(selector).get('min-width')).toBe('var(--cv-p-i-touch)')
|
|
188
|
+
expect(declarationsIn(selector).get('min-height')).toBe('var(--cv-p-i-touch)')
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('sits on a raised bar with a top border; send is the primary button', () => {
|
|
193
|
+
const bar = declarationsIn('.cv-p-composer')
|
|
194
|
+
expect(bar.get('background')).toBe('var(--cv-p-i-surface-raised)')
|
|
195
|
+
expect(bar.get('border-top')).toBe('1px solid var(--cv-p-i-border)')
|
|
196
|
+
expect(renderComposer()).toContain('class="cv-p-button cv-p-button--primary cv-p-composer__send"')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
INITIAL_COPY_ANNOUNCEMENT,
|
|
5
|
+
copyAnnouncementReducer,
|
|
6
|
+
resolveLiveText,
|
|
7
|
+
type CopyAnnouncementState,
|
|
8
|
+
} from './participantCopyAnnouncement'
|
|
9
|
+
|
|
10
|
+
function announce(state: CopyAnnouncementState, text: string): CopyAnnouncementState {
|
|
11
|
+
return copyAnnouncementReducer(state, { type: 'announce', text })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('copy announcement', () => {
|
|
15
|
+
it('changes the tick on every announcement, so a repeat restarts the timer', () => {
|
|
16
|
+
const first = announce(INITIAL_COPY_ANNOUNCEMENT, 'Copied')
|
|
17
|
+
const second = announce(first, 'Copied')
|
|
18
|
+
expect(second.tick).not.toBe(first.tick)
|
|
19
|
+
expect(copyAnnouncementReducer(second, { type: 'expired' }).text).toBe('')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders two identical copies in a row as different region content', () => {
|
|
23
|
+
const first = announce(INITIAL_COPY_ANNOUNCEMENT, 'Copied')
|
|
24
|
+
const second = announce(first, 'Copied')
|
|
25
|
+
const shown = (copy: CopyAnnouncementState): string =>
|
|
26
|
+
resolveLiveText({ copy, notice: '', baselineNotice: undefined }).text
|
|
27
|
+
expect(shown(first)).not.toBe(shown(second))
|
|
28
|
+
expect(shown(first).replace('', '')).toBe('Copied')
|
|
29
|
+
expect(shown(second).replace('', '')).toBe('Copied')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('does not repeat an unchanged notice after the copy expires', () => {
|
|
33
|
+
const copying = resolveLiveText({
|
|
34
|
+
copy: announce(INITIAL_COPY_ANNOUNCEMENT, 'Copied'),
|
|
35
|
+
notice: 'New messages',
|
|
36
|
+
baselineNotice: undefined,
|
|
37
|
+
})
|
|
38
|
+
const expired = resolveLiveText({
|
|
39
|
+
copy: { text: '', tick: 1 },
|
|
40
|
+
notice: 'New messages',
|
|
41
|
+
baselineNotice: copying.baselineNotice,
|
|
42
|
+
})
|
|
43
|
+
expect(expired.text).toBe('')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('announces a notice that really changed, and the notice of a thread without copies', () => {
|
|
47
|
+
const changed = resolveLiveText({ copy: { text: '', tick: 1 }, notice: 'New messages', baselineNotice: '' })
|
|
48
|
+
expect(changed).toEqual({ text: 'New messages', baselineNotice: undefined })
|
|
49
|
+
const plain = resolveLiveText({ copy: INITIAL_COPY_ANNOUNCEMENT, notice: 'New messages', baselineNotice: undefined })
|
|
50
|
+
expect(plain.text).toBe('New messages')
|
|
51
|
+
})
|
|
52
|
+
})
|