@adatechnology/conversations-ui 0.8.0 → 0.10.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 (129) hide show
  1. package/README.md +106 -0
  2. package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
  3. package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
  4. package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
  5. package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
  6. package/dist/chunk-ZSOD5AQT.js +19 -0
  7. package/dist/flows/index.js +4 -2
  8. package/dist/index.d.ts +10 -3
  9. package/dist/index.js +8 -6
  10. package/dist/participant/index.d.ts +54 -3
  11. package/dist/participant/index.js +1722 -418
  12. package/dist/preview/index.d.ts +2 -2
  13. package/dist/preview/index.js +2 -2
  14. package/dist/styles.css +281 -23
  15. package/package.json +3 -2
  16. package/src/AudioRecorderButton.tsx +4 -28
  17. package/src/Tooltip.tsx +5 -12
  18. package/src/TooltipDom.test.tsx +78 -0
  19. package/src/audioRecorderFormat.ts +27 -0
  20. package/src/domHarness.test-helper.ts +53 -0
  21. package/src/participant/ConversationThread.tsx +4 -0
  22. package/src/participant/ParticipantAttachmentItem.tsx +7 -10
  23. package/src/participant/ParticipantAudioAttachment.tsx +34 -0
  24. package/src/participant/ParticipantAudioPlayer.tsx +68 -0
  25. package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
  26. package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
  27. package/src/participant/ParticipantAudioTranscription.tsx +90 -0
  28. package/src/participant/ParticipantBackButton.tsx +1 -1
  29. package/src/participant/ParticipantBubbleBody.tsx +54 -0
  30. package/src/participant/ParticipantBubbleStatus.tsx +22 -28
  31. package/src/participant/ParticipantChannelBadges.tsx +10 -2
  32. package/src/participant/ParticipantComposer.tsx +51 -16
  33. package/src/participant/ParticipantComposerAudio.tsx +95 -0
  34. package/src/participant/ParticipantComposerControls.tsx +2 -0
  35. package/src/participant/ParticipantConversations.tsx +3 -0
  36. package/src/participant/ParticipantCopyableToken.tsx +1 -0
  37. package/src/participant/ParticipantFailedMenu.tsx +40 -0
  38. package/src/participant/ParticipantFailedMenuList.tsx +41 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
  40. package/src/participant/ParticipantMessageBubble.tsx +20 -52
  41. package/src/participant/ParticipantMessageText.test.tsx +1 -1
  42. package/src/participant/ParticipantMessageText.tsx +4 -2
  43. package/src/participant/ParticipantProtocolBadge.tsx +1 -0
  44. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  45. package/src/participant/ParticipantScrollToLatest.tsx +1 -1
  46. package/src/participant/ParticipantThread.tsx +30 -12
  47. package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
  48. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  49. package/src/participant/failedMenuKeyboard.ts +29 -0
  50. package/src/participant/index.ts +3 -0
  51. package/src/participant/participantAudio.types.ts +10 -0
  52. package/src/participant/participantAudioCapture.test.ts +15 -0
  53. package/src/participant/participantAudioCapture.ts +85 -0
  54. package/src/participant/participantAudioCoordinator.ts +13 -0
  55. package/src/participant/participantAudioFormat.test.ts +93 -0
  56. package/src/participant/participantAudioFormat.ts +72 -0
  57. package/src/participant/participantAudioGolden.test.tsx +168 -0
  58. package/src/participant/participantAudioLabels.test.ts +22 -0
  59. package/src/participant/participantAudioLabels.ts +76 -0
  60. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  61. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  62. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  63. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  64. package/src/participant/participantAudioPlayerState.ts +35 -0
  65. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  66. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  67. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  68. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  69. package/src/participant/participantAudioRecorderState.ts +93 -0
  70. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  71. package/src/participant/participantAudioSupport.ts +36 -0
  72. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  73. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  74. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  75. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  76. package/src/participant/participantFailedMenuDom.test.tsx +154 -0
  77. package/src/participant/participantFailedMenuState.test.ts +61 -0
  78. package/src/participant/participantFailedMenuState.ts +57 -0
  79. package/src/participant/participantFailedMessage.test.tsx +83 -0
  80. package/src/participant/participantFileValidation.ts +1 -1
  81. package/src/participant/participantGolden.test.tsx +2 -0
  82. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  83. package/src/participant/participantLabels.ts +10 -1
  84. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  85. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  86. package/src/participant/participantPublicTypes.test.ts +11 -0
  87. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  88. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  89. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  90. package/src/participant/participantThemeContrast.test.ts +17 -0
  91. package/src/participant/participantTooltips.test.tsx +74 -0
  92. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  93. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  94. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  95. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  96. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  97. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  98. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  99. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  100. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  101. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  102. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
  103. package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
  104. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  105. package/src/participant/test-fixtures/thread-default.golden.html +1 -1
  106. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  107. package/src/participant/test-fixtures/thread-error.golden.html +1 -1
  108. package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
  109. package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
  110. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
  111. package/src/participant/useFailedMenuEffects.ts +59 -0
  112. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  113. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  114. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  115. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  116. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  117. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  118. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  119. package/src/participant/useParticipantFailedMenu.ts +60 -0
  120. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  121. package/src/participant/useParticipantSendAudio.ts +28 -0
  122. package/src/participant/useParticipantThreadController.ts +20 -7
  123. package/src/participant/useStableAttachmentUrl.ts +71 -0
  124. package/src/participant/useSuppressTouchTooltips.ts +38 -0
  125. package/src/participant/useTranscriptionOverflow.ts +25 -0
  126. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  127. package/src/styles.css +48 -5
  128. package/src/tooltipLayerRegistry.test.ts +56 -0
  129. package/src/tooltipLayerRegistry.ts +34 -0
@@ -0,0 +1,42 @@
1
+ import { useEffect, useRef, type SyntheticEvent } from 'react'
2
+
3
+ import type { PlayerContext } from './participantAudioPlayerState'
4
+
5
+ export type UseParticipantAudioSourceRetryParams = {
6
+ readonly context: PlayerContext
7
+ readonly url: string | undefined
8
+ readonly onSourceError: (() => Promise<string | undefined>) | undefined
9
+ }
10
+
11
+ /** One retry with a fresh URL when the signed one expired, resuming from the same position. */
12
+ export function useParticipantAudioSourceRetry({ context, url, onSourceError }: UseParticipantAudioSourceRetryParams) {
13
+ const { audioRef, update } = context
14
+ const canRetryRef = useRef(true)
15
+ const resumeAtRef = useRef<number | undefined>(undefined)
16
+
17
+ useEffect(() => {
18
+ const audio = audioRef.current
19
+ const resumeAt = resumeAtRef.current
20
+ if (!audio || resumeAt === undefined || !url) return
21
+ resumeAtRef.current = undefined
22
+ audio.currentTime = resumeAt
23
+ Promise.resolve(audio.play()).catch(() => update({ isPlaying: false }))
24
+ }, [url, audioRef, update])
25
+
26
+ function allowRetry(): void {
27
+ canRetryRef.current = true
28
+ }
29
+
30
+ function handleError(event: SyntheticEvent<HTMLAudioElement>): void {
31
+ if (!onSourceError || !canRetryRef.current) return update({ hasFailed: true, isPlaying: false })
32
+ canRetryRef.current = false
33
+ resumeAtRef.current = event.currentTarget.currentTime
34
+ onSourceError().then((fresh) => {
35
+ if (fresh && fresh !== url) return
36
+ resumeAtRef.current = undefined
37
+ update({ hasFailed: true, isPlaying: false })
38
+ })
39
+ }
40
+
41
+ return { handleError, allowRetry }
42
+ }
@@ -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
+ }
@@ -0,0 +1,85 @@
1
+ import { act } from 'react'
2
+
3
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
4
+
5
+ import { setupDom, type DomHarness } from '../domHarness.test-helper'
6
+ import type { ParticipantSendContent } from './participantDrafts'
7
+ import { useParticipantSendAudio } from './useParticipantSendAudio'
8
+
9
+ let harness: DomHarness
10
+ let sendAudio: (file: File) => void
11
+ let calls: ParticipantSendContent[]
12
+ let releases: Array<() => void>
13
+
14
+ function Probe() {
15
+ sendAudio = useParticipantSendAudio((content) => {
16
+ calls.push(content)
17
+ return new Promise<void>((resolve) => releases.push(resolve))
18
+ })
19
+ return null
20
+ }
21
+
22
+ beforeAll(async () => {
23
+ harness = await setupDom()
24
+ })
25
+
26
+ afterAll(() => harness.teardown())
27
+
28
+ function mountProbe(): void {
29
+ calls = []
30
+ releases = []
31
+ harness.mount(<Probe />)
32
+ }
33
+
34
+ const audio = (name: string) => new File(['x'], name, { type: 'audio/ogg' })
35
+
36
+ describe('useParticipantSendAudio', () => {
37
+ it('sends the file alone, as a message of its own', () => {
38
+ mountProbe()
39
+ const file = audio('a.ogg')
40
+ sendAudio(file)
41
+ expect(calls).toEqual([{ files: [file] }])
42
+ })
43
+
44
+ it('ignores a second synchronous send of the same file', () => {
45
+ mountProbe()
46
+ const file = audio('a.ogg')
47
+ sendAudio(file)
48
+ sendAudio(file)
49
+ expect(calls).toHaveLength(1)
50
+ })
51
+
52
+ it('lets another recording through while the first is still uploading, and the same one again once it settled', async () => {
53
+ mountProbe()
54
+ const first = audio('a.ogg')
55
+ sendAudio(first)
56
+ sendAudio(audio('b.ogg'))
57
+ expect(calls).toHaveLength(2)
58
+ await act(async () => releases[0]?.())
59
+ sendAudio(first)
60
+ expect(calls).toHaveLength(3)
61
+ })
62
+
63
+ it('a send that throws (for example randomUUID in an insecure context) is contained and frees the file for a retry', async () => {
64
+ const unhandled: unknown[] = []
65
+ const onUnhandled = (reason: unknown): void => void unhandled.push(reason)
66
+ process.on('unhandledRejection', onUnhandled)
67
+ let attempts = 0
68
+ function Throwing() {
69
+ sendAudio = useParticipantSendAudio(async () => {
70
+ attempts += 1
71
+ throw new Error('crypto.randomUUID is not a function')
72
+ })
73
+ return null
74
+ }
75
+ harness.mount(<Throwing />)
76
+ const file = audio('a.ogg')
77
+ sendAudio(file)
78
+ await new Promise((resolve) => setTimeout(resolve, 10))
79
+ sendAudio(file)
80
+ await new Promise((resolve) => setTimeout(resolve, 10))
81
+ process.off('unhandledRejection', onUnhandled)
82
+ expect(unhandled).toEqual([])
83
+ expect(attempts).toBe(2)
84
+ })
85
+ })
@@ -0,0 +1,28 @@
1
+ import { useCallback, useRef } from 'react'
2
+
3
+ import type { ParticipantSendContent } from './participantDrafts'
4
+
5
+ /**
6
+ * Sends a recorded audio as its own message through the attachment path. The file itself is the
7
+ * lock: send generates a new client id per call, so the server idempotency would not stop a double tap.
8
+ */
9
+ export function useParticipantSendAudio(send: (content: ParticipantSendContent) => Promise<unknown>): (file: File) => void {
10
+ const sendRef = useRef(send)
11
+ sendRef.current = send
12
+ const inFlightRef = useRef(new Set<File>())
13
+
14
+ return useCallback((file: File): void => {
15
+ const inFlight = inFlightRef.current
16
+ if (inFlight.has(file)) return
17
+ inFlight.add(file)
18
+ void (async () => {
19
+ try {
20
+ await sendRef.current({ files: [file] })
21
+ } catch {
22
+ // the send path reports its own failures; a throw before it (insecure context) must not become an unhandled rejection
23
+ } finally {
24
+ inFlight.delete(file)
25
+ }
26
+ })()
27
+ }, [])
28
+ }
@@ -13,6 +13,7 @@ import { findFailedEntry, hasSendingEntry, type ParticipantSendAction } from './
13
13
  import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
14
14
  import { useParticipantConversationView } from './useParticipantConversationView'
15
15
  import { useParticipantDraft } from './useParticipantDraft'
16
+ import { useParticipantSendAudio } from './useParticipantSendAudio'
16
17
  import { useParticipantThread, type UseParticipantThreadResult } from './useParticipantThread'
17
18
  import { useParticipantThreadScroll } from './useParticipantThreadScroll'
18
19
 
@@ -53,6 +54,7 @@ export type ParticipantThreadCoreProps = Pick<
53
54
  | 'onLoadOlder'
54
55
  | 'pendingActions'
55
56
  | 'perspective'
57
+ | 'onSendAudio'
56
58
  >
57
59
 
58
60
  type BuildPendingActionsParams = {
@@ -71,25 +73,35 @@ function buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending
71
73
  }
72
74
  }
73
75
 
76
+ type BuildEditHandlerParams = {
77
+ readonly sendEntries: Parameters<typeof findFailedEntry>[0]
78
+ readonly dispatchSend: (action: ParticipantSendAction) => void
79
+ readonly restoreFromEntry: (entry: NonNullable<ReturnType<typeof findFailedEntry>>) => void
80
+ }
81
+
82
+ function buildEditHandler({ sendEntries, dispatchSend, restoreFromEntry }: BuildEditHandlerParams): (clientMessageId: string) => void {
83
+ return (clientMessageId) => {
84
+ const entry = findFailedEntry(sendEntries, clientMessageId)
85
+ if (!entry) return
86
+ restoreFromEntry(entry)
87
+ dispatchSend({ type: 'discarded', clientMessageId })
88
+ }
89
+ }
90
+
74
91
  export function useParticipantThreadController(params: ParticipantThreadControllerParams): ParticipantThreadCoreProps {
75
92
  const { api, subject, conversation, draftsRef } = params
76
93
  const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, subject)
77
94
  const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView(params)
78
95
  const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
79
96
  const { scroll, newMessagesCount } = useParticipantThreadScroll(items, params.perspective)
97
+ const handleSendAudio = useParticipantSendAudio(thread.send)
98
+ const handleEdit = buildEditHandler({ sendEntries, dispatchSend, restoreFromEntry })
80
99
 
81
100
  function handleSend(): void {
82
101
  const content = takeForSend()
83
102
  if (content) void thread.send(content)
84
103
  }
85
104
 
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
105
  return {
94
106
  conversation,
95
107
  items,
@@ -108,5 +120,6 @@ export function useParticipantThreadController(params: ParticipantThreadControll
108
120
  onLoadOlder: () => void thread.loadOlder(),
109
121
  pendingActions: buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending: params.onRetryPending }),
110
122
  perspective: params.perspective,
123
+ onSendAudio: handleSendAudio,
111
124
  }
112
125
  }
@@ -0,0 +1,71 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
6
+ import { isSafeAttachmentUrl } from './participantAttachmentUrl'
7
+
8
+ export type StableAttachmentUrl = {
9
+ readonly status: 'pending' | 'ready' | 'failed'
10
+ readonly url: string | undefined
11
+ /** Asks the host for a fresh URL (the signed one expired); resolves undefined when it cannot. */
12
+ readonly resolveAgain: () => Promise<string | undefined>
13
+ }
14
+
15
+ export type StableAttachmentUrlParams = {
16
+ readonly attachment: ParticipantAttachment
17
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
18
+ }
19
+
20
+ type ResolvedState = Pick<StableAttachmentUrl, 'status' | 'url'>
21
+
22
+ export const ATTACHMENT_URL_RETRY_DELAY_MS = 1000
23
+
24
+ const PENDING_STATE: ResolvedState = { status: 'pending', url: undefined }
25
+
26
+ async function resolveSafely({ attachment, resolveAttachmentUrl }: StableAttachmentUrlParams): Promise<string | undefined> {
27
+ try {
28
+ const resolved = await resolveAttachmentUrl(attachment, 'inline')
29
+ return isSafeAttachmentUrl(resolved) ? resolved.trim() : undefined
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ /** Resolves once per `attachment.id`: a refreshed conversation hands over a new object and URL, and swapping `src` restarts the audio. */
36
+ export function useStableAttachmentUrl(params: StableAttachmentUrlParams): StableAttachmentUrl {
37
+ const latest = useRef(params)
38
+ const currentId = useRef(params.attachment.id)
39
+ const [state, setState] = useState<ResolvedState>(PENDING_STATE)
40
+ const { id } = params.attachment
41
+
42
+ useEffect(() => {
43
+ latest.current = params
44
+ })
45
+
46
+ useEffect(() => {
47
+ currentId.current = id
48
+ setState(PENDING_STATE)
49
+ let retryTimer: ReturnType<typeof setTimeout> | undefined
50
+ const settle = (url: string | undefined): void => {
51
+ if (currentId.current === id) setState(url ? { status: 'ready', url } : { status: 'failed', url: undefined })
52
+ }
53
+ resolveSafely(latest.current).then((url) => {
54
+ if (url || currentId.current !== id) return settle(url)
55
+ retryTimer = setTimeout(() => void resolveSafely(latest.current).then(settle), ATTACHMENT_URL_RETRY_DELAY_MS)
56
+ })
57
+ return () => {
58
+ currentId.current = ''
59
+ clearTimeout(retryTimer)
60
+ }
61
+ }, [id])
62
+
63
+ const resolveAgain = useCallback(async (): Promise<string | undefined> => {
64
+ const requestedId = currentId.current
65
+ const url = await resolveSafely(latest.current)
66
+ if (url && currentId.current === requestedId) setState({ status: 'ready', url })
67
+ return url
68
+ }, [])
69
+
70
+ return { ...state, resolveAgain }
71
+ }
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ import { useEffect, useRef, useState, type RefObject } from 'react'
2
+
3
+ export type TranscriptionOverflow = {
4
+ readonly textRef: RefObject<HTMLParagraphElement | null>
5
+ readonly isOverflowing: boolean
6
+ }
7
+
8
+ /** Measures the clamped paragraph; nothing is claimed before layout exists (server render, first paint). */
9
+ export function useTranscriptionOverflow(text: string, isExpanded: boolean): TranscriptionOverflow {
10
+ const textRef = useRef<HTMLParagraphElement | null>(null)
11
+ const [isOverflowing, setIsOverflowing] = useState(false)
12
+
13
+ useEffect(() => {
14
+ const element = textRef.current
15
+ if (!element || isExpanded) return undefined
16
+ const measure = (): void => setIsOverflowing(element.scrollHeight > element.clientHeight)
17
+ measure()
18
+ if (typeof ResizeObserver === 'undefined') return undefined
19
+ const observer = new ResizeObserver(measure)
20
+ observer.observe(element)
21
+ return () => observer.disconnect()
22
+ }, [text, isExpanded])
23
+
24
+ return { textRef, isOverflowing }
25
+ }
@@ -0,0 +1,24 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import type { ParticipantTranscription } from '@adatechnology/conversation-contracts'
4
+
5
+ import { useAnnounceCopy } from './ParticipantCopyAnnouncer'
6
+
7
+ export type TranscriptionReadyAnnouncementParams = {
8
+ readonly attachmentId: string
9
+ readonly status: ParticipantTranscription['status'] | undefined
10
+ readonly text: string
11
+ }
12
+
13
+ /** Speaks through the thread's existing live region, only for a pending to ready change seen by this mount. */
14
+ export function useTranscriptionReadyAnnouncement({ attachmentId, status, text }: TranscriptionReadyAnnouncementParams): void {
15
+ const announce = useAnnounceCopy()
16
+ const previous = useRef({ attachmentId, status })
17
+
18
+ useEffect(() => {
19
+ const before = previous.current
20
+ previous.current = { attachmentId, status }
21
+ if (before.attachmentId !== attachmentId) return
22
+ if (before.status === 'pending' && status === 'ready') announce(text)
23
+ }, [announce, attachmentId, status, text])
24
+ }
package/src/styles.css CHANGED
@@ -1188,7 +1188,7 @@
1188
1188
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
1189
1189
  }
1190
1190
 
1191
- .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 {
1192
1192
  font: inherit; color: inherit; cursor: pointer;
1193
1193
  }
1194
1194
  .cv-p-button {
@@ -1300,20 +1300,50 @@
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
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
1307
  .cv-p-bubble .cv-status-ticks--read { color: var(--cv-p-i-tick-read); }
1308
1308
  .cv-p-bubble .cv-status-ticks--failed { color: var(--cv-p-i-danger); }
1309
- .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; }
1310
- .cv-p-bubble__actions { display: inline-flex; gap: 0.5rem; margin-top: 0.25rem; }
1311
- .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; }
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); }
1312
1324
  .cv-p-attachment { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.35rem; color: inherit; }
1313
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; }
1314
1326
  .cv-p-attachment__image { display: block; max-width: 100%; max-height: 16rem; object-fit: contain; }
1315
1327
  .cv-p-attachment__audio { width: 100%; min-height: var(--cv-p-i-touch); }
1328
+ .cv-p-audio { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.5rem; width: 100%; min-width: min(15rem, 100%); box-sizing: border-box; }
1329
+ .cv-p-audio__toggle, .cv-p-audio__rate { flex: 0 0 auto; display: inline-grid; place-items: center; min-width: var(--cv-p-i-touch); min-height: var(--cv-p-i-touch); padding: 0; background: transparent; border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); color: inherit; font: inherit; font-size: 0.75rem; cursor: pointer; }
1330
+ .cv-p-audio__toggle:disabled { opacity: 0.45; cursor: not-allowed; }
1331
+ .cv-p-audio__toggle svg { display: block; }
1332
+ .cv-p-audio__seek { flex: 1 1 5rem; min-width: 5rem; min-height: var(--cv-p-i-touch); margin: 0; accent-color: var(--cv-p-i-accent); cursor: pointer; }
1333
+ .cv-p-audio__time { flex: 0 0 auto; font-size: 0.75rem; font-variant-numeric: tabular-nums; color: var(--cv-p-i-text-muted); }
1334
+ .cv-p-audio__media { display: none; }
1335
+ .cv-p-audio__error { flex: 1 0 100%; font-size: 0.75rem; }
1336
+ .cv-p-recorder .cv-p-audio { flex: 1 1 8rem; width: auto; min-width: 0; }
1316
1337
  .cv-p-attachment__name, .cv-p-attachment__size { font-size: 0.75rem; overflow-wrap: anywhere; }
1338
+ .cv-p-transcription { display: flex; flex-direction: column; gap: 0.25rem; margin: 0; font-size: 0.8rem; line-height: 1.4; color: var(--cv-p-i-text); }
1339
+ .cv-p-transcription--pending, .cv-p-transcription--too-long { font-size: 0.75rem; color: var(--cv-p-i-text-muted); }
1340
+ .cv-p-transcription__text, .cv-p-transcription__empty, .cv-p-transcription__notice { margin: 0; overflow-wrap: anywhere; }
1341
+ .cv-p-transcription__text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 6; line-clamp: 6; overflow: hidden; -webkit-user-select: text; user-select: text; }
1342
+ .cv-p-transcription__text[data-expanded='true'] { display: block; overflow: visible; }
1343
+ .cv-p-transcription__empty { color: var(--cv-p-i-text-muted); }
1344
+ .cv-p-transcription__notice { order: 1; font-size: 0.7rem; color: var(--cv-p-i-text-muted); }
1345
+ .cv-p-transcription__toggle { order: 2; align-self: flex-start; min-width: var(--cv-p-i-touch); min-height: var(--cv-p-i-touch); padding: 0 0.5rem; background: transparent; border: 0; border-radius: var(--cv-p-i-radius); color: var(--cv-p-i-accent); font: inherit; font-size: 0.75rem; text-decoration: underline; cursor: pointer; }
1346
+ .cv-p-transcription__toggle:focus-visible { outline: 2px solid var(--cv-p-i-accent); outline-offset: 2px; }
1317
1347
 
1318
1348
  .cv-p-composer { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.5rem 0.75rem 0.75rem; background: var(--cv-p-i-surface-raised); border-top: 1px solid var(--cv-p-i-border); }
1319
1349
  .cv-p-composer__row { display: flex; align-items: flex-end; gap: 0.25rem; }
@@ -1323,6 +1353,16 @@
1323
1353
  .cv-p-composer__input { flex: 1 1 auto; min-width: 0; min-height: var(--cv-p-i-touch); max-height: 6.5rem; overflow-y: auto; resize: none; padding: 0.7rem 0.75rem; font: inherit; font-size: 0.9rem; color: inherit; background: var(--cv-p-i-surface); border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); }
1324
1354
  .cv-p-composer__send { flex: 0 0 auto; padding: 0; margin-inline-start: 0.25rem; }
1325
1355
  .cv-p-composer__error { margin: 0; font-size: 0.8rem; color: var(--cv-p-i-danger); }
1356
+ .cv-p-composer__record { flex: 0 0 auto; padding: 0; margin-inline-start: 0.25rem; }
1357
+ .cv-p-composer__record svg, .cv-p-recorder svg { display: block; }
1358
+ .cv-p-recorder { display: flex; flex: 1 1 auto; flex-wrap: wrap; align-items: center; gap: 0.5rem; min-width: 0; }
1359
+ .cv-p-recorder__hint { flex: 1 1 100%; margin: 0; font-size: 0.8rem; color: var(--cv-p-i-text-muted); }
1360
+ .cv-p-recorder__discard { flex: 0 0 auto; padding: 0; color: var(--cv-p-i-text-muted); }
1361
+ .cv-p-recorder__dot { flex: 0 0 auto; width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--cv-p-i-danger); animation: cv-p-recorder-pulse 1.2s ease-in-out infinite; }
1362
+ .cv-p-recorder__elapsed { flex: 1 1 auto; font: 0.85rem 'SFMono-Regular', Consolas, monospace; font-variant-numeric: tabular-nums; color: var(--cv-p-i-text); }
1363
+ .cv-p-recorder__stop, .cv-p-recorder__send { flex: 0 0 auto; padding: 0; }
1364
+ @keyframes cv-p-recorder-pulse { 50% { opacity: 0.3; } }
1365
+ @media (prefers-reduced-motion: reduce) { .cv-p-recorder__dot { animation: none; } }
1326
1366
  .cv-p-quick { --cv-p-i-fade: 0.75rem; display: flex; gap: 0.5rem; overflow-x: auto; margin: -0.25rem calc(-1 * var(--cv-p-i-fade)); padding-block: 0.25rem; }
1327
1367
  .cv-p-files { display: flex; flex-direction: column; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
1328
1368
  .cv-p-files__item { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
@@ -1416,7 +1456,10 @@
1416
1456
  .cv-p-thread__scroll, .cv-p-inbox, .cv-p-composer__input { scrollbar-width: auto; scrollbar-color: auto; }
1417
1457
  .cv-p-filters, .cv-p-quick { mask-image: none; -webkit-mask-image: none; }
1418
1458
  .cv-p-thread__latest-badge { border: 1px solid ButtonText; }
1459
+ .cv-p-transcription__toggle { color: LinkText; border: 1px solid ButtonText; }
1419
1460
  .cv-p-bubble .cv-status-ticks, .cv-p-copyable__done { color: CanvasText; }
1461
+ .cv-p-failed-menu__list { border-color: CanvasText; }
1462
+ .cv-p-failed-menu__item:focus-visible, .cv-p-failed-menu__item:hover { outline: 2px solid Highlight; }
1420
1463
  }
1421
1464
  @media (forced-colors: active) {
1422
1465
  .cv-p-text__code, .cv-p-text__pre, .cv-p-copyable { border: 1px solid; background: none; }
@@ -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
+ }