@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
@@ -0,0 +1,154 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { act } from 'react'
3
+
4
+ import { setupDom, pressKey, type DomHarness } from '../domHarness.test-helper'
5
+ import { ParticipantFailedMenu } from './ParticipantFailedMenu'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as LABELS } from './participantLabels'
7
+
8
+ let harness: DomHarness
9
+
10
+ beforeAll(async () => {
11
+ harness = await setupDom()
12
+ })
13
+ afterAll(() => harness.teardown())
14
+
15
+ const noop = (): void => undefined
16
+
17
+ function mountMenu() {
18
+ const tree = harness.mount(
19
+ <div className="cv-p-thread__scroll">
20
+ <div className="cv-p-bubble">
21
+ <ParticipantFailedMenu labels={LABELS} onEdit={noop} onDiscard={noop} />
22
+ </div>
23
+ </div>,
24
+ )
25
+ const trigger = tree.container.querySelector<HTMLButtonElement>('.cv-p-failed-menu__trigger')!
26
+ return { ...tree, trigger }
27
+ }
28
+
29
+ function click(element: Element): void {
30
+ act(() => element.dispatchEvent(new MouseEvent('click', { bubbles: true })))
31
+ }
32
+
33
+ function items(container: HTMLElement): HTMLElement[] {
34
+ return Array.from(container.querySelectorAll<HTMLElement>('[role="menuitem"]'))
35
+ }
36
+
37
+ describe('failed menu in a DOM', () => {
38
+ it('opens on click, names the menu by the trigger and focuses the first item', () => {
39
+ const { container, trigger } = mountMenu()
40
+ click(trigger)
41
+
42
+ const menu = container.querySelector('[role="menu"]')!
43
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
44
+ expect(menu.getAttribute('aria-labelledby')).toBe(trigger.id)
45
+ expect(document.activeElement).toBe(items(container)[0])
46
+ expect(items(container).every((item) => item.tabIndex === -1)).toBe(true)
47
+ expect(trigger.hasAttribute('data-cv-tooltip')).toBe(false)
48
+ })
49
+
50
+ it('ArrowUp on the trigger opens the menu on the last item', () => {
51
+ const { container, trigger } = mountMenu()
52
+ act(() => trigger.focus())
53
+ pressKey(trigger, 'ArrowUp')
54
+
55
+ expect(document.activeElement).toBe(items(container)[1])
56
+ })
57
+
58
+ it('Escape closes, gives the focus back to the trigger and does not reach the host', () => {
59
+ const { container, trigger } = mountMenu()
60
+ click(trigger)
61
+ let hostSawEscape = false
62
+ const hostListener = (): void => void (hostSawEscape = true)
63
+ document.addEventListener('keydown', hostListener)
64
+
65
+ pressKey(items(container)[0]!, 'Escape')
66
+ document.removeEventListener('keydown', hostListener)
67
+
68
+ expect(container.querySelector('[role="menu"]')).toBeNull()
69
+ expect(document.activeElement).toBe(trigger)
70
+ expect(hostSawEscape).toBe(false)
71
+ })
72
+
73
+ it('Tab closes the menu without pulling the focus back to the trigger', () => {
74
+ const { container, trigger } = mountMenu()
75
+ click(trigger)
76
+ pressKey(items(container)[0]!, 'Tab')
77
+
78
+ expect(container.querySelector('[role="menu"]')).toBeNull()
79
+ expect(document.activeElement).not.toBe(trigger)
80
+ })
81
+
82
+ it('closes on a pointer press outside and keeps open on one inside', () => {
83
+ const { container, trigger } = mountMenu()
84
+ click(trigger)
85
+ act(() => void items(container)[0]!.dispatchEvent(new Event('pointerdown', { bubbles: true })))
86
+ expect(container.querySelector('[role="menu"]')).not.toBeNull()
87
+
88
+ act(() => void document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })))
89
+ expect(container.querySelector('[role="menu"]')).toBeNull()
90
+ })
91
+
92
+ it('removes its document listener when unmounted with the menu open', () => {
93
+ const added: string[] = []
94
+ const removed: string[] = []
95
+ const add = document.addEventListener.bind(document)
96
+ const remove = document.removeEventListener.bind(document)
97
+ document.addEventListener = ((type: string, ...rest: unknown[]) => {
98
+ added.push(type)
99
+ return (add as (...args: unknown[]) => void)(type, ...rest)
100
+ }) as typeof document.addEventListener
101
+ document.removeEventListener = ((type: string, ...rest: unknown[]) => {
102
+ removed.push(type)
103
+ return (remove as (...args: unknown[]) => void)(type, ...rest)
104
+ }) as typeof document.removeEventListener
105
+
106
+ const { unmount, trigger } = mountMenu()
107
+ click(trigger)
108
+ unmount()
109
+ document.addEventListener = add
110
+ document.removeEventListener = remove
111
+
112
+ expect(added.filter((type) => type === 'pointerdown')).toHaveLength(1)
113
+ expect(removed.filter((type) => type === 'pointerdown')).toHaveLength(1)
114
+ expect(() => document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))).not.toThrow()
115
+ })
116
+ })
117
+
118
+ describe('failed menu placement in a DOM', () => {
119
+ let original: typeof Element.prototype.getBoundingClientRect
120
+
121
+ beforeEach(() => {
122
+ original = Element.prototype.getBoundingClientRect
123
+ })
124
+ afterEach(() => {
125
+ Element.prototype.getBoundingClientRect = original
126
+ })
127
+
128
+ function stubLayout(bubbleTop: number): void {
129
+ Element.prototype.getBoundingClientRect = function (this: Element) {
130
+ const top = this.classList.contains('cv-p-thread__scroll') ? 100 : this.classList.contains('cv-p-bubble') ? bubbleTop : 0
131
+ const height = this.getAttribute('role') === 'menu' ? 90 : 20
132
+ return { top, bottom: top + height, left: 0, right: 0, width: 0, height, x: 0, y: top, toJSON: () => ({}) }
133
+ }
134
+ }
135
+
136
+ it('flips below when the bubble is the first of the conversation', () => {
137
+ stubLayout(108)
138
+ const { container, trigger } = mountMenu()
139
+ click(trigger)
140
+ Element.prototype.getBoundingClientRect = original
141
+
142
+ expect(container.querySelector('.cv-p-failed-menu__list--below')).not.toBeNull()
143
+ })
144
+
145
+ it('stays above when there is room', () => {
146
+ stubLayout(500)
147
+ const { container, trigger } = mountMenu()
148
+ click(trigger)
149
+ Element.prototype.getBoundingClientRect = original
150
+
151
+ expect(container.querySelector('[role="menu"]')).not.toBeNull()
152
+ expect(container.querySelector('.cv-p-failed-menu__list--below')).toBeNull()
153
+ })
154
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ CLOSED_FAILED_MENU,
5
+ failedMenuReducer,
6
+ resolveFailedMenuKey,
7
+ resolveFailedMenuPlacement,
8
+ resolveFailedMenuTriggerKey,
9
+ } from './participantFailedMenuState'
10
+
11
+ describe('failed menu state', () => {
12
+ it('toggles open on first focus item and closes again', () => {
13
+ const open = failedMenuReducer(CLOSED_FAILED_MENU, { type: 'toggle' })
14
+ expect(open).toEqual({ isOpen: true, initialFocus: 'first' })
15
+ expect(failedMenuReducer(open, { type: 'toggle' })).toEqual(CLOSED_FAILED_MENU)
16
+ })
17
+
18
+ it('opens on the last item when asked and ignores a close while closed', () => {
19
+ expect(failedMenuReducer(CLOSED_FAILED_MENU, { type: 'open', initialFocus: 'last' }).initialFocus).toBe('last')
20
+ expect(failedMenuReducer(CLOSED_FAILED_MENU, { type: 'close' })).toBe(CLOSED_FAILED_MENU)
21
+ })
22
+ })
23
+
24
+ describe('failed menu keyboard', () => {
25
+ it('Escape closes and gives the focus back to the trigger', () => {
26
+ expect(resolveFailedMenuKey({ key: 'Escape', index: 0, count: 2 })).toEqual({ type: 'close', shouldRestoreFocus: true })
27
+ })
28
+
29
+ it('Tab closes without trapping: focus is not pulled back', () => {
30
+ expect(resolveFailedMenuKey({ key: 'Tab', index: 1, count: 2 })).toEqual({ type: 'close', shouldRestoreFocus: false })
31
+ })
32
+
33
+ it('arrows wrap around, Home and End jump', () => {
34
+ expect(resolveFailedMenuKey({ key: 'ArrowDown', index: 1, count: 2 })).toEqual({ type: 'focus', index: 0 })
35
+ expect(resolveFailedMenuKey({ key: 'ArrowUp', index: 0, count: 2 })).toEqual({ type: 'focus', index: 1 })
36
+ expect(resolveFailedMenuKey({ key: 'Home', index: 1, count: 2 })).toEqual({ type: 'focus', index: 0 })
37
+ expect(resolveFailedMenuKey({ key: 'End', index: 0, count: 2 })).toEqual({ type: 'focus', index: 1 })
38
+ expect(resolveFailedMenuKey({ key: 'a', index: 0, count: 2 })).toBeUndefined()
39
+ })
40
+
41
+ it('ArrowDown and ArrowUp open the menu from the trigger on the matching end', () => {
42
+ expect(resolveFailedMenuTriggerKey('ArrowDown')).toBe('first')
43
+ expect(resolveFailedMenuTriggerKey('ArrowUp')).toBe('last')
44
+ expect(resolveFailedMenuTriggerKey('Enter')).toBeUndefined()
45
+ })
46
+ })
47
+
48
+ describe('failed menu placement', () => {
49
+ it('opens above when the scroller leaves room for the menu', () => {
50
+ expect(resolveFailedMenuPlacement({ anchorTop: 300, scrollerTop: 100, menuHeight: 90 })).toBe('above')
51
+ })
52
+
53
+ it('flips below for the first bubble of the conversation, where the scroller would clip it', () => {
54
+ expect(resolveFailedMenuPlacement({ anchorTop: 112, scrollerTop: 100, menuHeight: 90 })).toBe('below')
55
+ })
56
+
57
+ it('needs the gap too: room for exactly the menu is not enough', () => {
58
+ expect(resolveFailedMenuPlacement({ anchorTop: 190, scrollerTop: 100, menuHeight: 90 })).toBe('below')
59
+ expect(resolveFailedMenuPlacement({ anchorTop: 198, scrollerTop: 100, menuHeight: 90 })).toBe('above')
60
+ })
61
+ })
@@ -0,0 +1,57 @@
1
+ export type FailedMenuFocus = 'first' | 'last'
2
+
3
+ export type FailedMenuState = { readonly isOpen: boolean; readonly initialFocus: FailedMenuFocus }
4
+
5
+ export type FailedMenuAction =
6
+ | { readonly type: 'open'; readonly initialFocus: FailedMenuFocus }
7
+ | { readonly type: 'close' }
8
+ | { readonly type: 'toggle' }
9
+
10
+ export const CLOSED_FAILED_MENU: FailedMenuState = { isOpen: false, initialFocus: 'first' }
11
+
12
+ export function failedMenuReducer(state: FailedMenuState, action: FailedMenuAction): FailedMenuState {
13
+ if (action.type === 'open') return { isOpen: true, initialFocus: action.initialFocus }
14
+ if (action.type === 'close') return state.isOpen ? CLOSED_FAILED_MENU : state
15
+ return state.isOpen ? CLOSED_FAILED_MENU : { isOpen: true, initialFocus: 'first' }
16
+ }
17
+
18
+ export type FailedMenuKeyResult =
19
+ | { readonly type: 'focus'; readonly index: number }
20
+ | { readonly type: 'close'; readonly shouldRestoreFocus: boolean }
21
+
22
+ type MenuKeyParams = { readonly key: string; readonly index: number; readonly count: number }
23
+
24
+ /** Roving focus inside the open menu; Tab is never trapped, it closes the menu and lets focus move on. */
25
+ export function resolveFailedMenuKey({ key, index, count }: MenuKeyParams): FailedMenuKeyResult | undefined {
26
+ if (key === 'Escape') return { type: 'close', shouldRestoreFocus: true }
27
+ if (key === 'Tab') return { type: 'close', shouldRestoreFocus: false }
28
+ if (key === 'ArrowDown') return { type: 'focus', index: (index + 1) % count }
29
+ if (key === 'ArrowUp') return { type: 'focus', index: (index - 1 + count) % count }
30
+ if (key === 'Home') return { type: 'focus', index: 0 }
31
+ if (key === 'End') return { type: 'focus', index: count - 1 }
32
+ return undefined
33
+ }
34
+
35
+ /** Keys that open the menu from its trigger; Enter and Space already open it through the click. */
36
+ export function resolveFailedMenuTriggerKey(key: string): FailedMenuFocus | undefined {
37
+ if (key === 'ArrowDown') return 'first'
38
+ if (key === 'ArrowUp') return 'last'
39
+ return undefined
40
+ }
41
+
42
+ export type FailedMenuPlacement = 'above' | 'below'
43
+
44
+ const MENU_GAP_PX = 8
45
+
46
+ type MenuPlacementParams = {
47
+ /** Top edge of the bubble the menu hangs from. */
48
+ readonly anchorTop: number
49
+ /** Top edge of the scroller that clips the menu. */
50
+ readonly scrollerTop: number
51
+ readonly menuHeight: number
52
+ }
53
+
54
+ /** The menu opens above its bubble; it flips below when the scroller would clip it. */
55
+ export function resolveFailedMenuPlacement({ anchorTop, scrollerTop, menuHeight }: MenuPlacementParams): FailedMenuPlacement {
56
+ return anchorTop - scrollerTop >= menuHeight + MENU_GAP_PX ? 'above' : 'below'
57
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { OwnStatus, RetryButton } from './ParticipantBubbleStatus'
5
+ import { ParticipantFailedMenu } from './ParticipantFailedMenu'
6
+ import { declarationsIn } from './participantCss.test-helper'
7
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as LABELS } from './participantLabels'
8
+
9
+ const noop = (): void => undefined
10
+
11
+ describe('failed message: markup', () => {
12
+ it('puts retry in a round icon button with the long text as its name, no visible text', () => {
13
+ const markup = renderToStaticMarkup(<RetryButton labels={LABELS} onRetry={noop} />)
14
+
15
+ expect(markup).toContain('aria-label="Failed — tap to retry"')
16
+ expect(markup).toContain('aria-hidden="true"')
17
+ expect(markup.replace(/<svg.*<\/svg>/, '')).not.toMatch(/>[A-Za-z]/)
18
+ })
19
+
20
+ it('keeps the meta of a failed bubble to the red icon, with the short text for screen readers only', () => {
21
+ const withRetry = renderToStaticMarkup(<OwnStatus status="failed" labels={LABELS} onRetry={noop} />)
22
+ const withoutRetry = renderToStaticMarkup(<OwnStatus status="failed" labels={LABELS} />)
23
+
24
+ expect(withRetry).not.toContain('<button')
25
+ expect(withRetry).toContain('cv-status-ticks--failed')
26
+ expect(withRetry).toContain('cv-p-sr-only">Failed<')
27
+ expect(withoutRetry).toContain('cv-p-bubble__status-text">Failed<')
28
+ })
29
+
30
+ it('renders a closed menu trigger with the menu button semantics, and no menu without handlers', () => {
31
+ const markup = renderToStaticMarkup(<ParticipantFailedMenu labels={LABELS} onEdit={noop} onDiscard={noop} />)
32
+
33
+ expect(markup).toContain('aria-haspopup="menu"')
34
+ expect(markup).toContain('aria-expanded="false"')
35
+ expect(markup).toContain('data-cv-tooltip="Message options"')
36
+ expect(markup).toContain('aria-label="Message options"')
37
+ expect(markup).not.toContain('role="menu"')
38
+ expect(renderToStaticMarkup(<ParticipantFailedMenu labels={LABELS} />)).toBe('')
39
+ })
40
+ })
41
+
42
+ describe('failed message: styles', () => {
43
+ it('hangs the retry button beside the bubble: 44px target, 2rem circle, no layout space taken', () => {
44
+ const button = declarationsIn('.cv-p-failed-retry')
45
+ const icon = declarationsIn('.cv-p-failed-retry__icon')
46
+
47
+ expect(button.get('position')).toBe('absolute')
48
+ expect(button.get('right')).toContain('100%')
49
+ expect(button.get('width')).toBe('var(--cv-p-i-touch)')
50
+ expect(button.get('height')).toBe('var(--cv-p-i-touch)')
51
+ expect(icon.get('width')).toBe('2rem')
52
+ expect(icon.get('border-radius')).toBe('50%')
53
+ expect(button.get('color')).toBe('var(--cv-p-i-danger)')
54
+ })
55
+
56
+ it('keeps the bubble cap of 85% by not wrapping the bubble in a row', () => {
57
+ expect(declarationsIn('.cv-p-bubble-row--failed').size).toBe(0)
58
+ expect(declarationsIn('.cv-p-bubble').get('max-width')).toBe('85%')
59
+ })
60
+
61
+ it('flips the menu below the bubble with a modifier', () => {
62
+ expect(declarationsIn('.cv-p-failed-menu__list--below').get('top')).toContain('100%')
63
+ expect(declarationsIn('.cv-p-failed-menu__list--below').get('bottom')).toBe('auto')
64
+ })
65
+
66
+ it('draws the menu with square corners, text and danger tokens, 44px items', () => {
67
+ expect(declarationsIn('.cv-p-failed-menu__list').get('border-radius')).toBe('var(--cv-p-i-radius)')
68
+ expect(declarationsIn('.cv-p-failed-menu__list').get('bottom')).toContain('100%')
69
+ expect(declarationsIn('.cv-p-failed-menu__item').get('color')).toBe('var(--cv-p-i-text)')
70
+ expect(declarationsIn('.cv-p-failed-menu__item').get('min-height')).toBe('var(--cv-p-i-touch)')
71
+ expect(declarationsIn('.cv-p-failed-menu__item--danger').get('color')).toBe('var(--cv-p-i-danger)')
72
+ })
73
+
74
+ it('keeps the trigger hit area inside the bubble padding (0.5rem), never into other rows', () => {
75
+ expect(declarationsIn('.cv-p-failed-menu__trigger::after').get('inset')).toBe('-0.5rem -0.4rem')
76
+ })
77
+
78
+ it('has no leftover action-row or bordered-retry rules', () => {
79
+ for (const selector of ['.cv-p-bubble__retry', '.cv-p-bubble__action', '.cv-p-bubble__actions']) {
80
+ expect(declarationsIn(selector).size).toBe(0)
81
+ }
82
+ })
83
+ })
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { beforeAll, describe, expect, it } from 'bun:test'
5
+
6
+ import { ParticipantConversations } from './ParticipantConversations'
7
+ import { ParticipantThread } from './ParticipantThread'
8
+ import { renderGoldenScenarios, type GoldenComponents } from './participantGoldenScenarios.test-helper'
9
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
10
+
11
+ const FIXTURES_DIRECTORY = join(import.meta.dir, 'test-fixtures')
12
+
13
+ function readGolden(name: string): string {
14
+ return readFileSync(join(FIXTURES_DIRECTORY, `${name}.golden.html`), 'utf8')
15
+ }
16
+
17
+ describe('participant default markup against the recorded origin/main output', () => {
18
+ let rendered: Record<string, string> = {}
19
+
20
+ beforeAll(() => {
21
+ process.env.TZ = 'UTC'
22
+ rendered = renderGoldenScenarios({
23
+ ParticipantThread,
24
+ ParticipantConversations,
25
+ labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
26
+ } as unknown as GoldenComponents)
27
+ })
28
+
29
+ const scenarioNames = [
30
+ 'thread-default',
31
+ 'thread-plain',
32
+ 'thread-avatars-initials',
33
+ 'thread-tail-off',
34
+ 'thread-error',
35
+ 'thread-loading',
36
+ 'thread-closed',
37
+ 'conversations-list',
38
+ 'conversations-selected',
39
+ ]
40
+
41
+ for (const name of scenarioNames) {
42
+ it(`${name} is byte-identical`, () => {
43
+ expect(rendered[name]).toBe(readGolden(name))
44
+ })
45
+ }
46
+ })
@@ -0,0 +1,57 @@
1
+ import type { ComponentType } from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { buildConversation, buildMessage } from './participantFixtures.test-helper'
5
+
6
+ /** The components under comparison are injected so the same scenarios render against any checkout. */
7
+ export type GoldenComponents = {
8
+ readonly ParticipantThread: ComponentType<Record<string, unknown>>
9
+ readonly ParticipantConversations: ComponentType<Record<string, unknown>>
10
+ readonly labels: unknown
11
+ }
12
+
13
+ const NOOP = (): void => undefined
14
+ const SUBJECT_GROUPS = [{ subjectType: 'invoice', label: 'Invoices' }]
15
+
16
+ const ITEMS = [
17
+ { kind: 'server', message: buildMessage({ id: 'a', direction: 'inbound', authorName: 'Ana Souza', text: '**oi** 11 98888-7777 a@b.com https://x.com', createdAt: '2026-10-01T12:00:00.000Z' }) },
18
+ { kind: 'server', message: buildMessage({ id: 'b', direction: 'outbound', authorName: 'Op', status: 'read', createdAt: '2026-10-01T12:05:00.000Z' }) },
19
+ { kind: 'server', message: buildMessage({ id: 'b2', direction: 'inbound', status: 'delivered', createdAt: '2026-10-02T12:05:00.000Z' }) },
20
+ { kind: 'pending', origin: 'local', displayState: 'failed', pending: { clientMessageId: 'c', subject: { subjectType: 'invoice', subjectId: '1' }, text: 'W', createdAt: '2026-10-02T12:06:00.000Z', state: 'failed' } },
21
+ ]
22
+
23
+ function buildThreadProps(labels: unknown): Record<string, unknown> {
24
+ return {
25
+ conversation: buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521', protocol: 'P-1', channels: ['app', 'whatsapp'] }),
26
+ items: ITEMS, hasMore: true, onLoadOlder: NOOP, labels, resolveAttachmentUrl: async () => 'x',
27
+ draft: { value: 'abc', onChange: NOOP, files: [], onFilesChange: NOOP }, onSend: NOOP, status: 'ready', refresh: NOOP,
28
+ scroll: { ref: { current: null }, onScroll: NOOP, isAwayFromBottom: true, scrollToLatest: NOOP },
29
+ newMessagesCount: 2, isSending: false, onBack: NOOP,
30
+ pendingActions: { retryLocal: NOOP, discardLocal: NOOP, editLocal: NOOP },
31
+ quickReplies: [{ id: '1', title: 'T', shortcut: 't', body: 'b' }],
32
+ }
33
+ }
34
+
35
+ const THREAD_VARIANTS: Record<string, Record<string, unknown>> = {
36
+ 'thread-default': {},
37
+ 'thread-plain': { hasMore: false, newMessagesCount: 0, quickReplies: [], scroll: { ref: { current: null }, onScroll: NOOP, isAwayFromBottom: false, scrollToLatest: NOOP }, items: ITEMS.slice(0, 2) },
38
+ 'thread-avatars-initials': { avatars: 'initials' },
39
+ 'thread-tail-off': { tail: false },
40
+ 'thread-error': { status: 'error', items: [] },
41
+ 'thread-loading': { status: 'loading', items: [] },
42
+ 'thread-closed': { conversation: buildConversation({ subjectId: '2', status: 'closed' }) },
43
+ }
44
+
45
+ export function renderGoldenScenarios({ ParticipantThread, ParticipantConversations, labels }: GoldenComponents): Record<string, string> {
46
+ const base = buildThreadProps(labels)
47
+ const html: Record<string, string> = {}
48
+ for (const [name, extra] of Object.entries(THREAD_VARIANTS)) {
49
+ html[name] = renderToStaticMarkup(<ParticipantThread {...base} {...extra} />)
50
+ }
51
+ const api = { listConversations: async () => ({ data: [] }), fetchMessages: async () => [], sendMessage: async () => ({ outcome: 'queued' }), markRead: async () => undefined, resolveAttachmentUrl: async () => 'x' }
52
+ html['conversations-list'] = renderToStaticMarkup(<ParticipantConversations api={api} subjectGroups={SUBJECT_GROUPS} />)
53
+ html['conversations-selected'] = renderToStaticMarkup(
54
+ <ParticipantConversations api={api} subjectGroups={SUBJECT_GROUPS} selected={{ subjectType: 'invoice', subjectId: '1' }} />,
55
+ )
56
+ return html
57
+ }
@@ -1,3 +1,5 @@
1
+ export const DEFAULT_MESSAGE_OPTIONS_LABEL = 'Message options'
2
+
1
3
  export type ParticipantConversationsLabels = {
2
4
  readonly inboxTitle: string
3
5
  readonly sectionAwaiting: string
@@ -33,9 +35,12 @@ export type ParticipantConversationsLabels = {
33
35
  readonly statusRead: string
34
36
  readonly statusFailed: string
35
37
  readonly statusFailedShort: string
38
+ /** @deprecated No longer rendered by the default view: the retry button is icon-only and named by `statusFailed`. */
36
39
  readonly retry: string
37
40
  readonly discard: string
38
41
  readonly edit: string
42
+ /** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
43
+ readonly messageOptions?: string
39
44
  readonly notFound: string
40
45
  readonly loading: string
41
46
  readonly loadError: string
@@ -98,6 +103,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
98
103
  retry: 'Retry',
99
104
  discard: 'Discard',
100
105
  edit: 'Edit',
106
+ messageOptions: DEFAULT_MESSAGE_OPTIONS_LABEL,
101
107
  notFound: 'Conversation not found',
102
108
  loading: 'Loading…',
103
109
  loadError: 'Could not load. Check your connection and try again.',
@@ -1,6 +1,7 @@
1
1
  import type { MessageDeliveryStatus, ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
2
 
3
3
  import type { ParticipantLocalPendingMessage, ParticipantPendingMessage } from './participantApi.types'
4
+ import { isMessageMine, type ParticipantPerspective } from './participantPerspective'
4
5
 
5
6
  export type ParticipantPendingDisplayState = 'sending' | 'queued' | 'failed' | 'sent'
6
7
  export type ParticipantOwnMessageStatus = 'sending' | 'queued' | 'sent' | 'delivered' | 'read' | 'failed'
@@ -58,9 +59,9 @@ export function mergeParticipantMessages(params: MergeParticipantMessagesParams)
58
59
  return items.sort((left, right) => createdAtOf(left) - createdAtOf(right))
59
60
  }
60
61
 
61
- /** Direction is from the company perspective: the participant is the inbound side. */
62
- export function isOwnMessage(message: ParticipantMessage): boolean {
63
- return message.direction === 'inbound'
62
+ /** Direction is from the company side: the participant is the inbound side, the operator the outbound one. */
63
+ export function isOwnMessage(message: ParticipantMessage, perspective?: ParticipantPerspective): boolean {
64
+ return isMessageMine(message, perspective)
64
65
  }
65
66
 
66
67
  export function resolveOwnMessageStatus(params: ResolveOwnMessageStatusParams): ParticipantOwnMessageStatus {
@@ -52,3 +52,24 @@ describe('collectServerMessageIds', () => {
52
52
  expect([...collectServerMessageIds([server('a', 'inbound'), server('b', 'outbound')])]).toEqual(['a', 'b'])
53
53
  })
54
54
  })
55
+
56
+ describe('countNewIncomingMessages with the operator perspective', () => {
57
+ const seen = [server('s', 'inbound', '2026-10-01T10:00:00.000Z')]
58
+ const seenIds = collectServerMessageIds(seen)
59
+
60
+ it('counts a new inbound message as news', () => {
61
+ const items = [...seen, server('n', 'inbound', '2026-10-01T10:05:00.000Z')]
62
+ expect(countNewIncomingMessages(seenIds, items, 'operator')).toBe(1)
63
+ })
64
+
65
+ it('does not count a new outbound message (written by the operator)', () => {
66
+ const items = [...seen, server('n', 'outbound', '2026-10-01T10:05:00.000Z')]
67
+ expect(countNewIncomingMessages(seenIds, items, 'operator')).toBe(0)
68
+ })
69
+
70
+ it('keeps the participant behavior when the perspective is omitted or participant', () => {
71
+ const items = [...seen, server('n', 'outbound', '2026-10-01T10:05:00.000Z'), server('m', 'inbound', '2026-10-01T10:06:00.000Z')]
72
+ expect(countNewIncomingMessages(seenIds, items)).toBe(1)
73
+ expect(countNewIncomingMessages(seenIds, items, 'participant')).toBe(1)
74
+ })
75
+ })
@@ -1,4 +1,5 @@
1
1
  import type { ParticipantTimelineItem } from './participantMessages'
2
+ import { isMessageMine, type ParticipantPerspective } from './participantPerspective'
2
3
 
3
4
  export function collectServerMessageIds(items: readonly ParticipantTimelineItem[]): ReadonlySet<string> {
4
5
  const ids = new Set<string>()
@@ -16,13 +17,17 @@ function latestSeenTime(previousIds: ReadonlySet<string>, items: readonly Partic
16
17
  return latest
17
18
  }
18
19
 
19
- /** Messages from the other side ('outbound') newer than what the participant has seen; older history loaded above is not news. */
20
- export function countNewIncomingMessages(previousIds: ReadonlySet<string>, currentItems: readonly ParticipantTimelineItem[]): number {
20
+ /** Messages from the other side of the viewer's perspective newer than what the participant has seen; older history loaded above is not news. */
21
+ export function countNewIncomingMessages(
22
+ previousIds: ReadonlySet<string>,
23
+ currentItems: readonly ParticipantTimelineItem[],
24
+ perspective?: ParticipantPerspective,
25
+ ): number {
21
26
  const latestSeen = latestSeenTime(previousIds, currentItems)
22
27
  return currentItems.filter(
23
28
  (item) =>
24
29
  item.kind === 'server' &&
25
- item.message.direction === 'outbound' &&
30
+ !isMessageMine(item.message, perspective) &&
26
31
  !previousIds.has(item.message.id) &&
27
32
  Date.parse(item.message.createdAt) > latestSeen,
28
33
  ).length
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { buildMessage } from './participantFixtures.test-helper'
4
+ import { isOwnMessage } from './participantMessages'
5
+ import {
6
+ countOtherSideMessages,
7
+ countUnreadByViewer,
8
+ isMessageMine,
9
+ resolveViewerUnread,
10
+ } from './participantPerspective'
11
+
12
+ const INBOUND = buildMessage({ id: 'i', direction: 'inbound' })
13
+ const OUTBOUND = buildMessage({ id: 'o', direction: 'outbound' })
14
+
15
+ describe('isMessageMine', () => {
16
+ it('treats inbound as mine for the participant, and by default', () => {
17
+ expect(isMessageMine(INBOUND, 'participant')).toBe(true)
18
+ expect(isMessageMine(OUTBOUND, 'participant')).toBe(false)
19
+ expect(isMessageMine(INBOUND)).toBe(true)
20
+ expect(isMessageMine(OUTBOUND)).toBe(false)
21
+ })
22
+
23
+ it('inverts for the operator: outbound is mine', () => {
24
+ expect(isMessageMine(OUTBOUND, 'operator')).toBe(true)
25
+ expect(isMessageMine(INBOUND, 'operator')).toBe(false)
26
+ })
27
+
28
+ it('keeps isOwnMessage identical without a perspective', () => {
29
+ expect(isOwnMessage(INBOUND)).toBe(true)
30
+ expect(isOwnMessage(OUTBOUND)).toBe(false)
31
+ expect(isOwnMessage(OUTBOUND, 'operator')).toBe(true)
32
+ })
33
+ })
34
+
35
+ describe('unread by the viewer', () => {
36
+ const MESSAGES = [
37
+ buildMessage({ id: 'a', direction: 'inbound' }),
38
+ buildMessage({ id: 'b', direction: 'inbound', status: 'read' }),
39
+ buildMessage({ id: 'c', direction: 'inbound', readAt: '2026-10-01T11:00:00.000Z' }),
40
+ buildMessage({ id: 'd', direction: 'outbound' }),
41
+ buildMessage({ id: 'e', direction: 'outbound' }),
42
+ ]
43
+
44
+ it('counts only the other side, never the viewer own messages', () => {
45
+ expect(countUnreadByViewer(MESSAGES, 'operator')).toBe(1)
46
+ expect(countUnreadByViewer(MESSAGES, 'participant')).toBe(2)
47
+ expect(countOtherSideMessages(MESSAGES, 'operator')).toBe(3)
48
+ expect(countOtherSideMessages(MESSAGES, 'participant')).toBe(2)
49
+ })
50
+
51
+ it('reads nothing as unread when the operator has only outbound messages', () => {
52
+ expect(countUnreadByViewer([OUTBOUND, OUTBOUND], 'operator')).toBe(0)
53
+ })
54
+
55
+ it('resolves to zero until something new arrives after the acknowledgement', () => {
56
+ const base = { messages: MESSAGES, perspective: 'operator' } as const
57
+ expect(resolveViewerUnread({ ...base, acknowledgedCount: 0 })).toBe(1)
58
+ expect(resolveViewerUnread({ ...base, acknowledgedCount: 3 })).toBe(0)
59
+ expect(
60
+ resolveViewerUnread({
61
+ ...base,
62
+ messages: [...MESSAGES, buildMessage({ id: 'f', direction: 'inbound' })],
63
+ acknowledgedCount: 3,
64
+ }),
65
+ ).toBe(2)
66
+ })
67
+ })