@adatechnology/conversations-ui 0.6.1 → 0.8.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 +221 -19
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- 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.d.ts +4 -4
- package/dist/index.js +834 -764
- package/dist/participant/index.d.ts +62 -3
- package/dist/participant/index.js +1444 -394
- package/dist/preview/index.d.ts +3 -3
- package/dist/preview/index.js +2 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +629 -20
- package/package.json +3 -3
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- 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 +32 -84
- 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 +54 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +83 -0
- package/src/participant/ParticipantThreadScreen.tsx +30 -56
- package/src/participant/ParticipantTimeline.tsx +106 -0
- package/src/participant/conversationThreadApi.test.ts +27 -0
- package/src/participant/conversationThreadApi.ts +24 -0
- package/src/participant/conversationThreadCss.test.ts +116 -0
- package/src/participant/conversationThreadSummary.ts +34 -0
- package/src/participant/index.ts +4 -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/participantContrast.test-helper.ts +116 -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/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -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/participantMessages.ts +4 -3
- package/src/participant/participantNewMessages.test.ts +21 -0
- package/src/participant/participantNewMessages.ts +8 -3
- package/src/participant/participantPerspective.test.ts +67 -0
- package/src/participant/participantPerspective.ts +50 -0
- package/src/participant/participantPerspectiveRender.test.tsx +94 -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/participantThemeContrast.test.ts +98 -0
- package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
- package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -0
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +13 -3
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- 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 +151 -23
- package/dist/chunk-MRVFFJEH.js +0 -64
- package/dist/chunk-OVFCLRQQ.js +0 -84
|
@@ -5,6 +5,7 @@ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@ada
|
|
|
5
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
7
|
import type { ConversationsTheme } from '../types'
|
|
8
|
+
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
8
9
|
import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
|
|
9
10
|
import type {
|
|
10
11
|
ParticipantConversationsClassNames,
|
|
@@ -34,6 +35,12 @@ export type ParticipantConversationsProps = {
|
|
|
34
35
|
renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
35
36
|
renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
|
|
36
37
|
onOpenSubject?: (subject: ParticipantSubjectRef) => void
|
|
38
|
+
/** Absent draws no avatar. The SDK has no photo: the host supplies it through renderAuthorAvatar. */
|
|
39
|
+
avatars?: 'initials'
|
|
40
|
+
/** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
|
|
41
|
+
renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
42
|
+
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
43
|
+
tail?: boolean
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export function ParticipantConversations(props: ParticipantConversationsProps) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
5
|
+
import { buildConversation, buildMessage } from './participantFixtures.test-helper'
|
|
6
|
+
import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
|
|
7
|
+
import * as realThreadModule from './useParticipantThread'
|
|
8
|
+
|
|
9
|
+
const MESSAGES = [
|
|
10
|
+
buildMessage({ id: 'a', authorName: 'Ana Souza', createdAt: '2026-10-01T10:00:00.000Z' }),
|
|
11
|
+
buildMessage({ id: 'b', direction: 'inbound', createdAt: '2026-10-01T10:01:00.000Z' }),
|
|
12
|
+
]
|
|
13
|
+
const realUseParticipantThread = realThreadModule.useParticipantThread
|
|
14
|
+
let isInjecting = false
|
|
15
|
+
|
|
16
|
+
const API: ParticipantConversationsApi = {
|
|
17
|
+
listConversations: async () => ({ data: [] }),
|
|
18
|
+
fetchMessages: async () => [],
|
|
19
|
+
sendMessage: async () => ({ outcome: 'queued' }),
|
|
20
|
+
markRead: async () => undefined,
|
|
21
|
+
resolveAttachmentUrl: async () => 'https://files.example/a',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let ParticipantConversationsScreen: typeof import('./ParticipantConversationsScreen').ParticipantConversationsScreen
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
// Server rendering runs no effects, so the messages are injected over the real hook.
|
|
28
|
+
mock.module('./useParticipantThread', () => ({
|
|
29
|
+
...realThreadModule,
|
|
30
|
+
useParticipantThread: (...args: Parameters<typeof realUseParticipantThread>) => {
|
|
31
|
+
const result = realUseParticipantThread(...args)
|
|
32
|
+
return isInjecting ? { ...result, messages: MESSAGES, status: 'ready' as const } : result
|
|
33
|
+
},
|
|
34
|
+
}))
|
|
35
|
+
ParticipantConversationsScreen = (await import('./ParticipantConversationsScreen')).ParticipantConversationsScreen
|
|
36
|
+
isInjecting = true
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
isInjecting = false
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
function renderThread(overrides: Partial<ParticipantConversationsScreenProps> = {}): string {
|
|
44
|
+
return renderToStaticMarkup(
|
|
45
|
+
<ParticipantConversationsScreen
|
|
46
|
+
api={API}
|
|
47
|
+
subjectGroups={[{ subjectType: 'invoice', label: 'Invoices' }]}
|
|
48
|
+
selected={{ subjectType: 'invoice', subjectId: '1' }}
|
|
49
|
+
onSelect={() => undefined}
|
|
50
|
+
inbox={{
|
|
51
|
+
status: 'ready',
|
|
52
|
+
conversations: [buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })],
|
|
53
|
+
hasMore: false,
|
|
54
|
+
refresh: async () => undefined,
|
|
55
|
+
loadMore: async () => undefined,
|
|
56
|
+
markSubjectRead: () => undefined,
|
|
57
|
+
}}
|
|
58
|
+
{...overrides}
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('participant options reach the DOM through the screen', () => {
|
|
64
|
+
it('injects the messages (sanity)', () => {
|
|
65
|
+
expect(renderThread()).toContain('cv-p-bubble--mine')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('draws the group eyebrow from subjectGroups', () => {
|
|
69
|
+
expect(renderThread()).toContain('<p class="cv-p-thread__eyebrow">Invoices</p>')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('draws no avatar by default and the initials with avatars="initials"', () => {
|
|
73
|
+
expect(renderThread()).not.toContain('cv-p-avatar')
|
|
74
|
+
expect(renderThread({ avatars: 'initials' })).toContain('class="cv-p-avatar" aria-hidden="true">AS<')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('draws the host slot through renderAuthorAvatar', () => {
|
|
78
|
+
const markup = renderThread({ renderAuthorAvatar: () => <i>PHOTO</i> })
|
|
79
|
+
expect(markup).toContain('<i>PHOTO</i>')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keeps the tail by default and removes it from every bubble with tail={false}', () => {
|
|
83
|
+
expect(renderThread()).not.toContain('cv-p-bubble--no-tail')
|
|
84
|
+
const markup = renderThread({ tail: false })
|
|
85
|
+
expect(markup.match(/cv-p-bubble--no-tail/g)?.length).toBe(2)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useReducer, useRef, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
INITIAL_COPY_ANNOUNCEMENT,
|
|
5
|
+
copyAnnouncementReducer,
|
|
6
|
+
resolveLiveText,
|
|
7
|
+
type CopyAnnouncementState,
|
|
8
|
+
} from './participantCopyAnnouncement'
|
|
9
|
+
|
|
10
|
+
const ANNOUNCEMENT_MS = 3000
|
|
11
|
+
|
|
12
|
+
const AnnounceContext = createContext<(text: string) => void>(() => undefined)
|
|
13
|
+
const AnnouncementContext = createContext<CopyAnnouncementState>(INITIAL_COPY_ANNOUNCEMENT)
|
|
14
|
+
|
|
15
|
+
export function useAnnounceCopy(): (text: string) => void {
|
|
16
|
+
return useContext(AnnounceContext)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Holds the text of the conversation's single polite region; the copy buttons announce through it. */
|
|
20
|
+
export function ParticipantCopyAnnouncer({ children }: { readonly children: ReactNode }) {
|
|
21
|
+
const [announcement, dispatch] = useReducer(copyAnnouncementReducer, INITIAL_COPY_ANNOUNCEMENT)
|
|
22
|
+
const announce = useCallback((text: string): void => dispatch({ type: 'announce', text }), [])
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (announcement.text === '') return undefined
|
|
26
|
+
const timer = setTimeout(() => dispatch({ type: 'expired' }), ANNOUNCEMENT_MS)
|
|
27
|
+
return () => clearTimeout(timer)
|
|
28
|
+
}, [announcement.tick, announcement.text])
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<AnnounceContext.Provider value={announce}>
|
|
32
|
+
<AnnouncementContext.Provider value={announcement}>{children}</AnnouncementContext.Provider>
|
|
33
|
+
</AnnounceContext.Provider>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The one aria-live region of the thread: new-message notice, or the last copy announcement. */
|
|
38
|
+
export function ParticipantThreadLive({ notice }: { readonly notice: string }) {
|
|
39
|
+
const copy = useContext(AnnouncementContext)
|
|
40
|
+
const baselineNotice = useRef<string | undefined>(undefined)
|
|
41
|
+
const resolution = resolveLiveText({ copy, notice, baselineNotice: baselineNotice.current })
|
|
42
|
+
baselineNotice.current = resolution.baselineNotice
|
|
43
|
+
return (
|
|
44
|
+
<div className="cv-p-thread__live" role="status" aria-live="polite">
|
|
45
|
+
{resolution.text}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Check, Copy } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import { useParticipantCopyFeedback } from './useParticipantCopyFeedback'
|
|
4
|
+
import type { CopyableKind } from './participantMessageFormat.types'
|
|
5
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
6
|
+
|
|
7
|
+
type CopyLabels = Pick<ParticipantConversationsLabels, 'copyValue' | 'valueCopied'>
|
|
8
|
+
|
|
9
|
+
export type ParticipantCopyableTokenProps = {
|
|
10
|
+
readonly copyKind: CopyableKind
|
|
11
|
+
readonly value: string
|
|
12
|
+
readonly labels: CopyLabels
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ParticipantCopyableToken({ copyKind, value, labels }: ParticipantCopyableTokenProps) {
|
|
16
|
+
const { isCopied, copy } = useParticipantCopyFeedback()
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
className={`cv-p-copyable cv-p-copyable--${copyKind}`}
|
|
21
|
+
aria-label={`${labels.copyValue} ${value}`}
|
|
22
|
+
onClick={() => copy(value, labels.valueCopied)}
|
|
23
|
+
>
|
|
24
|
+
{value}
|
|
25
|
+
{isCopied ? (
|
|
26
|
+
<span className="cv-p-copyable__done" aria-hidden="true">
|
|
27
|
+
<Check size={12} aria-hidden={true} /> {labels.valueCopied}
|
|
28
|
+
</span>
|
|
29
|
+
) : null}
|
|
30
|
+
</button>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ParticipantLinkCopyButtonProps = {
|
|
35
|
+
readonly value: string
|
|
36
|
+
readonly labels: CopyLabels
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ParticipantLinkCopyButton({ value, labels }: ParticipantLinkCopyButtonProps) {
|
|
40
|
+
const { isCopied, copy } = useParticipantCopyFeedback()
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
className="cv-p-text__link-copy"
|
|
45
|
+
aria-label={`${labels.copyValue} ${value}`}
|
|
46
|
+
onClick={() => copy(value, labels.valueCopied)}
|
|
47
|
+
>
|
|
48
|
+
{isCopied ? <Check size={14} aria-hidden={true} /> : <Copy size={14} aria-hidden={true} />}
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { toDateTimeAttribute } from './participantDay'
|
|
2
|
+
|
|
3
|
+
export type ParticipantDayDividerProps = {
|
|
4
|
+
readonly day: Date
|
|
5
|
+
readonly locale?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ParticipantDayDivider({ day, locale }: ParticipantDayDividerProps) {
|
|
9
|
+
const label = day.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit' })
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="cv-p-day" role="separator" aria-label={label}>
|
|
13
|
+
<time className="cv-p-day__label" dateTime={toDateTimeAttribute(day)}>
|
|
14
|
+
{label}
|
|
15
|
+
</time>
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -12,6 +12,7 @@ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadVi
|
|
|
12
12
|
import { ParticipantInboxRow } from './ParticipantInboxRow'
|
|
13
13
|
import { ParticipantInboxSearchField, type ParticipantInboxSearch } from './ParticipantInboxSearchField'
|
|
14
14
|
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
15
|
+
import { useRevealActiveChip } from './useRevealActiveChip'
|
|
15
16
|
|
|
16
17
|
export type ParticipantInboxProps = {
|
|
17
18
|
readonly view: ParticipantInboxView
|
|
@@ -54,8 +55,9 @@ type FiltersProps = {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
function Filters({ filters, active, labels, onChange }: FiltersProps) {
|
|
58
|
+
const strip = useRevealActiveChip(active)
|
|
57
59
|
return (
|
|
58
|
-
<div className="cv-p-filters" role="group" aria-label={labels.filtersGroup}>
|
|
60
|
+
<div className="cv-p-filters" role="group" aria-label={labels.filtersGroup} ref={strip}>
|
|
59
61
|
{filters.map((filter) => (
|
|
60
62
|
<button
|
|
61
63
|
key={filter.subjectType}
|
|
@@ -4,6 +4,7 @@ import { formatTimestamp, isSameDay } from '../lib/format'
|
|
|
4
4
|
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
5
5
|
import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
|
|
6
6
|
import { ParticipantChannelBadges } from './ParticipantChannelBadges'
|
|
7
|
+
import { isDrawable, resolveConversationIcon } from './participantSubjectIcon'
|
|
7
8
|
|
|
8
9
|
export type ParticipantInboxRowProps = {
|
|
9
10
|
readonly conversation: ParticipantConversationSummary
|
|
@@ -21,17 +22,18 @@ function formatRowTime(iso: string, locale: string | undefined): string {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
function RowIcon({ props, kind }: { readonly props: ParticipantInboxRowProps; readonly kind: string }) {
|
|
24
|
-
const
|
|
25
|
-
|
|
25
|
+
const { conversation, group, renderSubjectIcon } = props
|
|
26
|
+
const hostIcon = renderSubjectIcon?.(conversation)
|
|
27
|
+
if (isDrawable(hostIcon)) {
|
|
26
28
|
return (
|
|
27
29
|
<span className="cv-p-row__icon cv-p-inbox__icon" aria-hidden="true">
|
|
28
|
-
{
|
|
30
|
+
{hostIcon}
|
|
29
31
|
</span>
|
|
30
32
|
)
|
|
31
33
|
}
|
|
32
34
|
return (
|
|
33
35
|
<span className="cv-p-row__icon" aria-hidden="true">
|
|
34
|
-
{
|
|
36
|
+
{resolveConversationIcon({ conversation, group }) ?? kind.slice(0, 2)}
|
|
35
37
|
</span>
|
|
36
38
|
)
|
|
37
39
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
2
3
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
4
|
|
|
4
5
|
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
@@ -9,7 +10,10 @@ import { ParticipantMessageBubble } from './ParticipantMessageBubble'
|
|
|
9
10
|
const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
10
11
|
const resolveAttachmentUrl = async (): Promise<string> => 'https://files.example/a.png'
|
|
11
12
|
|
|
12
|
-
function renderServer(
|
|
13
|
+
function renderServer(
|
|
14
|
+
message = buildMessage({ id: 'm1' }),
|
|
15
|
+
extra: { onRetry?: () => void; confirmsRead?: boolean } = {},
|
|
16
|
+
): string {
|
|
13
17
|
const item: ParticipantTimelineItem = { kind: 'server', message }
|
|
14
18
|
return renderToStaticMarkup(
|
|
15
19
|
<ParticipantMessageBubble
|
|
@@ -82,14 +86,18 @@ describe('ParticipantMessageBubble', () => {
|
|
|
82
86
|
})
|
|
83
87
|
|
|
84
88
|
it('downgrades read to delivered when the channel does not confirm reads', () => {
|
|
85
|
-
const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'read' }), {
|
|
89
|
+
const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'read' }), {
|
|
90
|
+
confirmsRead: false,
|
|
91
|
+
})
|
|
86
92
|
|
|
87
93
|
expect(markup).toContain('cv-status-ticks--delivered')
|
|
88
94
|
expect(markup).not.toContain('cv-status-ticks--read')
|
|
89
95
|
})
|
|
90
96
|
|
|
91
97
|
it('shows no status on the other side', () => {
|
|
92
|
-
expect(renderServer(buildMessage({ id: 'a', direction: 'outbound', status: 'read' }))).not.toContain(
|
|
98
|
+
expect(renderServer(buildMessage({ id: 'a', direction: 'outbound', status: 'read' }))).not.toContain(
|
|
99
|
+
'cv-status-ticks',
|
|
100
|
+
)
|
|
93
101
|
})
|
|
94
102
|
|
|
95
103
|
it('renders pending sending and queued with their texts', () => {
|
|
@@ -152,9 +160,9 @@ describe('ParticipantMessageBubble', () => {
|
|
|
152
160
|
it('o texto do participante não é botão nem copia ao toque', () => {
|
|
153
161
|
const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound' }))
|
|
154
162
|
|
|
155
|
-
expect(markup).toContain('class="cv-
|
|
163
|
+
expect(markup).toContain('class="cv-p-text"')
|
|
156
164
|
expect(markup).not.toContain('role="button"')
|
|
157
|
-
expect(markup).not.toContain('cv-
|
|
165
|
+
expect(markup).not.toContain('cv-p-text--copyable')
|
|
158
166
|
})
|
|
159
167
|
|
|
160
168
|
it('uses only cv-p-* classes and no Tailwind utilities', () => {
|
|
@@ -165,3 +173,42 @@ describe('ParticipantMessageBubble', () => {
|
|
|
165
173
|
expect(markup).toContain('cv-p-')
|
|
166
174
|
})
|
|
167
175
|
})
|
|
176
|
+
|
|
177
|
+
describe('ParticipantMessageBubble avatar slot', () => {
|
|
178
|
+
const GOLDEN_RECEIVED =
|
|
179
|
+
'<div class="cv-p-bubble"><span class="cv-p-bubble__author">Ana Souza</span><div class="cv-p-text">Hi</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T10:00:00.000Z">HH:MM</time></span></div>'
|
|
180
|
+
|
|
181
|
+
function renderWith(direction: 'inbound' | 'outbound', avatar?: ReactNode): string {
|
|
182
|
+
const message = buildMessage({ id: 'm', direction, authorName: 'Ana Souza', text: 'Hi' })
|
|
183
|
+
return renderToStaticMarkup(
|
|
184
|
+
<ParticipantMessageBubble
|
|
185
|
+
item={{ kind: 'server', message }}
|
|
186
|
+
labels={LABELS}
|
|
187
|
+
confirmsRead
|
|
188
|
+
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
189
|
+
avatar={avatar}
|
|
190
|
+
/>,
|
|
191
|
+
).replace(/>\d\d:\d\d</, '>HH:MM<')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
it('keeps the markup identical to the one before the prop existed when avatar is absent', () => {
|
|
195
|
+
expect(renderWith('outbound')).toBe(GOLDEN_RECEIVED)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('puts the avatar to the left of a received bubble inside a row', () => {
|
|
199
|
+
const markup = renderWith('outbound', <span className="cv-p-avatar">AS</span>)
|
|
200
|
+
expect(markup).toBe(
|
|
201
|
+
`<div class="cv-p-bubble-row"><span class="cv-p-bubble-row__avatar"><span class="cv-p-avatar">AS</span></span>${GOLDEN_RECEIVED}</div>`,
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('reserves the avatar space when the slot is null', () => {
|
|
206
|
+
expect(renderWith('outbound', null)).toContain('<span class="cv-p-bubble-row__avatar"></span>')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('never draws the avatar on an own message', () => {
|
|
210
|
+
const own = renderWith('inbound', <span className="cv-p-avatar">AS</span>)
|
|
211
|
+
expect(own).not.toContain('cv-p-avatar')
|
|
212
|
+
expect(own).not.toContain('cv-p-bubble-row')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
1
3
|
import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
2
4
|
|
|
3
|
-
import { MessageText } from '../MessageText'
|
|
4
|
-
import { StatusTicks } from '../StatusTicks'
|
|
5
5
|
import { formatTimestamp } from '../lib/format'
|
|
6
|
+
import { FailedActions, OwnStatus } from './ParticipantBubbleStatus'
|
|
7
|
+
import { ParticipantMessageText } from './ParticipantMessageText'
|
|
6
8
|
import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
7
9
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
10
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
8
11
|
import {
|
|
9
12
|
isOwnMessage,
|
|
10
13
|
resolveOwnMessageStatus,
|
|
11
|
-
type ParticipantOwnMessageStatus,
|
|
12
14
|
type ParticipantPendingDisplayState,
|
|
13
15
|
type ParticipantTimelineItem,
|
|
14
16
|
} from './participantMessages'
|
|
@@ -24,6 +26,12 @@ export type ParticipantMessageBubbleProps = {
|
|
|
24
26
|
readonly onDiscard?: () => void
|
|
25
27
|
/** Absent means no edit button. */
|
|
26
28
|
readonly onEdit?: () => void
|
|
29
|
+
/** Opt-in slot to the left of a received bubble; null reserves the space, absent changes nothing. */
|
|
30
|
+
readonly avatar?: ReactNode
|
|
31
|
+
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
32
|
+
readonly tail?: boolean
|
|
33
|
+
/** Who is looking; absent is the participant. The operator owns the outbound messages. */
|
|
34
|
+
readonly perspective?: ParticipantPerspective
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
type BubbleContent = {
|
|
@@ -37,11 +45,11 @@ type BubbleContent = {
|
|
|
37
45
|
readonly serverStatus?: MessageDeliveryStatus
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
function describeItem(item: ParticipantTimelineItem): BubbleContent {
|
|
48
|
+
function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): BubbleContent {
|
|
41
49
|
if (item.kind === 'server') {
|
|
42
50
|
const { message } = item
|
|
43
51
|
return {
|
|
44
|
-
isMine: isOwnMessage(message),
|
|
52
|
+
isMine: isOwnMessage(message, perspective),
|
|
45
53
|
authorName: message.authorName ?? undefined,
|
|
46
54
|
text: message.text ?? undefined,
|
|
47
55
|
createdAt: message.createdAt,
|
|
@@ -60,79 +68,11 @@ function describeItem(item: ParticipantTimelineItem): BubbleContent {
|
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
function
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
delivered: labels.statusDelivered,
|
|
69
|
-
read: labels.statusRead,
|
|
70
|
-
failed: labels.statusFailed,
|
|
71
|
-
}
|
|
72
|
-
return byStatus[status]
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
type OwnStatusProps = {
|
|
76
|
-
readonly status: ParticipantOwnMessageStatus
|
|
77
|
-
readonly labels: ParticipantConversationsLabels
|
|
78
|
-
readonly onRetry?: () => void
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
|
|
82
|
-
const ticksStatus = status === 'sending' ? 'queued' : status
|
|
83
|
-
const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
|
|
84
|
-
|
|
85
|
-
if (status === 'failed' && onRetry) {
|
|
86
|
-
return (
|
|
87
|
-
<button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
|
|
88
|
-
<StatusTicks status="failed" appearance="stylesheet" />
|
|
89
|
-
<span>{labels.statusFailed}</span>
|
|
90
|
-
</button>
|
|
91
|
-
)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
|
|
95
|
-
return (
|
|
96
|
-
<span className="cv-p-bubble__status">
|
|
97
|
-
<StatusTicks status={ticksStatus} appearance="stylesheet" />
|
|
98
|
-
<span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
|
|
99
|
-
</span>
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
type FailedActionsProps = {
|
|
104
|
-
readonly labels: ParticipantConversationsLabels
|
|
105
|
-
readonly onDiscard?: () => void
|
|
106
|
-
readonly onEdit?: () => void
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
|
|
110
|
-
if (!onDiscard && !onEdit) return null
|
|
111
|
-
return (
|
|
112
|
-
<span className="cv-p-bubble__actions">
|
|
113
|
-
{onEdit ? (
|
|
114
|
-
<button type="button" className="cv-p-bubble__action" onClick={onEdit}>
|
|
115
|
-
{labels.edit}
|
|
116
|
-
</button>
|
|
117
|
-
) : null}
|
|
118
|
-
{onDiscard ? (
|
|
119
|
-
<button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
|
|
120
|
-
{labels.discard}
|
|
121
|
-
</button>
|
|
122
|
-
) : null}
|
|
123
|
-
</span>
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function toTextPayload(text: string, createdAt: string, isMine: boolean) {
|
|
128
|
-
return {
|
|
129
|
-
id: createdAt,
|
|
130
|
-
type: 'text' as const,
|
|
131
|
-
content: text,
|
|
132
|
-
direction: isMine ? ('inbound' as const) : ('outbound' as const),
|
|
133
|
-
sender: isMine ? ('customer' as const) : ('agent' as const),
|
|
134
|
-
timestamp: createdAt,
|
|
135
|
-
}
|
|
71
|
+
function bubbleClassName(isMine: boolean, hasTail: boolean): string {
|
|
72
|
+
const classes = ['cv-p-bubble']
|
|
73
|
+
if (isMine) classes.push('cv-p-bubble--mine')
|
|
74
|
+
if (!hasTail) classes.push('cv-p-bubble--no-tail')
|
|
75
|
+
return classes.join(' ')
|
|
136
76
|
}
|
|
137
77
|
|
|
138
78
|
export function ParticipantMessageBubble({
|
|
@@ -143,23 +83,24 @@ export function ParticipantMessageBubble({
|
|
|
143
83
|
onRetry,
|
|
144
84
|
onDiscard,
|
|
145
85
|
onEdit,
|
|
86
|
+
avatar,
|
|
87
|
+
tail = true,
|
|
88
|
+
perspective,
|
|
146
89
|
}: ParticipantMessageBubbleProps) {
|
|
147
|
-
const content = describeItem(item)
|
|
90
|
+
const content = describeItem(item, perspective)
|
|
148
91
|
const { isMine } = content
|
|
149
92
|
const ownStatus = isMine
|
|
150
93
|
? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
|
|
151
94
|
: undefined
|
|
152
95
|
|
|
153
|
-
|
|
154
|
-
<div className={isMine
|
|
96
|
+
const bubble = (
|
|
97
|
+
<div className={bubbleClassName(isMine, tail)}>
|
|
155
98
|
{isMine ? (
|
|
156
99
|
<span className="cv-p-sr-only">{labels.me}</span>
|
|
157
100
|
) : content.authorName ? (
|
|
158
101
|
<span className="cv-p-bubble__author">{content.authorName}</span>
|
|
159
102
|
) : null}
|
|
160
|
-
{content.text ?
|
|
161
|
-
<MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} appearance="stylesheet" />
|
|
162
|
-
) : null}
|
|
103
|
+
{content.text ? <ParticipantMessageText text={content.text} labels={labels} /> : null}
|
|
163
104
|
{content.attachments.map((attachment) => (
|
|
164
105
|
<ParticipantAttachmentItem
|
|
165
106
|
key={attachment.id}
|
|
@@ -182,4 +123,11 @@ export function ParticipantMessageBubble({
|
|
|
182
123
|
{ownStatus === 'failed' ? <FailedActions labels={labels} onDiscard={onDiscard} onEdit={onEdit} /> : null}
|
|
183
124
|
</div>
|
|
184
125
|
)
|
|
126
|
+
if (avatar === undefined || isMine) return bubble
|
|
127
|
+
return (
|
|
128
|
+
<div className="cv-p-bubble-row">
|
|
129
|
+
<span className="cv-p-bubble-row__avatar">{avatar}</span>
|
|
130
|
+
{bubble}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
185
133
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { ParticipantCopyableToken, ParticipantLinkCopyButton } from './ParticipantCopyableToken'
|
|
4
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
5
|
+
import type { InlineNode } from './participantMessageFormat.types'
|
|
6
|
+
|
|
7
|
+
export type ParticipantTextLabels = Pick<
|
|
8
|
+
ParticipantConversationsLabels,
|
|
9
|
+
'externalLinkHint' | 'copyValue' | 'valueCopied'
|
|
10
|
+
>
|
|
11
|
+
|
|
12
|
+
const LINK_REL = 'noopener noreferrer nofollow ugc'
|
|
13
|
+
|
|
14
|
+
function renderLink(
|
|
15
|
+
node: Extract<InlineNode, { kind: 'link' }>,
|
|
16
|
+
labels: ParticipantTextLabels,
|
|
17
|
+
key: number,
|
|
18
|
+
): ReactNode {
|
|
19
|
+
const isExternal = /^https?:/i.test(node.href)
|
|
20
|
+
return (
|
|
21
|
+
<span className="cv-p-text__link-group" key={key}>
|
|
22
|
+
<a className="cv-p-text__link" href={node.href} rel={LINK_REL} target={isExternal ? '_blank' : undefined}>
|
|
23
|
+
{node.label}
|
|
24
|
+
{isExternal ? <span className="cv-p-sr-only"> ({labels.externalLinkHint})</span> : null}
|
|
25
|
+
</a>
|
|
26
|
+
<ParticipantLinkCopyButton value={node.href} labels={labels} />
|
|
27
|
+
</span>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderInlineNodes(nodes: readonly InlineNode[], labels: ParticipantTextLabels): ReactNode[] {
|
|
32
|
+
return nodes.map((node, key): ReactNode => {
|
|
33
|
+
switch (node.kind) {
|
|
34
|
+
case 'text':
|
|
35
|
+
return node.value
|
|
36
|
+
case 'strong':
|
|
37
|
+
return <strong key={key}>{renderInlineNodes(node.children, labels)}</strong>
|
|
38
|
+
case 'em':
|
|
39
|
+
return <em key={key}>{renderInlineNodes(node.children, labels)}</em>
|
|
40
|
+
case 'del':
|
|
41
|
+
return <del key={key}>{renderInlineNodes(node.children, labels)}</del>
|
|
42
|
+
case 'code':
|
|
43
|
+
return (
|
|
44
|
+
<code className="cv-p-text__code" key={key}>
|
|
45
|
+
{node.value}
|
|
46
|
+
</code>
|
|
47
|
+
)
|
|
48
|
+
case 'link':
|
|
49
|
+
return renderLink(node, labels, key)
|
|
50
|
+
case 'copyable':
|
|
51
|
+
return <ParticipantCopyableToken key={key} copyKind={node.copyKind} value={node.value} labels={labels} />
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|