@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,60 @@
1
+ import { useReducer, useRef, type FocusEvent, type KeyboardEvent, type RefObject } from 'react'
2
+
3
+ import { handleFailedMenuKeyDown } from './failedMenuKeyboard'
4
+ import {
5
+ CLOSED_FAILED_MENU,
6
+ failedMenuReducer,
7
+ resolveFailedMenuTriggerKey,
8
+ type FailedMenuPlacement,
9
+ type FailedMenuState,
10
+ } from './participantFailedMenuState'
11
+ import { useFailedMenuInitialFocus, useFailedMenuOutsideClose, useFailedMenuPlacement } from './useFailedMenuEffects'
12
+
13
+ export type ParticipantFailedMenuController = {
14
+ readonly state: FailedMenuState
15
+ readonly placement: FailedMenuPlacement
16
+ readonly containerRef: RefObject<HTMLSpanElement | null>
17
+ readonly triggerRef: RefObject<HTMLButtonElement | null>
18
+ readonly menuRef: RefObject<HTMLDivElement | null>
19
+ readonly handleTriggerClick: () => void
20
+ readonly handleTriggerKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void
21
+ readonly handleMenuKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void
22
+ readonly handleBlur: (event: FocusEvent<HTMLSpanElement>) => void
23
+ readonly close: () => void
24
+ }
25
+
26
+ export function useParticipantFailedMenu(): ParticipantFailedMenuController {
27
+ const [state, dispatch] = useReducer(failedMenuReducer, CLOSED_FAILED_MENU)
28
+ const containerRef = useRef<HTMLSpanElement | null>(null)
29
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
30
+ const menuRef = useRef<HTMLDivElement | null>(null)
31
+
32
+ useFailedMenuInitialFocus(state, menuRef)
33
+ useFailedMenuOutsideClose(state.isOpen, containerRef, dispatch)
34
+ const placement = useFailedMenuPlacement(state.isOpen, { triggerRef, menuRef })
35
+
36
+ function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>): void {
37
+ const initialFocus = resolveFailedMenuTriggerKey(event.key)
38
+ if (!initialFocus) return
39
+ event.preventDefault()
40
+ dispatch({ type: 'open', initialFocus })
41
+ }
42
+
43
+ function handleBlur(event: FocusEvent<HTMLSpanElement>): void {
44
+ const next = event.relatedTarget
45
+ if (next instanceof Node && !containerRef.current?.contains(next)) dispatch({ type: 'close' })
46
+ }
47
+
48
+ return {
49
+ state,
50
+ placement,
51
+ containerRef,
52
+ triggerRef,
53
+ menuRef,
54
+ handleTriggerClick: () => dispatch({ type: 'toggle' }),
55
+ handleTriggerKeyDown,
56
+ handleMenuKeyDown: (event) => handleFailedMenuKeyDown({ event, menuRef, triggerRef, dispatch }),
57
+ handleBlur,
58
+ close: () => dispatch({ type: 'close' }),
59
+ }
60
+ }
@@ -17,9 +17,11 @@ import {
17
17
  type ParticipantSendEntry,
18
18
  } from './participantSendController'
19
19
  import { INITIAL_PARTICIPANT_THREAD_STATE, participantThreadReducer, type ParticipantThreadState } from './participantThreadState'
20
+ import type { ParticipantPerspective } from './participantPerspective'
20
21
  import { useParticipantMarkRead } from './useParticipantMarkRead'
21
22
  import { useParticipantRevalidation } from './useParticipantRevalidation'
22
23
  import { useParticipantSend, type UseParticipantSendResult } from './useParticipantSend'
24
+ import { useParticipantUnread } from './useParticipantUnread'
23
25
 
24
26
  const THREAD_PAGE_SIZE = 30
25
27
 
@@ -35,6 +37,8 @@ export type UseParticipantThreadParams = {
35
37
  readonly sendEntries: readonly ParticipantSendEntry[]
36
38
  readonly dispatchSend: (action: ParticipantSendAction) => void
37
39
  readonly hostPending?: readonly ParticipantPendingMessage[]
40
+ /** Present on a single-conversation screen: unread comes from the messages, not from `unreadCount`. */
41
+ readonly perspective?: ParticipantPerspective
38
42
  }
39
43
 
40
44
  export type UseParticipantThreadResult = ParticipantThreadState &
@@ -55,7 +59,7 @@ function isForSubject(event: ParticipantConversationEvent | undefined, subject:
55
59
  }
56
60
 
57
61
  export function useParticipantThread(params: UseParticipantThreadParams): UseParticipantThreadResult {
58
- const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [] } = params
62
+ const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [], perspective } = params
59
63
  const { subjectType, subjectId } = subject
60
64
  const stableSubject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
61
65
  const [state, dispatch] = useReducer(participantThreadReducer, INITIAL_PARTICIPANT_THREAD_STATE)
@@ -109,7 +113,14 @@ export function useParticipantThread(params: UseParticipantThreadParams): UsePar
109
113
  dispatchSend({ type: 'reflected', knownClientMessageIds: collectKnownClientMessageIds({ messages: state.messages, hostPending }) })
110
114
  }, [sendEntries, state.messages, hostPending, dispatchSend])
111
115
 
112
- useParticipantMarkRead({ apiRef, subject: stableSubject, unreadCount, messageCount: state.messages.length, onMarkedRead })
116
+ const unread = useParticipantUnread({ messages: state.messages, perspective, reportedUnreadCount: unreadCount, onMarkedRead })
117
+ useParticipantMarkRead({
118
+ apiRef,
119
+ subject: stableSubject,
120
+ unreadCount: unread.unreadCount,
121
+ messageCount: state.messages.length,
122
+ onMarkedRead: unread.handleMarkedRead,
123
+ })
113
124
 
114
125
  const localPending = useMemo(() => toLocalPending(sendEntries), [sendEntries])
115
126
  return { ...state, localPending, confirmsRead, refresh, loadOlder, ...sending }
@@ -0,0 +1,112 @@
1
+ import { useMemo, type Dispatch, type MutableRefObject } from 'react'
2
+
3
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ConversationChannel } from '../conversationChannel'
6
+ import type { ParticipantThreadProps } from './ParticipantThread'
7
+ import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
8
+ import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
9
+ import type { ParticipantDrafts } from './participantDrafts'
10
+ import type { ParticipantConversationsLabels } from './participantLabels'
11
+ import type { ParticipantPerspective } from './participantPerspective'
12
+ import { findFailedEntry, hasSendingEntry, type ParticipantSendAction } from './participantSendController'
13
+ import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
14
+ import { useParticipantConversationView } from './useParticipantConversationView'
15
+ import { useParticipantDraft } from './useParticipantDraft'
16
+ import { useParticipantThread, type UseParticipantThreadResult } from './useParticipantThread'
17
+ import { useParticipantThreadScroll } from './useParticipantThreadScroll'
18
+
19
+ export type ParticipantThreadControllerParams = {
20
+ readonly api: ParticipantConversationsApi
21
+ readonly subject: ParticipantSubjectRef
22
+ readonly conversation: ParticipantConversationSummary
23
+ readonly labels: ParticipantConversationsLabels
24
+ /** Holds the immutable drafts of every subject; replaced, never mutated. */
25
+ readonly draftsRef: MutableRefObject<ParticipantDrafts>
26
+ readonly sendStates: ParticipantSendStates
27
+ readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
28
+ readonly onMarkedRead: (subject: ParticipantSubjectRef) => void
29
+ readonly channel?: ConversationChannel
30
+ readonly locale?: string
31
+ readonly pendingMessages?: readonly ParticipantPendingMessage[]
32
+ readonly onRetryPending?: (clientMessageId: string) => void
33
+ readonly perspective?: ParticipantPerspective
34
+ }
35
+
36
+ /** Everything a conversation screen needs from the hooks; the caller adds only its own presentation props. */
37
+ export type ParticipantThreadCoreProps = Pick<
38
+ ParticipantThreadProps,
39
+ | 'conversation'
40
+ | 'items'
41
+ | 'hasMore'
42
+ | 'labels'
43
+ | 'resolveAttachmentUrl'
44
+ | 'draft'
45
+ | 'onSend'
46
+ | 'status'
47
+ | 'refresh'
48
+ | 'scroll'
49
+ | 'newMessagesCount'
50
+ | 'isSending'
51
+ | 'channel'
52
+ | 'locale'
53
+ | 'onLoadOlder'
54
+ | 'pendingActions'
55
+ | 'perspective'
56
+ >
57
+
58
+ type BuildPendingActionsParams = {
59
+ readonly thread: UseParticipantThreadResult
60
+ readonly dispatchSend: (action: ParticipantSendAction) => void
61
+ readonly handleEdit: (clientMessageId: string) => void
62
+ readonly onRetryPending: ParticipantThreadControllerParams['onRetryPending']
63
+ }
64
+
65
+ function buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending }: BuildPendingActionsParams): ParticipantThreadCoreProps['pendingActions'] {
66
+ return {
67
+ retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
68
+ discardLocal: (clientMessageId) => dispatchSend({ type: 'discarded', clientMessageId }),
69
+ editLocal: handleEdit,
70
+ ...(onRetryPending ? { retryHost: onRetryPending } : {}),
71
+ }
72
+ }
73
+
74
+ export function useParticipantThreadController(params: ParticipantThreadControllerParams): ParticipantThreadCoreProps {
75
+ const { api, subject, conversation, draftsRef } = params
76
+ const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, subject)
77
+ const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView(params)
78
+ const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
79
+ const { scroll, newMessagesCount } = useParticipantThreadScroll(items, params.perspective)
80
+
81
+ function handleSend(): void {
82
+ const content = takeForSend()
83
+ if (content) void thread.send(content)
84
+ }
85
+
86
+ function handleEdit(clientMessageId: string): void {
87
+ const entry = findFailedEntry(sendEntries, clientMessageId)
88
+ if (!entry) return
89
+ restoreFromEntry(entry)
90
+ dispatchSend({ type: 'discarded', clientMessageId })
91
+ }
92
+
93
+ return {
94
+ conversation,
95
+ items,
96
+ hasMore: thread.hasMore,
97
+ labels: params.labels,
98
+ resolveAttachmentUrl,
99
+ draft: { value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles },
100
+ onSend: handleSend,
101
+ status: thread.status,
102
+ refresh: () => void thread.refresh(),
103
+ scroll,
104
+ newMessagesCount,
105
+ isSending: hasSendingEntry(sendEntries),
106
+ channel: params.channel,
107
+ locale: params.locale,
108
+ onLoadOlder: () => void thread.loadOlder(),
109
+ pendingActions: buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending: params.onRetryPending }),
110
+ perspective: params.perspective,
111
+ }
112
+ }
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
 
3
3
  import { collectServerMessageIds, countNewIncomingMessages } from './participantNewMessages'
4
4
  import type { ParticipantTimelineItem } from './participantMessages'
5
+ import type { ParticipantPerspective } from './participantPerspective'
5
6
  import { useScrollAwayState } from './useScrollAwayState'
6
7
  import { useStickToBottom, type ParticipantThreadScroll } from './useStickToBottom'
7
8
 
@@ -17,6 +18,7 @@ function keyOf(item: ParticipantTimelineItem | undefined): string | undefined {
17
18
 
18
19
  export function useParticipantThreadScroll(
19
20
  items: readonly ParticipantTimelineItem[],
21
+ perspective?: ParticipantPerspective,
20
22
  ): UseParticipantThreadScrollResult {
21
23
  const { scroll: base, isNearBottom } = useStickToBottom({
22
24
  firstKey: keyOf(items[0]),
@@ -30,5 +32,5 @@ export function useParticipantThreadScroll(
30
32
  if (isNearBottom) seenIds.current = collectServerMessageIds(items)
31
33
  }, [isNearBottom, items])
32
34
 
33
- return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items) }
35
+ return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items, perspective) }
34
36
  }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
5
+
6
+ import { buildMessage } from './participantFixtures.test-helper'
7
+ import type { ParticipantPerspective } from './participantPerspective'
8
+ import { useParticipantUnread } from './useParticipantUnread'
9
+
10
+ const MESSAGES: readonly ParticipantMessage[] = [
11
+ buildMessage({ id: 'a', direction: 'inbound' }),
12
+ buildMessage({ id: 'b', direction: 'inbound' }),
13
+ buildMessage({ id: 'c', direction: 'outbound' }),
14
+ ]
15
+
16
+ type ProbeProps = { readonly perspective?: ParticipantPerspective; readonly reported: number }
17
+
18
+ function Probe({ perspective, reported }: ProbeProps) {
19
+ const { unreadCount } = useParticipantUnread({ messages: MESSAGES, perspective, reportedUnreadCount: reported })
20
+ return <output>{unreadCount}</output>
21
+ }
22
+
23
+ function unreadOf(props: ProbeProps): string {
24
+ return renderToStaticMarkup(<Probe {...props} />)
25
+ }
26
+
27
+ describe('useParticipantUnread', () => {
28
+ it('keeps the count the inbox reported when no perspective is given', () => {
29
+ expect(unreadOf({ reported: 3 })).toBe('<output>3</output>')
30
+ expect(unreadOf({ reported: 0 })).toBe('<output>0</output>')
31
+ })
32
+
33
+ it('derives from the messages for the operator: only the inbound ones, whatever the inbox said', () => {
34
+ expect(unreadOf({ perspective: 'operator', reported: 9 })).toBe('<output>2</output>')
35
+ })
36
+
37
+ it('derives from the messages for the participant: only the outbound ones', () => {
38
+ expect(unreadOf({ perspective: 'participant', reported: 9 })).toBe('<output>1</output>')
39
+ })
40
+ })
@@ -0,0 +1,38 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ import type { ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import { countOtherSideMessages, resolveViewerUnread, type ParticipantPerspective } from './participantPerspective'
6
+
7
+ export type UseParticipantUnreadParams = {
8
+ readonly messages: readonly ParticipantMessage[]
9
+ /** Absent keeps the count the inbox reported, which is how the list-driven screen works. */
10
+ readonly perspective?: ParticipantPerspective
11
+ readonly reportedUnreadCount: number
12
+ readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
13
+ }
14
+
15
+ export type UseParticipantUnreadResult = {
16
+ readonly unreadCount: number
17
+ readonly handleMarkedRead: (subject: ParticipantSubjectRef) => void
18
+ }
19
+
20
+ /** A single conversation has no inbox, so what the viewer has not read is derived from the messages themselves. */
21
+ export function useParticipantUnread(params: UseParticipantUnreadParams): UseParticipantUnreadResult {
22
+ const { messages, perspective, reportedUnreadCount, onMarkedRead } = params
23
+ const [acknowledgedCount, setAcknowledgedCount] = useState(0)
24
+ const messagesRef = useRef(messages)
25
+ messagesRef.current = messages
26
+
27
+ const handleMarkedRead = useCallback(
28
+ (subject: ParticipantSubjectRef): void => {
29
+ if (perspective) setAcknowledgedCount(countOtherSideMessages(messagesRef.current, perspective))
30
+ onMarkedRead?.(subject)
31
+ },
32
+ [perspective, onMarkedRead],
33
+ )
34
+ const unreadCount = perspective
35
+ ? resolveViewerUnread({ messages, perspective, acknowledgedCount })
36
+ : reportedUnreadCount
37
+ return { unreadCount, handleMarkedRead }
38
+ }
@@ -0,0 +1,38 @@
1
+ import { useEffect, type RefObject } from 'react'
2
+
3
+ /**
4
+ * Keeps the package tooltip layer (which listens on the document) from lighting up for touch: a tap
5
+ * focuses the button on Android and the balloon would stay on. The events are stopped at the thread
6
+ * root, so the layer keeps its behavior for every other flow.
7
+ */
8
+ export function useSuppressTouchTooltips(rootRef: RefObject<HTMLElement | null>): void {
9
+ useEffect(() => {
10
+ const root = rootRef.current
11
+ if (!root) return undefined
12
+ let isTouching = false
13
+
14
+ function handlePointerDown(event: PointerEvent): void {
15
+ isTouching = event.pointerType === 'touch'
16
+ }
17
+ function handlePointerOver(event: PointerEvent): void {
18
+ if (event.pointerType === 'touch') event.stopPropagation()
19
+ }
20
+ function handleFocusIn(event: Event): void {
21
+ if (isTouching) event.stopPropagation()
22
+ }
23
+ function handleKeyDown(): void {
24
+ isTouching = false
25
+ }
26
+
27
+ root.addEventListener('pointerdown', handlePointerDown, true)
28
+ root.addEventListener('pointerover', handlePointerOver)
29
+ root.addEventListener('focusin', handleFocusIn)
30
+ root.addEventListener('keydown', handleKeyDown, true)
31
+ return () => {
32
+ root.removeEventListener('pointerdown', handlePointerDown, true)
33
+ root.removeEventListener('pointerover', handlePointerOver)
34
+ root.removeEventListener('focusin', handleFocusIn)
35
+ root.removeEventListener('keydown', handleKeyDown, true)
36
+ }
37
+ }, [rootRef])
38
+ }
package/src/styles.css CHANGED
@@ -1139,7 +1139,7 @@
1139
1139
 
1140
1140
  /* Participant view (.cv-p-*). Public theming API: override --cv-p-surface, --cv-p-surface-raised,
1141
1141
  --cv-p-text, --cv-p-text-muted, --cv-p-border, --cv-p-accent, --cv-p-accent-contrast,
1142
- --cv-p-highlight, --cv-p-danger, --cv-p-radius, --cv-p-wallpaper, --cv-p-scrollbar-thumb, --cv-p-scrollbar-thumb-hover, --cv-p-channel-app, --cv-p-channel-whatsapp on .cv-p or any
1142
+ --cv-p-highlight, --cv-p-danger, --cv-p-radius, --cv-p-wallpaper, --cv-p-scrollbar-thumb, --cv-p-scrollbar-thumb-hover, --cv-p-tick, --cv-p-tick-read, --cv-p-channel-app, --cv-p-channel-whatsapp on .cv-p or any
1143
1143
  ancestor. Channel colors must keep 3:1 against surface, surface-raised and highlight. */
1144
1144
  .cv-p {
1145
1145
  --cv-p-i-surface: var(--cv-p-surface, #ffffff);
@@ -1149,12 +1149,14 @@
1149
1149
  --cv-p-i-border: var(--cv-p-border, #d5d9df);
1150
1150
  --cv-p-i-accent: var(--cv-p-accent, #a85a1c);
1151
1151
  --cv-p-i-accent-contrast: var(--cv-p-accent-contrast, #ffffff);
1152
- --cv-p-i-highlight: var(--cv-p-highlight, #fbefe2);
1152
+ --cv-p-i-highlight: var(--cv-p-highlight, #fcf3ea);
1153
1153
  --cv-p-i-danger: var(--cv-p-danger, #c62828);
1154
1154
  --cv-p-i-radius: var(--cv-p-radius, 0);
1155
1155
  --cv-p-i-touch: 2.75rem;
1156
1156
  --cv-p-i-scrollbar-thumb: var(--cv-p-scrollbar-thumb, var(--cv-p-i-border));
1157
1157
  --cv-p-i-scrollbar-thumb-hover: var(--cv-p-scrollbar-thumb-hover, var(--cv-p-i-accent));
1158
+ --cv-p-i-tick: var(--cv-p-tick, var(--cv-p-i-text-muted));
1159
+ --cv-p-i-tick-read: var(--cv-p-tick-read, #0369a1);
1158
1160
  --cv-p-i-wallpaper-ink: var(--cv-p-i-border);
1159
1161
  --cv-p-i-wallpaper: radial-gradient(circle at 2px 2px, var(--cv-p-i-wallpaper-ink) 1px, transparent 1.5px), radial-gradient(circle at 14px 14px, var(--cv-p-i-wallpaper-ink) 1px, transparent 1.5px);
1160
1162
  display: flex;
@@ -1177,6 +1179,7 @@
1177
1179
  --cv-p-i-accent-contrast: var(--cv-p-accent-contrast, #10222c);
1178
1180
  --cv-p-i-highlight: var(--cv-p-highlight, rgb(213 138 71 / 10%));
1179
1181
  --cv-p-i-danger: var(--cv-p-danger, #ff6b63);
1182
+ --cv-p-i-tick-read: var(--cv-p-tick-read, #5cc8ff);
1180
1183
  }
1181
1184
  .cv-p *, .cv-p *::before, .cv-p *::after { box-sizing: border-box; }
1182
1185
  .cv-p :focus-visible { outline: 2px solid var(--cv-p-i-accent); outline-offset: 2px; }
@@ -1185,7 +1188,7 @@
1185
1188
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
1186
1189
  }
1187
1190
 
1188
- .cv-p-button, .cv-p-chip, .cv-p-row, .cv-p-thread__older, .cv-p-bubble__retry, .cv-p-bubble__action {
1191
+ .cv-p-button, .cv-p-chip, .cv-p-row, .cv-p-thread__older, .cv-p-failed-menu__trigger, .cv-p-failed-menu__item {
1189
1192
  font: inherit; color: inherit; cursor: pointer;
1190
1193
  }
1191
1194
  .cv-p-button {
@@ -1211,6 +1214,7 @@
1211
1214
  .cv-p-filters { --cv-p-i-fade: 1rem; display: flex; gap: 0.5rem; padding: 0.625rem 1rem; overflow-x: auto; border-bottom: 1px solid var(--cv-p-i-border); }
1212
1215
  .cv-p-error { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 1.5rem 1rem; text-align: center; color: var(--cv-p-i-danger, #b91c1c); }
1213
1216
  .cv-p-error__message { margin: 0; }
1217
+ .cv-p-thread__scroll > .cv-p-error { background: var(--cv-p-i-surface); }
1214
1218
  .cv-p-loading { display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; }
1215
1219
  .cv-p-skeleton { display: block; height: 3rem; border-radius: var(--cv-p-i-radius); background: var(--cv-p-i-surface-raised); }
1216
1220
  .cv-p-inbox__more { display: block; margin: 0.75rem auto; padding: 0 1rem; }
@@ -1258,6 +1262,10 @@
1258
1262
  .cv-p-thread__eyebrow { margin: 0; font: 0.64rem 'SFMono-Regular', Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; color: var(--cv-p-i-text-muted); }
1259
1263
  .cv-p-thread__title { margin: 0; font-size: 1rem; font-weight: 600; line-height: 1.3; min-width: 0; overflow-wrap: anywhere; }
1260
1264
  .cv-p-thread__title-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; line-clamp: 1; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
1265
+ .cv-p-thread__counterpart { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.78rem; line-height: 1.3; color: var(--cv-p-i-text-muted); }
1266
+ .cv-p-thread__actions { flex: 0 0 auto; display: flex; align-items: center; gap: 0.5rem; margin-inline-start: auto; max-width: 100%; }
1267
+ .cv-p-thread__header:has(.cv-p-thread__actions) { flex-wrap: wrap; }
1268
+ .cv-p-thread__header:has(.cv-p-thread__actions) .cv-p-thread__heading { min-width: 6rem; }
1261
1269
  .cv-p-thread__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.125rem 0.5rem; min-width: 0; padding-top: 0.5rem; }
1262
1270
  .cv-p-thread__title-button { display: flex; align-items: center; max-width: 100%; min-height: var(--cv-p-i-touch); padding: 0; background: none; border: 0; color: inherit; font: inherit; font-weight: 600; text-align: start; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 0.2em; cursor: pointer; }
1263
1271
  .cv-p-thread__subject-card { margin: 0.75rem 1rem 0; padding: 0.625rem 0.75rem; border: 1px solid var(--cv-p-i-border); background: var(--cv-p-i-surface-raised); border-radius: var(--cv-p-i-radius); font-size: 0.82rem; }
@@ -1265,7 +1273,7 @@
1265
1273
  .cv-p-thread__scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-anchor: none; touch-action: pan-y pinch-zoom; padding: 0.5rem 0.75rem 1rem; background-image: var(--cv-p-wallpaper, var(--cv-p-i-wallpaper)); background-size: var(--cv-p-wallpaper-size, 24px 24px); }
1266
1274
  .cv-p-thread__older { display: block; margin: 0 auto 0.5rem; min-height: var(--cv-p-i-touch); padding: 0 1rem; background: transparent; border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); }
1267
1275
  .cv-p-thread__messages { display: flex; flex-direction: column; gap: 0.5rem; }
1268
- .cv-p-thread__live { padding: 0 1rem; font-size: 0.75rem; color: var(--cv-p-i-accent); }
1276
+ .cv-p-thread__live { padding: 0 1rem; font-size: 0.75rem; color: var(--cv-p-i-accent); background: var(--cv-p-i-surface-raised); }
1269
1277
  .cv-p-thread__live:empty { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
1270
1278
  .cv-p-thread__closed { margin: 0; padding: 1rem; text-align: center; color: var(--cv-p-i-text-muted); border-top: 1px solid var(--cv-p-i-border); }
1271
1279
  .cv-p-day { align-self: center; margin: 0.5rem 0; padding: 0.25rem 0.75rem; background: var(--cv-p-i-surface-raised); border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); font: 0.65rem 'SFMono-Regular', Consolas, monospace; letter-spacing: 0.06em; text-transform: uppercase; color: var(--cv-p-i-text-muted); }
@@ -1285,14 +1293,6 @@
1285
1293
  .cv-p-bubble-row > .cv-p-bubble:not(.cv-p-bubble--no-tail) { margin-left: 0.25rem; }
1286
1294
  .cv-p-bubble--no-tail { border-radius: var(--cv-p-i-radius); }
1287
1295
  .cv-p-bubble--no-tail::before, .cv-p-bubble--no-tail::after { content: none; }
1288
- @media (forced-colors: active) {
1289
- .cv-p-bubble::before, .cv-p-bubble::after { content: none; }
1290
- .cv-p-bubble, .cv-p-bubble--mine { border-radius: var(--cv-p-i-radius); }
1291
- .cv-p-thread__scroll { background-image: none; }
1292
- .cv-p-thread__scroll, .cv-p-inbox, .cv-p-composer__input { scrollbar-width: auto; scrollbar-color: auto; }
1293
- .cv-p-filters, .cv-p-quick { mask-image: none; -webkit-mask-image: none; }
1294
- .cv-p-thread__latest-badge { border: 1px solid ButtonText; }
1295
- }
1296
1296
  @media print {
1297
1297
  .cv-p-bubble::before, .cv-p-bubble::after { content: none; }
1298
1298
  .cv-p-bubble, .cv-p-bubble--mine { border-radius: var(--cv-p-i-radius); }
@@ -1300,12 +1300,27 @@
1300
1300
  }
1301
1301
  .cv-p-bubble__author { display: block; flex: 1 0 100%; margin-bottom: 0.15rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; text-transform: uppercase; color: var(--cv-p-i-accent); }
1302
1302
  .cv-p-bubble > .cv-p-text { flex: 0 1 auto; min-width: 0; margin-inline-end: auto; }
1303
- .cv-p-bubble > .cv-p-attachment, .cv-p-bubble > .cv-p-bubble__actions { flex: 1 0 100%; }
1303
+ .cv-p-bubble > .cv-p-attachment { flex: 1 0 100%; }
1304
1304
  .cv-p-bubble__meta { display: flex; flex: 0 0 auto; justify-content: flex-end; align-items: center; gap: 0.35rem; margin-inline-start: auto; margin-top: 0.25rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; color: var(--cv-p-i-text-muted); }
1305
1305
  .cv-p-bubble__status { display: inline-flex; align-items: center; gap: 0.25rem; }
1306
- .cv-p-bubble__retry { min-height: var(--cv-p-i-touch); display: inline-flex; align-items: center; gap: 0.25rem; padding: 0 0.5rem; background: transparent; border: 1px solid var(--cv-p-i-danger); color: var(--cv-p-i-danger); border-radius: var(--cv-p-i-radius); font-size: 0.7rem; }
1307
- .cv-p-bubble__actions { display: inline-flex; gap: 0.5rem; margin-top: 0.25rem; }
1308
- .cv-p-bubble__action { min-height: var(--cv-p-i-touch); display: inline-flex; align-items: center; padding: 0 0.75rem; background: transparent; border: 1px solid var(--cv-p-i-border); color: inherit; border-radius: var(--cv-p-i-radius); font-size: 0.7rem; }
1306
+ .cv-p-bubble .cv-status-ticks--queued, .cv-p-bubble .cv-status-ticks--sent, .cv-p-bubble .cv-status-ticks--delivered { color: var(--cv-p-i-tick); }
1307
+ .cv-p-bubble .cv-status-ticks--read { color: var(--cv-p-i-tick-read); }
1308
+ .cv-p-bubble .cv-status-ticks--failed { color: var(--cv-p-i-danger); }
1309
+ @supports (color: color-mix(in srgb, red, blue)) {
1310
+ .cv-p-bubble--failed { border-color: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-border)); --cv-p-tail-edge: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-border)); }
1311
+ .cv-p-bubble--mine.cv-p-bubble--failed { border-color: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-accent)); --cv-p-tail-edge: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-accent)); }
1312
+ }
1313
+ .cv-p-failed-retry { position: absolute; top: 50%; right: calc(100% + 0.125rem); transform: translateY(-50%); display: grid; place-items: center; width: var(--cv-p-i-touch); height: var(--cv-p-i-touch); padding: 0; background: transparent; border: 0; color: var(--cv-p-i-danger); font: inherit; cursor: pointer; }
1314
+ .cv-p-failed-retry__icon { display: grid; place-items: center; width: 2rem; height: 2rem; border: 1px solid var(--cv-p-i-danger); border-radius: 50%; }
1315
+ .cv-p-failed-retry:hover .cv-p-failed-retry__icon, .cv-p-failed-retry:focus-visible .cv-p-failed-retry__icon { background: var(--cv-p-i-surface-raised); }
1316
+ .cv-p-failed-menu { display: inline-flex; }
1317
+ .cv-p-failed-menu__trigger { position: relative; display: inline-grid; place-items: center; min-width: 1.25rem; height: 0.75rem; padding: 0; background: transparent; border: 0; color: var(--cv-p-i-text-muted); font: inherit; line-height: 0.75rem; cursor: pointer; }
1318
+ .cv-p-failed-menu__trigger::after { content: ''; position: absolute; inset: -0.5rem -0.4rem; }
1319
+ .cv-p-failed-menu__list { position: absolute; right: 0; bottom: calc(100% + 0.25rem); z-index: 2; display: flex; flex-direction: column; min-width: 8rem; background: var(--cv-p-i-surface-raised); border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); }
1320
+ .cv-p-failed-menu__item { min-height: var(--cv-p-i-touch); padding: 0 0.75rem; background: transparent; border: 0; color: var(--cv-p-i-text); font: 0.7rem 'SFMono-Regular', Consolas, monospace; text-align: start; text-transform: uppercase; letter-spacing: 0.04em; cursor: pointer; }
1321
+ .cv-p-failed-menu__list--below { bottom: auto; top: calc(100% + 0.25rem); }
1322
+ .cv-p-failed-menu__item--danger { color: var(--cv-p-i-danger); }
1323
+ .cv-p-failed-menu__item:hover, .cv-p-failed-menu__item:focus-visible { background: var(--cv-p-i-highlight); }
1309
1324
  .cv-p-attachment { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.35rem; color: inherit; }
1310
1325
  .cv-p-attachment--document { flex-direction: row; justify-content: space-between; min-height: var(--cv-p-i-touch); align-items: center; text-decoration: underline; }
1311
1326
  .cv-p-attachment__image { display: block; max-width: 100%; max-height: 16rem; object-fit: contain; }
@@ -1405,7 +1420,18 @@
1405
1420
  .cv-p-text__link-copy::after { inset-inline: 0 auto; min-width: var(--cv-p-i-touch); }
1406
1421
  .cv-p-copyable { padding: 0 0.15rem; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.92em; background: color-mix(in srgb, var(--cv-p-i-accent) 14%, transparent); border-bottom: 1px dotted var(--cv-p-i-accent); border-radius: var(--cv-p-i-radius); }
1407
1422
  .cv-p-copyable--email { font-family: inherit; }
1408
- .cv-p-copyable__done { margin-inline-start: 0.35rem; font-size: 0.85em; color: var(--cv-p-i-accent); }
1423
+ .cv-p-copyable__done { margin-inline-start: 0.35rem; font-size: 0.85em; color: var(--cv-p-i-text); }
1424
+ @media (forced-colors: active) {
1425
+ .cv-p-bubble::before, .cv-p-bubble::after { content: none; }
1426
+ .cv-p-bubble, .cv-p-bubble--mine { border-radius: var(--cv-p-i-radius); }
1427
+ .cv-p-thread__scroll { background-image: none; }
1428
+ .cv-p-thread__scroll, .cv-p-inbox, .cv-p-composer__input { scrollbar-width: auto; scrollbar-color: auto; }
1429
+ .cv-p-filters, .cv-p-quick { mask-image: none; -webkit-mask-image: none; }
1430
+ .cv-p-thread__latest-badge { border: 1px solid ButtonText; }
1431
+ .cv-p-bubble .cv-status-ticks, .cv-p-copyable__done { color: CanvasText; }
1432
+ .cv-p-failed-menu__list { border-color: CanvasText; }
1433
+ .cv-p-failed-menu__item:focus-visible, .cv-p-failed-menu__item:hover { outline: 2px solid Highlight; }
1434
+ }
1409
1435
  @media (forced-colors: active) {
1410
1436
  .cv-p-text__code, .cv-p-text__pre, .cv-p-copyable { border: 1px solid; background: none; }
1411
1437
  .cv-p-text__link, .cv-p-text__link-copy { color: LinkText; }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { createTooltipLayerRegistry } from './tooltipLayerRegistry'
4
+
5
+ function track() {
6
+ const events: boolean[] = []
7
+ return { events, notify: (isOwner: boolean) => void events.push(isOwner) }
8
+ }
9
+
10
+ describe('createTooltipLayerRegistry', () => {
11
+ it('makes the first mounted layer the owner and keeps the others quiet', () => {
12
+ const registry = createTooltipLayerRegistry()
13
+ const first = track()
14
+ const second = track()
15
+ registry.register({}, first.notify)
16
+ registry.register({}, second.notify)
17
+
18
+ expect(first.events).toEqual([true])
19
+ expect(second.events).toEqual([])
20
+ })
21
+
22
+ it('hands the ownership to the next mounted layer when the owner unmounts', () => {
23
+ const registry = createTooltipLayerRegistry()
24
+ const first = track()
25
+ const second = track()
26
+ const third = track()
27
+ const unmountFirst = registry.register({}, first.notify)
28
+ registry.register({}, second.notify)
29
+ registry.register({}, third.notify)
30
+
31
+ unmountFirst()
32
+
33
+ expect(second.events).toEqual([true])
34
+ expect(third.events).toEqual([])
35
+ })
36
+
37
+ it('does not disturb the owner when a waiting layer unmounts', () => {
38
+ const registry = createTooltipLayerRegistry()
39
+ const first = track()
40
+ const second = track()
41
+ registry.register({}, first.notify)
42
+ registry.register({}, second.notify)()
43
+
44
+ expect(first.events).toEqual([true])
45
+ expect(second.events).toEqual([])
46
+ })
47
+
48
+ it('lets a layer mounted after everyone left become the owner again', () => {
49
+ const registry = createTooltipLayerRegistry()
50
+ registry.register({}, () => undefined)()
51
+ const late = track()
52
+ registry.register({}, late.notify)
53
+
54
+ expect(late.events).toEqual([true])
55
+ })
56
+ })
@@ -0,0 +1,34 @@
1
+ export type TooltipLayerRegistry = {
2
+ /** Mounts a layer; the callback hears when it becomes (true) or stops being (false) the one that draws. */
3
+ readonly register: (layer: object, onOwnershipChange: (isOwner: boolean) => void) => () => void
4
+ }
5
+
6
+ /** Layers in mount order: one draws at a time, and when it unmounts the next mounted one takes over. */
7
+ export function createTooltipLayerRegistry(): TooltipLayerRegistry {
8
+ const layers = new Map<object, (isOwner: boolean) => void>()
9
+ let owner: object | undefined
10
+
11
+ function handOff(): void {
12
+ const next = layers.entries().next()
13
+ if (next.done) {
14
+ owner = undefined
15
+ return
16
+ }
17
+ const [layer, onOwnershipChange] = next.value
18
+ owner = layer
19
+ onOwnershipChange(true)
20
+ }
21
+
22
+ return {
23
+ register(layer, onOwnershipChange) {
24
+ layers.set(layer, onOwnershipChange)
25
+ if (owner === undefined) handOff()
26
+ return () => {
27
+ layers.delete(layer)
28
+ if (owner !== layer) return
29
+ owner = undefined
30
+ handOff()
31
+ }
32
+ },
33
+ }
34
+ }