@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.
- package/README.md +106 -0
- package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
- package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.d.ts +10 -3
- package/dist/index.js +8 -6
- package/dist/participant/index.d.ts +54 -3
- package/dist/participant/index.js +1722 -418
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +281 -23
- package/package.json +3 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/audioRecorderFormat.ts +27 -0
- package/src/domHarness.test-helper.ts +53 -0
- package/src/participant/ConversationThread.tsx +4 -0
- package/src/participant/ParticipantAttachmentItem.tsx +7 -10
- package/src/participant/ParticipantAudioAttachment.tsx +34 -0
- package/src/participant/ParticipantAudioPlayer.tsx +68 -0
- package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
- package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
- package/src/participant/ParticipantAudioTranscription.tsx +90 -0
- package/src/participant/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +51 -16
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +20 -52
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +30 -12
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/failedMenuKeyboard.ts +29 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantAudio.types.ts +10 -0
- package/src/participant/participantAudioCapture.test.ts +15 -0
- package/src/participant/participantAudioCapture.ts +85 -0
- package/src/participant/participantAudioCoordinator.ts +13 -0
- package/src/participant/participantAudioFormat.test.ts +93 -0
- package/src/participant/participantAudioFormat.ts +72 -0
- package/src/participant/participantAudioGolden.test.tsx +168 -0
- package/src/participant/participantAudioLabels.test.ts +22 -0
- package/src/participant/participantAudioLabels.ts +76 -0
- package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
- package/src/participant/participantAudioPlayerCss.test.ts +63 -0
- package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
- package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
- package/src/participant/participantAudioPlayerState.ts +35 -0
- package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
- package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
- package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
- package/src/participant/participantAudioRecorderState.test.ts +144 -0
- package/src/participant/participantAudioRecorderState.ts +93 -0
- package/src/participant/participantAudioRecorderView.test.tsx +98 -0
- package/src/participant/participantAudioSupport.ts +36 -0
- package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
- package/src/participant/participantComposerAudioDom.test.tsx +185 -0
- package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
- package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
- package/src/participant/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -0
- package/src/participant/participantFileValidation.ts +1 -1
- package/src/participant/participantGolden.test.tsx +2 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
- package/src/participant/participantLabels.ts +10 -1
- package/src/participant/participantMediaFakes.test-helper.ts +82 -0
- package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
- package/src/participant/participantPublicTypes.test.ts +11 -0
- package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
- package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
- package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
- package/src/participant/participantThemeContrast.test.ts +17 -0
- package/src/participant/participantTooltips.test.tsx +74 -0
- package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
- package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -1
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -1
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
- package/src/participant/useFailedMenuEffects.ts +59 -0
- package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
- package/src/participant/useParticipantAudioPlayer.ts +52 -0
- package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
- package/src/participant/useParticipantAudioRecorder.ts +153 -0
- package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
- package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
- package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
- package/src/participant/useParticipantFailedMenu.ts +60 -0
- package/src/participant/useParticipantSendAudio.test.tsx +85 -0
- package/src/participant/useParticipantSendAudio.ts +28 -0
- package/src/participant/useParticipantThreadController.ts +20 -7
- package/src/participant/useStableAttachmentUrl.ts +71 -0
- package/src/participant/useSuppressTouchTooltips.ts +38 -0
- package/src/participant/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- package/src/styles.css +48 -5
- package/src/tooltipLayerRegistry.test.ts +56 -0
- 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-
|
|
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
|
|
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
|
-
|
|
1310
|
-
.cv-p-
|
|
1311
|
-
.cv-p-
|
|
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
|
+
}
|