@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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { buildConversation, buildMessage } from './participantFixtures.test-helper'
|
|
5
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
6
|
+
import type { ParticipantTimelineItem } from './participantMessages'
|
|
7
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
8
|
+
|
|
9
|
+
function ana(id: string, createdAt: string): ParticipantTimelineItem {
|
|
10
|
+
return { kind: 'server', message: buildMessage({ id, authorName: 'Ana Souza', createdAt }) }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mine(id: string, createdAt: string): ParticipantTimelineItem {
|
|
14
|
+
return { kind: 'server', message: buildMessage({ id, direction: 'inbound', authorName: 'Ana Souza', createdAt }) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function render(items: readonly ParticipantTimelineItem[]): string {
|
|
18
|
+
return renderToStaticMarkup(
|
|
19
|
+
<ParticipantThread
|
|
20
|
+
conversation={buildConversation({ subjectId: '1' })}
|
|
21
|
+
items={items}
|
|
22
|
+
hasMore={false}
|
|
23
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
24
|
+
resolveAttachmentUrl={async () => 'https://files.example/a'}
|
|
25
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
26
|
+
onSend={() => undefined}
|
|
27
|
+
status="ready"
|
|
28
|
+
refresh={() => undefined}
|
|
29
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
30
|
+
newMessagesCount={0}
|
|
31
|
+
isSending={false}
|
|
32
|
+
avatars="initials"
|
|
33
|
+
/>,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function countAvatars(markup: string): number {
|
|
38
|
+
return markup.match(/class="cv-p-avatar" aria-hidden="true">AS</g)?.length ?? 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('avatar sequence in the thread', () => {
|
|
42
|
+
it('shows one avatar for consecutive messages of the same author', () => {
|
|
43
|
+
const markup = render([ana('a', '2026-10-01T10:00:00.000Z'), ana('b', '2026-10-01T10:01:00.000Z')])
|
|
44
|
+
expect(countAvatars(markup)).toBe(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('shows the avatar again after an own message of the same author in between', () => {
|
|
48
|
+
const markup = render([
|
|
49
|
+
ana('a', '2026-10-01T10:00:00.000Z'),
|
|
50
|
+
mine('b', '2026-10-01T10:01:00.000Z'),
|
|
51
|
+
ana('c', '2026-10-01T10:02:00.000Z'),
|
|
52
|
+
])
|
|
53
|
+
expect(countAvatars(markup)).toBe(2)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows the avatar again after the day separator', () => {
|
|
57
|
+
const markup = render([ana('a', '2026-10-01T10:00:00.000Z'), ana('b', '2026-10-03T10:00:00.000Z')])
|
|
58
|
+
expect(markup).toMatch(/role="separator" aria-label="[^"]+"/)
|
|
59
|
+
expect(countAvatars(markup)).toBe(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('never draws the avatar on own messages', () => {
|
|
63
|
+
expect(render([mine('a', '2026-10-01T10:00:00.000Z')])).not.toContain('cv-p-avatar')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
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 { buildConversation } from './participantFixtures.test-helper'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { ParticipantProtocolBadgeView } from './ParticipantProtocolBadge'
|
|
10
|
+
import { ParticipantThreadHeader, type ParticipantThreadHeaderProps } from './ParticipantThreadHeader'
|
|
11
|
+
|
|
12
|
+
const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
13
|
+
const GROUPS = [{ subjectType: 'invoice', label: 'Invoices' }]
|
|
14
|
+
|
|
15
|
+
function render(overrides: Partial<ParticipantThreadHeaderProps> = {}): string {
|
|
16
|
+
return renderToStaticMarkup(
|
|
17
|
+
<ParticipantThreadHeader
|
|
18
|
+
conversation={buildConversation({ subjectId: '1', subjectLabel: 'Item 4521' })}
|
|
19
|
+
labels={LABELS}
|
|
20
|
+
{...overrides}
|
|
21
|
+
/>,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function metaOf(markup: string): string {
|
|
26
|
+
const start = markup.indexOf('class="cv-p-thread__meta"')
|
|
27
|
+
const end = markup.indexOf('</header>', start)
|
|
28
|
+
return start === -1 ? '' : markup.slice(start, end)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('thread header structure', () => {
|
|
32
|
+
const conversation = buildConversation({
|
|
33
|
+
subjectId: '1',
|
|
34
|
+
subjectLabel: 'Item 4521',
|
|
35
|
+
protocol: '261009-K7M2',
|
|
36
|
+
channels: ['app', 'whatsapp'],
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('draws the eyebrow only when the subject type has a group', () => {
|
|
40
|
+
expect(render({ subjectGroups: GROUPS })).toContain('<p class="cv-p-thread__eyebrow">Invoices</p>')
|
|
41
|
+
expect(render()).not.toContain('cv-p-thread__eyebrow')
|
|
42
|
+
expect(render({ subjectGroups: [{ subjectType: 'other', label: 'Other' }] })).not.toContain('cv-p-thread__eyebrow')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('draws the title, clickable only with onOpenSubject', () => {
|
|
46
|
+
expect(render()).toContain('Item 4521')
|
|
47
|
+
expect(render()).not.toContain('cv-p-thread__title-button')
|
|
48
|
+
expect(render({ onOpenSubject: () => undefined })).toContain('cv-p-thread__title-button')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('keeps protocol, copy button and channel badges in one single meta line', () => {
|
|
52
|
+
const markup = render({ conversation })
|
|
53
|
+
expect(markup.match(/cv-p-thread__meta"/g)?.length).toBe(1)
|
|
54
|
+
const meta = metaOf(markup)
|
|
55
|
+
expect(meta).toContain('261009-K7M2')
|
|
56
|
+
expect(meta).toContain('cv-p-protocol__copy')
|
|
57
|
+
expect(meta).toContain('cv-p-channel--app')
|
|
58
|
+
expect(meta).toContain('cv-p-channel--whatsapp')
|
|
59
|
+
expect(markup.match(/cv-p-channels/g)?.length).toBe(1)
|
|
60
|
+
expect(markup.indexOf('cv-p-channels')).toBeGreaterThan(markup.indexOf('cv-p-thread__meta'))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not draw a separate channels row', () => {
|
|
64
|
+
const markup = render({ conversation })
|
|
65
|
+
expect(markup).not.toContain('<ul')
|
|
66
|
+
expect(markup).toContain('<span class="cv-p-channels">')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('draws the meta line with channels only when there is no protocol', () => {
|
|
70
|
+
const markup = render({ conversation: buildConversation({ subjectId: '1', channels: ['app'] }) })
|
|
71
|
+
expect(metaOf(markup)).toContain('cv-p-channel--app')
|
|
72
|
+
expect(markup).not.toContain('cv-p-protocol')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('draws no meta line without protocol and without channels', () => {
|
|
76
|
+
const markup = render()
|
|
77
|
+
expect(markup).not.toContain('cv-p-thread__meta')
|
|
78
|
+
expect(markup).not.toContain('cv-p-protocol')
|
|
79
|
+
expect(markup).not.toContain('cv-p-channels')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('draws the back button only with onBack', () => {
|
|
83
|
+
expect(render()).not.toContain('cv-p-thread__back')
|
|
84
|
+
expect(render({ onBack: () => undefined })).toContain('cv-p-thread__back')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('copy button as an icon', () => {
|
|
89
|
+
const view = (isCopied: boolean) =>
|
|
90
|
+
renderToStaticMarkup(
|
|
91
|
+
<ParticipantProtocolBadgeView
|
|
92
|
+
protocol="261009-K7M2"
|
|
93
|
+
labels={LABELS}
|
|
94
|
+
copyState={{ copyCount: isCopied ? 1 : 0, isCopied }}
|
|
95
|
+
onCopy={() => undefined}
|
|
96
|
+
/>,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
it('has the full accessible name and no visible text', () => {
|
|
100
|
+
const markup = view(false)
|
|
101
|
+
expect(markup).toContain('aria-label="Copy protocol 261009-K7M2"')
|
|
102
|
+
const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
|
|
103
|
+
expect(button).toContain('<svg')
|
|
104
|
+
expect(button.replace(/<[^>]+>/g, '')).toBe('')
|
|
105
|
+
expect(markup.replace(/aria-label="[^"]*"/g, '')).not.toContain('Copy protocol')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('shows the short confirmation next to the icon while copied', () => {
|
|
109
|
+
const markup = view(true)
|
|
110
|
+
const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
|
|
111
|
+
expect(button).toContain(`<span class="cv-p-protocol__copied" aria-hidden="true">${LABELS.protocolCopied}</span>`)
|
|
112
|
+
expect(markup).toMatch(/aria-live="polite"><span>Protocol copied<\/span><\/div>/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('keeps the live region outside the protocol block', () => {
|
|
116
|
+
const markup = view(true)
|
|
117
|
+
const block = markup.slice(markup.indexOf('cv-p-protocol--header'), markup.indexOf('</button>'))
|
|
118
|
+
expect(block).not.toContain('aria-live')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const CSS = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
123
|
+
|
|
124
|
+
function declarationsOf(selector: string): Map<string, string> {
|
|
125
|
+
const declarations = new Map<string, string>()
|
|
126
|
+
for (const { prelude, body } of splitBlocks(CSS)) {
|
|
127
|
+
if (body === undefined || prelude.startsWith('@')) continue
|
|
128
|
+
if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
|
|
129
|
+
for (const declaration of body.split(';')) {
|
|
130
|
+
const separator = declaration.indexOf(':')
|
|
131
|
+
if (separator === -1) continue
|
|
132
|
+
declarations.set(declaration.slice(0, separator).trim().toLowerCase(), declaration.slice(separator + 1).trim())
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return declarations
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toRem(value: string | undefined): number {
|
|
139
|
+
const match = /^(\d*\.?\d+)rem$/.exec(value ?? '')
|
|
140
|
+
return match ? Number(match[1]) : 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe('thread header CSS', () => {
|
|
144
|
+
it('lays the meta line out as a wrapping flex row', () => {
|
|
145
|
+
const meta = declarationsOf('.cv-p-thread__meta')
|
|
146
|
+
expect(meta.get('display')).toBe('flex')
|
|
147
|
+
expect(meta.get('flex-wrap')).toBe('wrap')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('gives the copy button a wide touch area without inflating the line', () => {
|
|
151
|
+
const copy = declarationsOf('.cv-p-protocol__copy')
|
|
152
|
+
expect(toRem(copy.get('min-width'))).toBeGreaterThanOrEqual(2.75)
|
|
153
|
+
expect(toRem(copy.get('min-height'))).toBeGreaterThanOrEqual(2)
|
|
154
|
+
expect(copy.get('margin')).toMatch(/^-/)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('sets a floor of 4rem on the header but never a fixed or maximum height', () => {
|
|
158
|
+
const header = declarationsOf('.cv-p-thread__header')
|
|
159
|
+
for (const property of ['height', 'max-height']) expect(header.has(property)).toBe(false)
|
|
160
|
+
expect(toRem(header.get('min-height'))).toBeGreaterThanOrEqual(4)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('clamps the title to one line', () => {
|
|
164
|
+
expect(declarationsOf('.cv-p-thread__title-text').get('-webkit-line-clamp')).toBe('1')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('draws the eyebrow like the list row kind label', () => {
|
|
168
|
+
const eyebrow = declarationsOf('.cv-p-thread__eyebrow')
|
|
169
|
+
expect(eyebrow.get('text-transform')).toBe('uppercase')
|
|
170
|
+
expect(eyebrow.get('font')).toContain('monospace')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('avatar CSS', () => {
|
|
175
|
+
it('draws a 2rem box that follows the host radius token', () => {
|
|
176
|
+
const avatar = declarationsOf('.cv-p-avatar')
|
|
177
|
+
expect(avatar.get('width')).toBe('2rem')
|
|
178
|
+
expect(avatar.get('height')).toBe('2rem')
|
|
179
|
+
expect(avatar.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import { ParticipantBackButton } from './ParticipantBackButton'
|
|
6
|
+
import { ParticipantChannelBadges } from './ParticipantChannelBadges'
|
|
7
|
+
import { resolveParticipantChannels } from './participantChannels'
|
|
8
|
+
import { ParticipantConversationAvatar } from './ParticipantConversationAvatar'
|
|
9
|
+
import { ParticipantProtocolBadge } from './ParticipantProtocolBadge'
|
|
10
|
+
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
11
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
12
|
+
import { resolveConversationIcon } from './participantSubjectIcon'
|
|
13
|
+
|
|
14
|
+
export type ParticipantThreadHeaderProps = {
|
|
15
|
+
readonly conversation: ParticipantConversationSummary
|
|
16
|
+
readonly labels: ParticipantConversationsLabels
|
|
17
|
+
readonly subjectGroups?: readonly ParticipantSubjectGroup[]
|
|
18
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
19
|
+
readonly onBack?: () => void
|
|
20
|
+
readonly onOpenSubject?: (subject: ParticipantSubjectRef) => void
|
|
21
|
+
/** Short line under the title, written by the host (for example who is on the other side). */
|
|
22
|
+
readonly counterpartLabel?: string
|
|
23
|
+
/** Host slot at the end of the bar; the package knows none of these actions. */
|
|
24
|
+
readonly actions?: ReactNode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type TitleProps = Pick<ParticipantThreadHeaderProps, 'conversation' | 'labels' | 'onOpenSubject'>
|
|
28
|
+
|
|
29
|
+
function Title({ conversation, labels, onOpenSubject }: TitleProps) {
|
|
30
|
+
const { subjectType, subjectId, subjectLabel } = conversation
|
|
31
|
+
return (
|
|
32
|
+
<h2 className="cv-p-thread__title">
|
|
33
|
+
{onOpenSubject ? (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className="cv-p-thread__title-button"
|
|
37
|
+
aria-label={`${labels.openSubject}: ${subjectLabel}`}
|
|
38
|
+
onClick={() => onOpenSubject({ subjectType, subjectId })}
|
|
39
|
+
>
|
|
40
|
+
<span className="cv-p-thread__title-text">{subjectLabel}</span>
|
|
41
|
+
</button>
|
|
42
|
+
) : (
|
|
43
|
+
<span className="cv-p-thread__title-text">{subjectLabel}</span>
|
|
44
|
+
)}
|
|
45
|
+
</h2>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ParticipantThreadHeader({
|
|
50
|
+
conversation,
|
|
51
|
+
labels,
|
|
52
|
+
subjectGroups,
|
|
53
|
+
renderSubjectIcon,
|
|
54
|
+
onBack,
|
|
55
|
+
onOpenSubject,
|
|
56
|
+
counterpartLabel,
|
|
57
|
+
actions,
|
|
58
|
+
}: ParticipantThreadHeaderProps) {
|
|
59
|
+
const group = subjectGroups?.find((candidate) => candidate.subjectType === conversation.subjectType)
|
|
60
|
+
const icon = resolveConversationIcon({ conversation, group, renderSubjectIcon })
|
|
61
|
+
const hasChannels = resolveParticipantChannels(conversation.channels).length > 0
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<header className="cv-p-thread__header">
|
|
65
|
+
{onBack ? <ParticipantBackButton label={labels.back} onBack={onBack} /> : null}
|
|
66
|
+
{icon !== undefined ? <ParticipantConversationAvatar icon={icon} /> : null}
|
|
67
|
+
<div className="cv-p-thread__heading">
|
|
68
|
+
{icon === undefined && group ? <p className="cv-p-thread__eyebrow">{group.label}</p> : null}
|
|
69
|
+
<Title conversation={conversation} labels={labels} onOpenSubject={onOpenSubject} />
|
|
70
|
+
{counterpartLabel ? <p className="cv-p-thread__counterpart">{counterpartLabel}</p> : null}
|
|
71
|
+
{conversation.protocol || hasChannels ? (
|
|
72
|
+
<div className="cv-p-thread__meta">
|
|
73
|
+
{conversation.protocol ? (
|
|
74
|
+
<ParticipantProtocolBadge protocol={conversation.protocol} labels={labels} />
|
|
75
|
+
) : null}
|
|
76
|
+
<ParticipantChannelBadges channels={conversation.channels} labels={labels} variant="inline" iconSize={16} />
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
</div>
|
|
80
|
+
{actions ? <div className="cv-p-thread__actions">{actions}</div> : null}
|
|
81
|
+
</header>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useState, type Dispatch, type MutableRefObject } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
|
+
import { ParticipantBackButton } from './ParticipantBackButton'
|
|
5
6
|
import { ParticipantThread } from './ParticipantThread'
|
|
6
7
|
import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
|
|
7
|
-
import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
|
|
8
8
|
import type { ParticipantDrafts } from './participantDrafts'
|
|
9
9
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
10
10
|
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
11
|
-
import { findFailedEntry, hasSendingEntry } from './participantSendController'
|
|
12
11
|
import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
|
|
13
|
-
import {
|
|
14
|
-
import { useParticipantConversationView } from './useParticipantConversationView'
|
|
15
|
-
import { useParticipantThreadScroll } from './useParticipantThreadScroll'
|
|
12
|
+
import { useParticipantThreadController } from './useParticipantThreadController'
|
|
16
13
|
|
|
17
14
|
export type ParticipantThreadScreenProps = Omit<ParticipantConversationsScreenProps, 'selected' | 'labels'> & {
|
|
18
15
|
readonly selected: ParticipantSubjectRef
|
|
@@ -24,9 +21,15 @@ export type ParticipantThreadScreenProps = Omit<ParticipantConversationsScreenPr
|
|
|
24
21
|
readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
type OpenedState =
|
|
24
|
+
type OpenedState =
|
|
25
|
+
| { readonly status: 'idle' | 'loading' | 'missing' }
|
|
26
|
+
| { readonly status: 'found'; readonly conversation: ParticipantConversationSummary }
|
|
28
27
|
|
|
29
|
-
function useOpenedConversation(
|
|
28
|
+
function useOpenedConversation(
|
|
29
|
+
api: ParticipantConversationsApi,
|
|
30
|
+
subject: ParticipantSubjectRef,
|
|
31
|
+
isNeeded: boolean,
|
|
32
|
+
): OpenedState {
|
|
30
33
|
const [opened, setOpened] = useState<OpenedState>({ status: 'idle' })
|
|
31
34
|
const { subjectType, subjectId } = subject
|
|
32
35
|
const canOpen = api.openConversation !== undefined
|
|
@@ -47,16 +50,17 @@ function useOpenedConversation(api: ParticipantConversationsApi, subject: Partic
|
|
|
47
50
|
return opened
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
function NotFound({
|
|
53
|
+
function NotFound({
|
|
54
|
+
labels,
|
|
55
|
+
onBack,
|
|
56
|
+
}: {
|
|
57
|
+
readonly labels: ParticipantConversationsLabels
|
|
58
|
+
readonly onBack?: () => void
|
|
59
|
+
}) {
|
|
51
60
|
return (
|
|
52
61
|
<div className="cv-p cv-p-thread">
|
|
53
62
|
<header className="cv-p-thread__header">
|
|
54
|
-
{onBack ?
|
|
55
|
-
<button type="button" className="cv-p-button cv-p-thread__back" onClick={onBack}>
|
|
56
|
-
<span aria-hidden="true">‹</span>
|
|
57
|
-
<span className="cv-p-sr-only">{labels.back}</span>
|
|
58
|
-
</button>
|
|
59
|
-
) : null}
|
|
63
|
+
{onBack ? <ParticipantBackButton label={labels.back} onBack={onBack} /> : null}
|
|
60
64
|
</header>
|
|
61
65
|
<p className="cv-p-empty">{labels.notFound}</p>
|
|
62
66
|
</div>
|
|
@@ -74,62 +78,32 @@ export function ParticipantThreadScreen(props: ParticipantThreadScreenProps) {
|
|
|
74
78
|
if (conversation) return <LoadedThread {...props} conversation={conversation} />
|
|
75
79
|
const isResolving = opened.status === 'loading' || (api.openConversation !== undefined && opened.status === 'idle')
|
|
76
80
|
const isListLoading = inbox.status === 'idle' || inbox.status === 'loading'
|
|
77
|
-
if (isResolving || (api.openConversation === undefined && isListLoading))
|
|
81
|
+
if (isResolving || (api.openConversation === undefined && isListLoading))
|
|
82
|
+
return <div className="cv-p cv-p-thread" aria-busy="true" />
|
|
78
83
|
return <NotFound labels={labels} onBack={props.onBack} />
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
type LoadedThreadProps = ParticipantThreadScreenProps & { readonly conversation: ParticipantConversationSummary }
|
|
82
87
|
|
|
83
88
|
function LoadedThread(props: LoadedThreadProps) {
|
|
84
|
-
const
|
|
85
|
-
const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, selected)
|
|
86
|
-
const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView({
|
|
89
|
+
const core = useParticipantThreadController({
|
|
87
90
|
...props,
|
|
88
|
-
subject: selected,
|
|
89
|
-
onMarkedRead: inbox.markSubjectRead,
|
|
91
|
+
subject: props.selected,
|
|
92
|
+
onMarkedRead: props.inbox.markSubjectRead,
|
|
90
93
|
})
|
|
91
|
-
const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
|
|
92
|
-
const { scroll, newMessagesCount } = useParticipantThreadScroll(items)
|
|
93
|
-
|
|
94
|
-
function handleSend(): void {
|
|
95
|
-
const content = takeForSend()
|
|
96
|
-
if (content) void thread.send(content)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function handleEdit(clientMessageId: string): void {
|
|
100
|
-
const entry = findFailedEntry(sendEntries, clientMessageId)
|
|
101
|
-
if (!entry) return
|
|
102
|
-
restoreFromEntry(entry)
|
|
103
|
-
dispatchSend({ type: 'discarded', clientMessageId })
|
|
104
|
-
}
|
|
105
94
|
|
|
106
95
|
return (
|
|
107
96
|
<ParticipantThread
|
|
108
|
-
|
|
109
|
-
items={items}
|
|
110
|
-
hasMore={thread.hasMore}
|
|
111
|
-
labels={props.labels}
|
|
112
|
-
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
113
|
-
draft={{ value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles }}
|
|
114
|
-
onSend={handleSend}
|
|
115
|
-
status={thread.status}
|
|
116
|
-
refresh={() => void thread.refresh()}
|
|
117
|
-
scroll={scroll}
|
|
118
|
-
newMessagesCount={newMessagesCount}
|
|
119
|
-
isSending={hasSendingEntry(sendEntries)}
|
|
120
|
-
channel={props.channel}
|
|
121
|
-
locale={props.locale}
|
|
97
|
+
{...core}
|
|
122
98
|
onBack={props.onBack}
|
|
123
99
|
onOpenSubject={props.onOpenSubject}
|
|
124
|
-
onLoadOlder={() => void thread.loadOlder()}
|
|
125
|
-
pendingActions={{
|
|
126
|
-
retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
|
|
127
|
-
discardLocal: (clientMessageId) => dispatchSend({ type: 'discarded', clientMessageId }),
|
|
128
|
-
editLocal: handleEdit,
|
|
129
|
-
...(props.onRetryPending ? { retryHost: props.onRetryPending } : {}),
|
|
130
|
-
}}
|
|
131
100
|
quickReplies={props.quickReplies}
|
|
132
101
|
renderSubjectCard={props.renderSubjectCard}
|
|
102
|
+
subjectGroups={props.subjectGroups}
|
|
103
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
104
|
+
avatars={props.avatars}
|
|
105
|
+
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
106
|
+
tail={props.tail}
|
|
133
107
|
/>
|
|
134
108
|
)
|
|
135
109
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
19
|
+
|
|
20
|
+
export type ParticipantTimelineProps = {
|
|
21
|
+
readonly items: readonly ParticipantTimelineItem[]
|
|
22
|
+
readonly labels: ParticipantConversationsLabels
|
|
23
|
+
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
24
|
+
readonly channel?: ConversationChannel
|
|
25
|
+
readonly locale?: string
|
|
26
|
+
readonly pendingActions?: ParticipantPendingActions
|
|
27
|
+
readonly avatars?: 'initials'
|
|
28
|
+
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
29
|
+
readonly tail?: boolean
|
|
30
|
+
readonly perspective?: ParticipantPerspective
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createdAtOf(item: ParticipantTimelineItem): string {
|
|
34
|
+
return item.kind === 'server' ? item.message.createdAt : item.pending.createdAt
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function keyOf(item: ParticipantTimelineItem): string {
|
|
38
|
+
return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type AuthorOfItem = { readonly isMine: boolean; readonly author: ParticipantAvatarAuthor }
|
|
42
|
+
|
|
43
|
+
function authorOf(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): AuthorOfItem {
|
|
44
|
+
if (item.kind === 'pending') return { isMine: true, author: null }
|
|
45
|
+
return { isMine: isOwnMessage(item.message, perspective), author: item.message.authorName ?? null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveActions(item: ParticipantTimelineItem, pendingActions?: ParticipantPendingActions) {
|
|
49
|
+
if (!pendingActions || item.kind === 'server') return {}
|
|
50
|
+
return resolveParticipantBubbleActions(item, bindPendingActions(pendingActions, item.pending.clientMessageId))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type AvatarSlotParams = {
|
|
54
|
+
readonly isEnabled: boolean
|
|
55
|
+
readonly isMine: boolean
|
|
56
|
+
readonly showAvatar: boolean
|
|
57
|
+
readonly author: ParticipantAvatarAuthor
|
|
58
|
+
readonly render?: ParticipantAuthorAvatarRenderer
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveAvatarSlot({ isEnabled, isMine, showAvatar, author, render }: AvatarSlotParams): ReactNode {
|
|
62
|
+
if (!isEnabled || isMine) return undefined
|
|
63
|
+
return showAvatar ? <ParticipantAuthorAvatar name={author} render={render} /> : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ParticipantTimeline(props: ParticipantTimelineProps) {
|
|
67
|
+
const { items, labels, locale, resolveAttachmentUrl, pendingActions } = props
|
|
68
|
+
const confirmsRead = channelCapabilityFor(props.channel ?? 'app').confirmsRead
|
|
69
|
+
const isAvatarEnabled = props.avatars === 'initials' || props.renderAuthorAvatar !== undefined
|
|
70
|
+
const nodes: ReactNode[] = []
|
|
71
|
+
let previousDay: Date | undefined
|
|
72
|
+
let previousAuthor: ParticipantAvatarAuthor | undefined
|
|
73
|
+
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
const day = new Date(createdAtOf(item))
|
|
76
|
+
if (previousDay === undefined || !isSameDay(previousDay, day)) {
|
|
77
|
+
nodes.push(<ParticipantDayDivider key={`day:${keyOf(item)}`} day={day} locale={locale} />)
|
|
78
|
+
previousAuthor = undefined
|
|
79
|
+
}
|
|
80
|
+
previousDay = day
|
|
81
|
+
const { isMine, author } = authorOf(item, props.perspective)
|
|
82
|
+
const showAvatar = shouldShowAvatar({ previousAuthor, author, isMine })
|
|
83
|
+
previousAuthor = isMine ? undefined : author
|
|
84
|
+
const avatar = resolveAvatarSlot({
|
|
85
|
+
isEnabled: isAvatarEnabled,
|
|
86
|
+
isMine,
|
|
87
|
+
showAvatar,
|
|
88
|
+
author,
|
|
89
|
+
render: props.renderAuthorAvatar,
|
|
90
|
+
})
|
|
91
|
+
nodes.push(
|
|
92
|
+
<ParticipantMessageBubble
|
|
93
|
+
key={keyOf(item)}
|
|
94
|
+
item={item}
|
|
95
|
+
labels={labels}
|
|
96
|
+
confirmsRead={confirmsRead}
|
|
97
|
+
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
98
|
+
tail={props.tail}
|
|
99
|
+
perspective={props.perspective}
|
|
100
|
+
avatar={avatar}
|
|
101
|
+
{...resolveActions(item, pendingActions)}
|
|
102
|
+
/>,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
return <div className="cv-p-thread__messages">{nodes}</div>
|
|
106
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { toParticipantApi, type ConversationThreadApi } from './conversationThreadApi'
|
|
4
|
+
|
|
5
|
+
const SUBJECT = { subjectType: 'invoice', subjectId: '1' }
|
|
6
|
+
|
|
7
|
+
function buildApi(overrides: Partial<ConversationThreadApi> = {}): ConversationThreadApi {
|
|
8
|
+
return {
|
|
9
|
+
fetchMessages: async () => [],
|
|
10
|
+
sendMessage: async () => ({ outcome: 'queued' }),
|
|
11
|
+
resolveAttachmentUrl: async () => 'x',
|
|
12
|
+
...overrides,
|
|
13
|
+
} as ConversationThreadApi
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('toParticipantApi markRead', () => {
|
|
17
|
+
it('rejects when the host does not implement markRead, so nothing is reported as read', async () => {
|
|
18
|
+
await expect(toParticipantApi(buildApi()).markRead(SUBJECT)).rejects.toThrow()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('delegates to the host markRead keeping its this', async () => {
|
|
22
|
+
const calls: unknown[] = []
|
|
23
|
+
const host = buildApi({ markRead: async function (this: unknown, subject) { calls.push([this, subject]) } })
|
|
24
|
+
await toParticipantApi(host).markRead(SUBJECT)
|
|
25
|
+
expect(calls).toEqual([[host, SUBJECT]])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
2
|
+
|
|
3
|
+
/** What one conversation needs from the host: no list, no open-by-subject. `markRead` and `subscribe` are optional. */
|
|
4
|
+
export type ConversationThreadApi = Pick<
|
|
5
|
+
ParticipantConversationsApi,
|
|
6
|
+
'fetchMessages' | 'sendMessage' | 'resolveAttachmentUrl'
|
|
7
|
+
> &
|
|
8
|
+
Partial<Pick<ParticipantConversationsApi, 'markRead' | 'subscribe'>>
|
|
9
|
+
|
|
10
|
+
/** Methods are delegated, never detached, so adapters written as classes keep their `this`. */
|
|
11
|
+
export function toParticipantApi(api: ConversationThreadApi): ParticipantConversationsApi {
|
|
12
|
+
const { subscribe } = api
|
|
13
|
+
return {
|
|
14
|
+
listConversations: async () => ({ data: [] }),
|
|
15
|
+
fetchMessages: (subject, params) => api.fetchMessages(subject, params),
|
|
16
|
+
sendMessage: (input) => api.sendMessage(input),
|
|
17
|
+
markRead: async (subject) => {
|
|
18
|
+
if (!api.markRead) throw new Error('The host api has no markRead')
|
|
19
|
+
await api.markRead(subject)
|
|
20
|
+
},
|
|
21
|
+
resolveAttachmentUrl: (attachment, disposition) => api.resolveAttachmentUrl(attachment, disposition),
|
|
22
|
+
...(subscribe ? { subscribe: (listener) => api.subscribe?.(listener) ?? (() => undefined) } : {}),
|
|
23
|
+
}
|
|
24
|
+
}
|