@adatechnology/conversations-ui 0.7.0 → 0.9.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 +96 -1
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +5 -3
- package/dist/participant/index.d.ts +48 -3
- package/dist/participant/index.js +782 -347
- package/dist/preview/index.d.ts +3 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +173 -52
- package/package.json +2 -1
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/domHarness.test-helper.ts +53 -0
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- package/src/participant/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +1 -1
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +26 -54
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +30 -10
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/ParticipantThreadHeader.tsx +10 -0
- package/src/participant/ParticipantThreadScreen.tsx +6 -46
- package/src/participant/ParticipantTimeline.tsx +6 -3
- 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/failedMenuKeyboard.ts +29 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantContrast.test-helper.ts +116 -0
- package/src/participant/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -0
- package/src/participant/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
- package/src/participant/participantLabels.ts +6 -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/participantThemeContrast.test.ts +106 -0
- package/src/participant/participantTooltips.test.tsx +74 -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/useFailedMenuEffects.ts +59 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantFailedMenu.ts +60 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +3 -1
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- package/src/participant/useSuppressTouchTooltips.ts +38 -0
- package/src/styles.css +43 -17
- package/src/tooltipLayerRegistry.test.ts +56 -0
- package/src/tooltipLayerRegistry.ts +34 -0
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useRef, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
7
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
8
|
+
import { TooltipLayer } from '../Tooltip'
|
|
8
9
|
import { ParticipantComposer } from './ParticipantComposer'
|
|
10
|
+
import { useSuppressTouchTooltips } from './useSuppressTouchTooltips'
|
|
9
11
|
import { ParticipantCopyAnnouncer, ParticipantThreadLive } from './ParticipantCopyAnnouncer'
|
|
10
12
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
11
13
|
import type { ParticipantPendingActions } from './participantBubbleActions'
|
|
12
14
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
13
15
|
import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
|
|
14
16
|
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
17
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
15
18
|
import type { ParticipantThreadScroll } from './useStickToBottom'
|
|
16
19
|
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
17
20
|
import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
|
|
@@ -62,6 +65,12 @@ export type ParticipantThreadProps = {
|
|
|
62
65
|
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
63
66
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
64
67
|
readonly tail?: boolean
|
|
68
|
+
/** Who is looking; absent is the participant, so nothing changes for the existing screen. */
|
|
69
|
+
readonly perspective?: ParticipantPerspective
|
|
70
|
+
/** Short line under the title, written by the host. */
|
|
71
|
+
readonly counterpartLabel?: string
|
|
72
|
+
/** Host slot at the end of the header bar (close, reopen...). */
|
|
73
|
+
readonly headerActions?: ReactNode
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
@@ -86,21 +95,31 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
86
95
|
)
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
function Header({ props }: { readonly props: ParticipantThreadProps }) {
|
|
99
|
+
return (
|
|
100
|
+
<ParticipantThreadHeader
|
|
101
|
+
conversation={props.conversation}
|
|
102
|
+
labels={props.labels}
|
|
103
|
+
subjectGroups={props.subjectGroups}
|
|
104
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
105
|
+
onBack={props.onBack}
|
|
106
|
+
onOpenSubject={props.onOpenSubject}
|
|
107
|
+
counterpartLabel={props.counterpartLabel}
|
|
108
|
+
actions={props.headerActions}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
function ThreadBody(props: ParticipantThreadProps) {
|
|
90
114
|
const { conversation, labels, hasMore, onLoadOlder, renderSubjectCard } = props
|
|
91
115
|
const subjectCard = renderSubjectCard?.(conversation)
|
|
92
116
|
const loadView = resolveLoadView({ status: props.status, hasItems: props.items.length > 0 })
|
|
117
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
118
|
+
useSuppressTouchTooltips(rootRef)
|
|
93
119
|
|
|
94
120
|
return (
|
|
95
|
-
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading}>
|
|
96
|
-
<
|
|
97
|
-
conversation={conversation}
|
|
98
|
-
labels={labels}
|
|
99
|
-
subjectGroups={props.subjectGroups}
|
|
100
|
-
renderSubjectIcon={props.renderSubjectIcon}
|
|
101
|
-
onBack={props.onBack}
|
|
102
|
-
onOpenSubject={props.onOpenSubject}
|
|
103
|
-
/>
|
|
121
|
+
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading} ref={rootRef}>
|
|
122
|
+
<Header props={props} />
|
|
104
123
|
{subjectCard ? <div className="cv-p-thread__subject-card">{subjectCard}</div> : null}
|
|
105
124
|
<div className="cv-p-thread__scroll" ref={props.scroll.ref} onScroll={props.scroll.onScroll} tabIndex={-1}>
|
|
106
125
|
{loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
|
|
@@ -129,6 +148,7 @@ export function ParticipantThread(props: ParticipantThreadProps) {
|
|
|
129
148
|
return (
|
|
130
149
|
<ParticipantCopyAnnouncer>
|
|
131
150
|
<ThreadBody {...props} />
|
|
151
|
+
<TooltipLayer />
|
|
132
152
|
</ParticipantCopyAnnouncer>
|
|
133
153
|
)
|
|
134
154
|
}
|
|
@@ -102,7 +102,7 @@ describe('copy button as an icon', () => {
|
|
|
102
102
|
const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
|
|
103
103
|
expect(button).toContain('<svg')
|
|
104
104
|
expect(button.replace(/<[^>]+>/g, '')).toBe('')
|
|
105
|
-
expect(markup.replace(/aria-label="[^"]*"/g, '')).not.toContain('Copy protocol')
|
|
105
|
+
expect(markup.replace(/(aria-label|data-cv-tooltip)="[^"]*"/g, '')).not.toContain('Copy protocol')
|
|
106
106
|
})
|
|
107
107
|
|
|
108
108
|
it('shows the short confirmation next to the icon while copied', () => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
1
3
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
4
|
|
|
3
5
|
import { ParticipantBackButton } from './ParticipantBackButton'
|
|
@@ -16,6 +18,10 @@ export type ParticipantThreadHeaderProps = {
|
|
|
16
18
|
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
17
19
|
readonly onBack?: () => void
|
|
18
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
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
type TitleProps = Pick<ParticipantThreadHeaderProps, 'conversation' | 'labels' | 'onOpenSubject'>
|
|
@@ -47,6 +53,8 @@ export function ParticipantThreadHeader({
|
|
|
47
53
|
renderSubjectIcon,
|
|
48
54
|
onBack,
|
|
49
55
|
onOpenSubject,
|
|
56
|
+
counterpartLabel,
|
|
57
|
+
actions,
|
|
50
58
|
}: ParticipantThreadHeaderProps) {
|
|
51
59
|
const group = subjectGroups?.find((candidate) => candidate.subjectType === conversation.subjectType)
|
|
52
60
|
const icon = resolveConversationIcon({ conversation, group, renderSubjectIcon })
|
|
@@ -59,6 +67,7 @@ export function ParticipantThreadHeader({
|
|
|
59
67
|
<div className="cv-p-thread__heading">
|
|
60
68
|
{icon === undefined && group ? <p className="cv-p-thread__eyebrow">{group.label}</p> : null}
|
|
61
69
|
<Title conversation={conversation} labels={labels} onOpenSubject={onOpenSubject} />
|
|
70
|
+
{counterpartLabel ? <p className="cv-p-thread__counterpart">{counterpartLabel}</p> : null}
|
|
62
71
|
{conversation.protocol || hasChannels ? (
|
|
63
72
|
<div className="cv-p-thread__meta">
|
|
64
73
|
{conversation.protocol ? (
|
|
@@ -68,6 +77,7 @@ export function ParticipantThreadHeader({
|
|
|
68
77
|
</div>
|
|
69
78
|
) : null}
|
|
70
79
|
</div>
|
|
80
|
+
{actions ? <div className="cv-p-thread__actions">{actions}</div> : null}
|
|
71
81
|
</header>
|
|
72
82
|
)
|
|
73
83
|
}
|
|
@@ -1,19 +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
5
|
import { ParticipantBackButton } from './ParticipantBackButton'
|
|
6
6
|
import { ParticipantThread } from './ParticipantThread'
|
|
7
7
|
import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
|
|
8
|
-
import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
|
|
9
8
|
import type { ParticipantDrafts } from './participantDrafts'
|
|
10
9
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
11
10
|
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
12
|
-
import { findFailedEntry, hasSendingEntry } from './participantSendController'
|
|
13
11
|
import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
|
|
14
|
-
import {
|
|
15
|
-
import { useParticipantConversationView } from './useParticipantConversationView'
|
|
16
|
-
import { useParticipantThreadScroll } from './useParticipantThreadScroll'
|
|
12
|
+
import { useParticipantThreadController } from './useParticipantThreadController'
|
|
17
13
|
|
|
18
14
|
export type ParticipantThreadScreenProps = Omit<ParticipantConversationsScreenProps, 'selected' | 'labels'> & {
|
|
19
15
|
readonly selected: ParticipantSubjectRef
|
|
@@ -90,53 +86,17 @@ export function ParticipantThreadScreen(props: ParticipantThreadScreenProps) {
|
|
|
90
86
|
type LoadedThreadProps = ParticipantThreadScreenProps & { readonly conversation: ParticipantConversationSummary }
|
|
91
87
|
|
|
92
88
|
function LoadedThread(props: LoadedThreadProps) {
|
|
93
|
-
const
|
|
94
|
-
const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, selected)
|
|
95
|
-
const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView({
|
|
89
|
+
const core = useParticipantThreadController({
|
|
96
90
|
...props,
|
|
97
|
-
subject: selected,
|
|
98
|
-
onMarkedRead: inbox.markSubjectRead,
|
|
91
|
+
subject: props.selected,
|
|
92
|
+
onMarkedRead: props.inbox.markSubjectRead,
|
|
99
93
|
})
|
|
100
|
-
const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
|
|
101
|
-
const { scroll, newMessagesCount } = useParticipantThreadScroll(items)
|
|
102
|
-
|
|
103
|
-
function handleSend(): void {
|
|
104
|
-
const content = takeForSend()
|
|
105
|
-
if (content) void thread.send(content)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function handleEdit(clientMessageId: string): void {
|
|
109
|
-
const entry = findFailedEntry(sendEntries, clientMessageId)
|
|
110
|
-
if (!entry) return
|
|
111
|
-
restoreFromEntry(entry)
|
|
112
|
-
dispatchSend({ type: 'discarded', clientMessageId })
|
|
113
|
-
}
|
|
114
94
|
|
|
115
95
|
return (
|
|
116
96
|
<ParticipantThread
|
|
117
|
-
|
|
118
|
-
items={items}
|
|
119
|
-
hasMore={thread.hasMore}
|
|
120
|
-
labels={props.labels}
|
|
121
|
-
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
122
|
-
draft={{ value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles }}
|
|
123
|
-
onSend={handleSend}
|
|
124
|
-
status={thread.status}
|
|
125
|
-
refresh={() => void thread.refresh()}
|
|
126
|
-
scroll={scroll}
|
|
127
|
-
newMessagesCount={newMessagesCount}
|
|
128
|
-
isSending={hasSendingEntry(sendEntries)}
|
|
129
|
-
channel={props.channel}
|
|
130
|
-
locale={props.locale}
|
|
97
|
+
{...core}
|
|
131
98
|
onBack={props.onBack}
|
|
132
99
|
onOpenSubject={props.onOpenSubject}
|
|
133
|
-
onLoadOlder={() => void thread.loadOlder()}
|
|
134
|
-
pendingActions={{
|
|
135
|
-
retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
|
|
136
|
-
discardLocal: (clientMessageId) => dispatchSend({ type: 'discarded', clientMessageId }),
|
|
137
|
-
editLocal: handleEdit,
|
|
138
|
-
...(props.onRetryPending ? { retryHost: props.onRetryPending } : {}),
|
|
139
|
-
}}
|
|
140
100
|
quickReplies={props.quickReplies}
|
|
141
101
|
renderSubjectCard={props.renderSubjectCard}
|
|
142
102
|
subjectGroups={props.subjectGroups}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './participantBubbleActions'
|
|
16
16
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
17
17
|
import { isOwnMessage, type ParticipantTimelineItem } from './participantMessages'
|
|
18
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
18
19
|
|
|
19
20
|
export type ParticipantTimelineProps = {
|
|
20
21
|
readonly items: readonly ParticipantTimelineItem[]
|
|
@@ -26,6 +27,7 @@ export type ParticipantTimelineProps = {
|
|
|
26
27
|
readonly avatars?: 'initials'
|
|
27
28
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
28
29
|
readonly tail?: boolean
|
|
30
|
+
readonly perspective?: ParticipantPerspective
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
function createdAtOf(item: ParticipantTimelineItem): string {
|
|
@@ -38,9 +40,9 @@ function keyOf(item: ParticipantTimelineItem): string {
|
|
|
38
40
|
|
|
39
41
|
type AuthorOfItem = { readonly isMine: boolean; readonly author: ParticipantAvatarAuthor }
|
|
40
42
|
|
|
41
|
-
function authorOf(item: ParticipantTimelineItem): AuthorOfItem {
|
|
43
|
+
function authorOf(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): AuthorOfItem {
|
|
42
44
|
if (item.kind === 'pending') return { isMine: true, author: null }
|
|
43
|
-
return { isMine: isOwnMessage(item.message), author: item.message.authorName ?? null }
|
|
45
|
+
return { isMine: isOwnMessage(item.message, perspective), author: item.message.authorName ?? null }
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function resolveActions(item: ParticipantTimelineItem, pendingActions?: ParticipantPendingActions) {
|
|
@@ -76,7 +78,7 @@ export function ParticipantTimeline(props: ParticipantTimelineProps) {
|
|
|
76
78
|
previousAuthor = undefined
|
|
77
79
|
}
|
|
78
80
|
previousDay = day
|
|
79
|
-
const { isMine, author } = authorOf(item)
|
|
81
|
+
const { isMine, author } = authorOf(item, props.perspective)
|
|
80
82
|
const showAvatar = shouldShowAvatar({ previousAuthor, author, isMine })
|
|
81
83
|
previousAuthor = isMine ? undefined : author
|
|
82
84
|
const avatar = resolveAvatarSlot({
|
|
@@ -94,6 +96,7 @@ export function ParticipantTimeline(props: ParticipantTimelineProps) {
|
|
|
94
96
|
confirmsRead={confirmsRead}
|
|
95
97
|
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
96
98
|
tail={props.tail}
|
|
99
|
+
perspective={props.perspective}
|
|
97
100
|
avatar={avatar}
|
|
98
101
|
{...resolveActions(item, pendingActions)}
|
|
99
102
|
/>,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { declarationsIn, mediaBody, PARTICIPANT_CSS } from './participantCss.test-helper'
|
|
6
|
+
|
|
7
|
+
const NEW_SOURCES = [
|
|
8
|
+
'ConversationThread.tsx',
|
|
9
|
+
'conversationThreadApi.ts',
|
|
10
|
+
'conversationThreadSummary.ts',
|
|
11
|
+
'participantPerspective.ts',
|
|
12
|
+
'useParticipantUnread.ts',
|
|
13
|
+
'useParticipantThreadController.ts',
|
|
14
|
+
].map((file) => ({ file, source: readFileSync(join(import.meta.dir, file), 'utf8') }))
|
|
15
|
+
|
|
16
|
+
describe('header additions', () => {
|
|
17
|
+
it('puts the counterpart line in one muted, ellipsized line', () => {
|
|
18
|
+
const rule = declarationsIn('.cv-p-thread__counterpart')
|
|
19
|
+
expect(rule.get('color')).toBe('var(--cv-p-i-text-muted)')
|
|
20
|
+
expect(rule.get('white-space')).toBe('nowrap')
|
|
21
|
+
expect(rule.get('text-overflow')).toBe('ellipsis')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps the host actions at the end of the bar, never wider than the bar', () => {
|
|
25
|
+
const rule = declarationsIn('.cv-p-thread__actions')
|
|
26
|
+
expect(rule.get('flex')).toBe('0 0 auto')
|
|
27
|
+
expect(rule.get('margin-inline-start')).toBe('auto')
|
|
28
|
+
expect(rule.get('max-width')).toBe('100%')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('lets the bar wrap and gives the title a floor only when there are host actions', () => {
|
|
32
|
+
const withActions = '.cv-p-thread__header:has(.cv-p-thread__actions)'
|
|
33
|
+
expect(declarationsIn(withActions).get('flex-wrap')).toBe('wrap')
|
|
34
|
+
expect(declarationsIn(`${withActions} .cv-p-thread__heading`).get('min-width')).toBe('6rem')
|
|
35
|
+
expect(declarationsIn('.cv-p-thread__header').has('flex-wrap')).toBe(false)
|
|
36
|
+
expect(declarationsIn('.cv-p-thread__heading').get('min-width')).toBe('0')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('ticks', () => {
|
|
41
|
+
it('take their color from package tokens that a host can override, with a fallback', () => {
|
|
42
|
+
const root = declarationsIn('.cv-p')
|
|
43
|
+
expect(root.get('--cv-p-i-tick')).toBe('var(--cv-p-tick, var(--cv-p-i-text-muted))')
|
|
44
|
+
expect(root.get('--cv-p-i-tick-read')).toMatch(/^var\(--cv-p-tick-read, #[0-9a-f]{6}\)$/)
|
|
45
|
+
expect(declarationsIn('.dark .cv-p').get('--cv-p-i-tick-read')).toMatch(/^var\(--cv-p-tick-read, #[0-9a-f]{6}\)$/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('override the shared tick colors only inside the participant bubble', () => {
|
|
49
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--sent').get('color')).toBe('var(--cv-p-i-tick)')
|
|
50
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--delivered').get('color')).toBe('var(--cv-p-i-tick)')
|
|
51
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--read').get('color')).toBe('var(--cv-p-i-tick-read)')
|
|
52
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--failed').get('color')).toBe('var(--cv-p-i-danger)')
|
|
53
|
+
expect(declarationsIn('.cv-status-ticks--read').get('color')).toBe('rgb(14 165 233)')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('give way to the system colors under forced-colors', () => {
|
|
57
|
+
const forced = mediaBody('(forced-colors: active)') ?? ''
|
|
58
|
+
expect(forced).toContain('.cv-p-bubble .cv-status-ticks')
|
|
59
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks', forced).get('color')).toBe('CanvasText')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('wallpaper and the strips around it', () => {
|
|
64
|
+
it('gives the load error a solid panel so no text depends on the dots', () => {
|
|
65
|
+
expect(declarationsIn('.cv-p-thread__scroll > .cv-p-error').get('background')).toBe('var(--cv-p-i-surface)')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('paints the live strip like the composer it sits on', () => {
|
|
69
|
+
expect(declarationsIn('.cv-p-thread__live').get('background')).toBe('var(--cv-p-i-surface-raised)')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps the wallpaper off under forced-colors', () => {
|
|
73
|
+
expect(
|
|
74
|
+
declarationsIn('.cv-p-thread__scroll', mediaBody('(forced-colors: active)') ?? '').get('background-image'),
|
|
75
|
+
).toBe('none')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('motion', () => {
|
|
80
|
+
it('adds no transition or animation outside the no-preference block', () => {
|
|
81
|
+
const rules = declarationsIn('.cv-p-thread__actions')
|
|
82
|
+
const counterpart = declarationsIn('.cv-p-thread__counterpart')
|
|
83
|
+
for (const declarations of [rules, counterpart]) {
|
|
84
|
+
expect(
|
|
85
|
+
[...declarations.keys()].some(
|
|
86
|
+
(property) => property.startsWith('transition') || property.startsWith('animation'),
|
|
87
|
+
),
|
|
88
|
+
).toBe(false)
|
|
89
|
+
}
|
|
90
|
+
expect(mediaBody('(prefers-reduced-motion: no-preference)')).toBeDefined()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('new sources', () => {
|
|
95
|
+
it('use no Tailwind utilities and none of the vocabulary of a specific product', () => {
|
|
96
|
+
for (const { file, source } of NEW_SOURCES) {
|
|
97
|
+
expect(source, file).not.toMatch(/className="[^"]*\b(flex|grid|p-\d|m-\d|text-(sm|xs|lg)|bg-\w+)\b/)
|
|
98
|
+
expect(source, file).not.toMatch(
|
|
99
|
+
/\b(nota|ocorr\w+|motorista|viagem|carga|entrega|frete|cte|nf-?e|transport\w*|trip|driver)\b/i,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('stay under 200 lines and use no any', () => {
|
|
105
|
+
for (const { file, source } of NEW_SOURCES) {
|
|
106
|
+
expect(source.split('\n').length, file).toBeLessThanOrEqual(200)
|
|
107
|
+
expect(source, file).not.toMatch(/:\s*any\b|\bas any\b|<any>/)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('declare the new tokens in the public theming comment', () => {
|
|
112
|
+
const raw = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8')
|
|
113
|
+
expect(raw).toContain('--cv-p-tick, --cv-p-tick-read')
|
|
114
|
+
expect(PARTICIPANT_CSS).toContain('--cv-p-i-tick-read')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ParticipantConversationStatus,
|
|
3
|
+
ParticipantConversationSummary,
|
|
4
|
+
ParticipantSubjectRef,
|
|
5
|
+
} from '@adatechnology/conversation-contracts'
|
|
6
|
+
|
|
7
|
+
export type BuildThreadSummaryParams = {
|
|
8
|
+
readonly subject: ParticipantSubjectRef
|
|
9
|
+
readonly title: string
|
|
10
|
+
readonly protocol?: string
|
|
11
|
+
readonly channels?: ParticipantConversationSummary['channels']
|
|
12
|
+
readonly status?: ParticipantConversationStatus
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The header reads a summary; a single conversation has no list to take it from, so the host's props build it. */
|
|
16
|
+
export function buildThreadSummary({
|
|
17
|
+
subject,
|
|
18
|
+
title,
|
|
19
|
+
protocol,
|
|
20
|
+
channels,
|
|
21
|
+
status,
|
|
22
|
+
}: BuildThreadSummaryParams): ParticipantConversationSummary {
|
|
23
|
+
return {
|
|
24
|
+
subjectType: subject.subjectType,
|
|
25
|
+
subjectId: subject.subjectId,
|
|
26
|
+
subjectLabel: title,
|
|
27
|
+
lastMessageAt: null,
|
|
28
|
+
unreadCount: 0,
|
|
29
|
+
awaitingParticipant: false,
|
|
30
|
+
status: status ?? 'open',
|
|
31
|
+
...(protocol ? { protocol } : {}),
|
|
32
|
+
...(channels ? { channels } : {}),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Dispatch, KeyboardEvent, RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { resolveFailedMenuKey, type FailedMenuAction } from './participantFailedMenuState'
|
|
4
|
+
|
|
5
|
+
type MenuKeyParams = {
|
|
6
|
+
readonly event: KeyboardEvent<HTMLElement>
|
|
7
|
+
readonly menuRef: RefObject<HTMLElement | null>
|
|
8
|
+
readonly triggerRef: RefObject<HTMLElement | null>
|
|
9
|
+
readonly dispatch: Dispatch<FailedMenuAction>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Escape is consumed here so it does not also close a host dialog or drawer around the thread. */
|
|
13
|
+
export function handleFailedMenuKeyDown({ event, menuRef, triggerRef, dispatch }: MenuKeyParams): void {
|
|
14
|
+
const items = Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])
|
|
15
|
+
const index = items.findIndex((item) => item === document.activeElement)
|
|
16
|
+
const result = resolveFailedMenuKey({ key: event.key, index, count: items.length })
|
|
17
|
+
if (!result) return
|
|
18
|
+
if (result.type === 'focus') {
|
|
19
|
+
event.preventDefault()
|
|
20
|
+
items[result.index]?.focus()
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
if (result.shouldRestoreFocus) {
|
|
24
|
+
event.preventDefault()
|
|
25
|
+
event.stopPropagation()
|
|
26
|
+
triggerRef.current?.focus()
|
|
27
|
+
}
|
|
28
|
+
dispatch({ type: 'close' })
|
|
29
|
+
}
|
package/src/participant/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { ParticipantConversations, type ParticipantConversationsProps } from './ParticipantConversations'
|
|
2
|
+
export { ConversationThread, type ConversationThreadProps } from './ConversationThread'
|
|
3
|
+
export type { ConversationThreadApi } from './conversationThreadApi'
|
|
4
|
+
export type { ParticipantPerspective } from './participantPerspective'
|
|
2
5
|
export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
3
6
|
export { groupParticipantConversations } from './participantGrouping'
|
|
4
7
|
export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { declarationsIn } from './participantCss.test-helper'
|
|
2
|
+
|
|
3
|
+
export type Rgba = { readonly r: number; readonly g: number; readonly b: number; readonly a: number }
|
|
4
|
+
|
|
5
|
+
export type TokenName =
|
|
6
|
+
'surface' | 'raised' | 'text' | 'muted' | 'border' | 'accent' | 'accentContrast' | 'highlight' | 'danger'
|
|
7
|
+
|
|
8
|
+
export type Palette = Readonly<Record<TokenName, Rgba>>
|
|
9
|
+
|
|
10
|
+
const TOKEN_VARIABLE: Record<TokenName, string> = {
|
|
11
|
+
surface: '--cv-p-surface',
|
|
12
|
+
raised: '--cv-p-surface-raised',
|
|
13
|
+
text: '--cv-p-text',
|
|
14
|
+
muted: '--cv-p-text-muted',
|
|
15
|
+
border: '--cv-p-border',
|
|
16
|
+
accent: '--cv-p-accent',
|
|
17
|
+
accentContrast: '--cv-p-accent-contrast',
|
|
18
|
+
highlight: '--cv-p-highlight',
|
|
19
|
+
danger: '--cv-p-danger',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseColor(source: string): Rgba {
|
|
23
|
+
const hex = /^#([0-9a-f]{6})$/i.exec(source.trim())
|
|
24
|
+
if (hex?.[1]) {
|
|
25
|
+
const value = hex[1]
|
|
26
|
+
return {
|
|
27
|
+
r: parseInt(value.slice(0, 2), 16),
|
|
28
|
+
g: parseInt(value.slice(2, 4), 16),
|
|
29
|
+
b: parseInt(value.slice(4, 6), 16),
|
|
30
|
+
a: 1,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const functional = /^rgb\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*(\d+)%)?\s*\)$/.exec(source.trim())
|
|
34
|
+
if (!functional) throw new Error(`unsupported color: ${source}`)
|
|
35
|
+
return {
|
|
36
|
+
r: Number(functional[1]),
|
|
37
|
+
g: Number(functional[2]),
|
|
38
|
+
b: Number(functional[3]),
|
|
39
|
+
a: Number(functional[4] ?? 100) / 100,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Paints `top` over an opaque `base`; the result is opaque. */
|
|
44
|
+
export function over(top: Rgba, base: Rgba): Rgba {
|
|
45
|
+
const mixChannel = (from: number, to: number): number => Math.round(from * top.a + to * (1 - top.a))
|
|
46
|
+
return { r: mixChannel(top.r, base.r), g: mixChannel(top.g, base.g), b: mixChannel(top.b, base.b), a: 1 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What `color-mix(in srgb, top <percent>%, transparent)` paints over `base`. */
|
|
50
|
+
export function tint(top: Rgba, percent: number, base: Rgba): Rgba {
|
|
51
|
+
return over({ ...top, a: top.a * (percent / 100) }, base)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function channelLuminance(channel: number): number {
|
|
55
|
+
const unit = channel / 255
|
|
56
|
+
return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function luminance(color: Rgba): number {
|
|
60
|
+
return 0.2126 * channelLuminance(color.r) + 0.7152 * channelLuminance(color.g) + 0.0722 * channelLuminance(color.b)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** WCAG 2.x contrast ratio; both colors must already be opaque. */
|
|
64
|
+
export function contrastRatio(first: Rgba, second: Rgba): number {
|
|
65
|
+
const [lighter, darker] = [luminance(first), luminance(second)].sort((left, right) => right - left)
|
|
66
|
+
return ((lighter ?? 0) + 0.05) / ((darker ?? 0) + 0.05)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fallbackOf(declaration: string | undefined): string {
|
|
70
|
+
const match = /^var\(--cv-p-[a-z-]+,\s*(.+)\)$/.exec(declaration ?? '')
|
|
71
|
+
if (!match?.[1]) throw new Error(`no fallback in: ${declaration}`)
|
|
72
|
+
return match[1]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function internalVariable(token: TokenName): string {
|
|
76
|
+
return TOKEN_VARIABLE[token].replace('--cv-p-', '--cv-p-i-')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The default palette as styles.css declares it: the light one on `.cv-p`, the dark one on `.dark .cv-p`. */
|
|
80
|
+
export function defaultPalette(theme: 'light' | 'dark'): Palette {
|
|
81
|
+
const light = declarationsIn('.cv-p')
|
|
82
|
+
const dark = theme === 'dark' ? declarationsIn('.dark .cv-p') : new Map<string, string>()
|
|
83
|
+
const entries = (Object.keys(TOKEN_VARIABLE) as TokenName[]).map((token) => {
|
|
84
|
+
const variable = internalVariable(token)
|
|
85
|
+
return [token, parseColor(fallbackOf(dark.get(variable) ?? light.get(variable)))] as const
|
|
86
|
+
})
|
|
87
|
+
return Object.fromEntries(entries) as Palette
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A host mapping: only the tokens it sets; the rest keeps the light defaults, exactly as the cascade would. */
|
|
91
|
+
export function mappedPalette(overrides: Partial<Record<TokenName, string>>): Palette {
|
|
92
|
+
const base = defaultPalette('light')
|
|
93
|
+
const entries = (Object.keys(base) as TokenName[]).map((token) => {
|
|
94
|
+
const override = overrides[token]
|
|
95
|
+
return [token, override ? parseColor(override) : base[token]] as const
|
|
96
|
+
})
|
|
97
|
+
return Object.fromEntries(entries) as Palette
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Opaque version of the palette: translucent tokens are painted over the surface they sit on. */
|
|
101
|
+
export function flatten(palette: Palette): Palette {
|
|
102
|
+
const entries = (Object.keys(palette) as TokenName[]).map(
|
|
103
|
+
(token) => [token, over(palette[token], palette.surface)] as const,
|
|
104
|
+
)
|
|
105
|
+
return Object.fromEntries(entries) as Palette
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Default value of a standalone token declared on `.cv-p` or `.dark .cv-p` (for example the read tick). */
|
|
109
|
+
export function declaredToken(name: string, theme: 'light' | 'dark'): string {
|
|
110
|
+
const value =
|
|
111
|
+
(theme === 'dark' ? declarationsIn('.dark .cv-p').get(name) : undefined) ?? declarationsIn('.cv-p').get(name)
|
|
112
|
+
if (!value) throw new Error(`token not declared: ${name}`)
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { fallbackOf }
|