@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.
Files changed (81) hide show
  1. package/README.md +96 -1
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
  4. package/dist/chunk-ZSOD5AQT.js +19 -0
  5. package/dist/flows/index.js +4 -2
  6. package/dist/index.d.ts +4 -4
  7. package/dist/index.js +5 -3
  8. package/dist/participant/index.d.ts +48 -3
  9. package/dist/participant/index.js +782 -347
  10. package/dist/preview/index.d.ts +3 -3
  11. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  12. package/dist/styles.css +173 -52
  13. package/package.json +2 -1
  14. package/src/Tooltip.tsx +5 -12
  15. package/src/TooltipDom.test.tsx +78 -0
  16. package/src/domHarness.test-helper.ts +53 -0
  17. package/src/participant/ConversationThread.test.tsx +165 -0
  18. package/src/participant/ConversationThread.tsx +117 -0
  19. package/src/participant/ParticipantBackButton.tsx +1 -1
  20. package/src/participant/ParticipantBubbleBody.tsx +54 -0
  21. package/src/participant/ParticipantBubbleStatus.tsx +22 -28
  22. package/src/participant/ParticipantChannelBadges.tsx +10 -2
  23. package/src/participant/ParticipantComposer.tsx +1 -1
  24. package/src/participant/ParticipantComposerControls.tsx +2 -0
  25. package/src/participant/ParticipantCopyableToken.tsx +1 -0
  26. package/src/participant/ParticipantFailedMenu.tsx +40 -0
  27. package/src/participant/ParticipantFailedMenuList.tsx +41 -0
  28. package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
  29. package/src/participant/ParticipantMessageBubble.tsx +26 -54
  30. package/src/participant/ParticipantMessageText.test.tsx +1 -1
  31. package/src/participant/ParticipantMessageText.tsx +4 -2
  32. package/src/participant/ParticipantProtocolBadge.tsx +1 -0
  33. package/src/participant/ParticipantScrollToLatest.tsx +1 -1
  34. package/src/participant/ParticipantThread.tsx +30 -10
  35. package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
  36. package/src/participant/ParticipantThreadHeader.tsx +10 -0
  37. package/src/participant/ParticipantThreadScreen.tsx +6 -46
  38. package/src/participant/ParticipantTimeline.tsx +6 -3
  39. package/src/participant/conversationThreadApi.test.ts +27 -0
  40. package/src/participant/conversationThreadApi.ts +24 -0
  41. package/src/participant/conversationThreadCss.test.ts +116 -0
  42. package/src/participant/conversationThreadSummary.ts +34 -0
  43. package/src/participant/failedMenuKeyboard.ts +29 -0
  44. package/src/participant/index.ts +3 -0
  45. package/src/participant/participantContrast.test-helper.ts +116 -0
  46. package/src/participant/participantFailedMenuDom.test.tsx +154 -0
  47. package/src/participant/participantFailedMenuState.test.ts +61 -0
  48. package/src/participant/participantFailedMenuState.ts +57 -0
  49. package/src/participant/participantFailedMessage.test.tsx +83 -0
  50. package/src/participant/participantGolden.test.tsx +46 -0
  51. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  52. package/src/participant/participantLabels.ts +6 -0
  53. package/src/participant/participantMessages.ts +4 -3
  54. package/src/participant/participantNewMessages.test.ts +21 -0
  55. package/src/participant/participantNewMessages.ts +8 -3
  56. package/src/participant/participantPerspective.test.ts +67 -0
  57. package/src/participant/participantPerspective.ts +50 -0
  58. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  59. package/src/participant/participantThemeContrast.test.ts +106 -0
  60. package/src/participant/participantTooltips.test.tsx +74 -0
  61. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  62. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  63. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  64. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  65. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  66. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  67. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  68. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  69. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  70. package/src/participant/useFailedMenuEffects.ts +59 -0
  71. package/src/participant/useParticipantConversationView.ts +3 -0
  72. package/src/participant/useParticipantFailedMenu.ts +60 -0
  73. package/src/participant/useParticipantThread.ts +13 -2
  74. package/src/participant/useParticipantThreadController.ts +112 -0
  75. package/src/participant/useParticipantThreadScroll.ts +3 -1
  76. package/src/participant/useParticipantUnread.test.tsx +40 -0
  77. package/src/participant/useParticipantUnread.ts +38 -0
  78. package/src/participant/useSuppressTouchTooltips.ts +38 -0
  79. package/src/styles.css +43 -17
  80. package/src/tooltipLayerRegistry.test.ts +56 -0
  81. package/src/tooltipLayerRegistry.ts +34 -0
@@ -1,17 +1,20 @@
1
- import type { ReactNode } from 'react'
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
- <ParticipantThreadHeader
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, useMemo, useState, type Dispatch, type MutableRefObject } from 'react'
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 { useParticipantDraft } from './useParticipantDraft'
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 { api, selected, conversation, draftsRef, inbox } = props
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
- conversation={conversation}
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
+ }
@@ -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 }