@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,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CLOSED_FAILED_MENU,
|
|
5
|
+
failedMenuReducer,
|
|
6
|
+
resolveFailedMenuKey,
|
|
7
|
+
resolveFailedMenuPlacement,
|
|
8
|
+
resolveFailedMenuTriggerKey,
|
|
9
|
+
} from './participantFailedMenuState'
|
|
10
|
+
|
|
11
|
+
describe('failed menu state', () => {
|
|
12
|
+
it('toggles open on first focus item and closes again', () => {
|
|
13
|
+
const open = failedMenuReducer(CLOSED_FAILED_MENU, { type: 'toggle' })
|
|
14
|
+
expect(open).toEqual({ isOpen: true, initialFocus: 'first' })
|
|
15
|
+
expect(failedMenuReducer(open, { type: 'toggle' })).toEqual(CLOSED_FAILED_MENU)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('opens on the last item when asked and ignores a close while closed', () => {
|
|
19
|
+
expect(failedMenuReducer(CLOSED_FAILED_MENU, { type: 'open', initialFocus: 'last' }).initialFocus).toBe('last')
|
|
20
|
+
expect(failedMenuReducer(CLOSED_FAILED_MENU, { type: 'close' })).toBe(CLOSED_FAILED_MENU)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('failed menu keyboard', () => {
|
|
25
|
+
it('Escape closes and gives the focus back to the trigger', () => {
|
|
26
|
+
expect(resolveFailedMenuKey({ key: 'Escape', index: 0, count: 2 })).toEqual({ type: 'close', shouldRestoreFocus: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('Tab closes without trapping: focus is not pulled back', () => {
|
|
30
|
+
expect(resolveFailedMenuKey({ key: 'Tab', index: 1, count: 2 })).toEqual({ type: 'close', shouldRestoreFocus: false })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('arrows wrap around, Home and End jump', () => {
|
|
34
|
+
expect(resolveFailedMenuKey({ key: 'ArrowDown', index: 1, count: 2 })).toEqual({ type: 'focus', index: 0 })
|
|
35
|
+
expect(resolveFailedMenuKey({ key: 'ArrowUp', index: 0, count: 2 })).toEqual({ type: 'focus', index: 1 })
|
|
36
|
+
expect(resolveFailedMenuKey({ key: 'Home', index: 1, count: 2 })).toEqual({ type: 'focus', index: 0 })
|
|
37
|
+
expect(resolveFailedMenuKey({ key: 'End', index: 0, count: 2 })).toEqual({ type: 'focus', index: 1 })
|
|
38
|
+
expect(resolveFailedMenuKey({ key: 'a', index: 0, count: 2 })).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('ArrowDown and ArrowUp open the menu from the trigger on the matching end', () => {
|
|
42
|
+
expect(resolveFailedMenuTriggerKey('ArrowDown')).toBe('first')
|
|
43
|
+
expect(resolveFailedMenuTriggerKey('ArrowUp')).toBe('last')
|
|
44
|
+
expect(resolveFailedMenuTriggerKey('Enter')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('failed menu placement', () => {
|
|
49
|
+
it('opens above when the scroller leaves room for the menu', () => {
|
|
50
|
+
expect(resolveFailedMenuPlacement({ anchorTop: 300, scrollerTop: 100, menuHeight: 90 })).toBe('above')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('flips below for the first bubble of the conversation, where the scroller would clip it', () => {
|
|
54
|
+
expect(resolveFailedMenuPlacement({ anchorTop: 112, scrollerTop: 100, menuHeight: 90 })).toBe('below')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('needs the gap too: room for exactly the menu is not enough', () => {
|
|
58
|
+
expect(resolveFailedMenuPlacement({ anchorTop: 190, scrollerTop: 100, menuHeight: 90 })).toBe('below')
|
|
59
|
+
expect(resolveFailedMenuPlacement({ anchorTop: 198, scrollerTop: 100, menuHeight: 90 })).toBe('above')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type FailedMenuFocus = 'first' | 'last'
|
|
2
|
+
|
|
3
|
+
export type FailedMenuState = { readonly isOpen: boolean; readonly initialFocus: FailedMenuFocus }
|
|
4
|
+
|
|
5
|
+
export type FailedMenuAction =
|
|
6
|
+
| { readonly type: 'open'; readonly initialFocus: FailedMenuFocus }
|
|
7
|
+
| { readonly type: 'close' }
|
|
8
|
+
| { readonly type: 'toggle' }
|
|
9
|
+
|
|
10
|
+
export const CLOSED_FAILED_MENU: FailedMenuState = { isOpen: false, initialFocus: 'first' }
|
|
11
|
+
|
|
12
|
+
export function failedMenuReducer(state: FailedMenuState, action: FailedMenuAction): FailedMenuState {
|
|
13
|
+
if (action.type === 'open') return { isOpen: true, initialFocus: action.initialFocus }
|
|
14
|
+
if (action.type === 'close') return state.isOpen ? CLOSED_FAILED_MENU : state
|
|
15
|
+
return state.isOpen ? CLOSED_FAILED_MENU : { isOpen: true, initialFocus: 'first' }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type FailedMenuKeyResult =
|
|
19
|
+
| { readonly type: 'focus'; readonly index: number }
|
|
20
|
+
| { readonly type: 'close'; readonly shouldRestoreFocus: boolean }
|
|
21
|
+
|
|
22
|
+
type MenuKeyParams = { readonly key: string; readonly index: number; readonly count: number }
|
|
23
|
+
|
|
24
|
+
/** Roving focus inside the open menu; Tab is never trapped, it closes the menu and lets focus move on. */
|
|
25
|
+
export function resolveFailedMenuKey({ key, index, count }: MenuKeyParams): FailedMenuKeyResult | undefined {
|
|
26
|
+
if (key === 'Escape') return { type: 'close', shouldRestoreFocus: true }
|
|
27
|
+
if (key === 'Tab') return { type: 'close', shouldRestoreFocus: false }
|
|
28
|
+
if (key === 'ArrowDown') return { type: 'focus', index: (index + 1) % count }
|
|
29
|
+
if (key === 'ArrowUp') return { type: 'focus', index: (index - 1 + count) % count }
|
|
30
|
+
if (key === 'Home') return { type: 'focus', index: 0 }
|
|
31
|
+
if (key === 'End') return { type: 'focus', index: count - 1 }
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Keys that open the menu from its trigger; Enter and Space already open it through the click. */
|
|
36
|
+
export function resolveFailedMenuTriggerKey(key: string): FailedMenuFocus | undefined {
|
|
37
|
+
if (key === 'ArrowDown') return 'first'
|
|
38
|
+
if (key === 'ArrowUp') return 'last'
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type FailedMenuPlacement = 'above' | 'below'
|
|
43
|
+
|
|
44
|
+
const MENU_GAP_PX = 8
|
|
45
|
+
|
|
46
|
+
type MenuPlacementParams = {
|
|
47
|
+
/** Top edge of the bubble the menu hangs from. */
|
|
48
|
+
readonly anchorTop: number
|
|
49
|
+
/** Top edge of the scroller that clips the menu. */
|
|
50
|
+
readonly scrollerTop: number
|
|
51
|
+
readonly menuHeight: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The menu opens above its bubble; it flips below when the scroller would clip it. */
|
|
55
|
+
export function resolveFailedMenuPlacement({ anchorTop, scrollerTop, menuHeight }: MenuPlacementParams): FailedMenuPlacement {
|
|
56
|
+
return anchorTop - scrollerTop >= menuHeight + MENU_GAP_PX ? 'above' : 'below'
|
|
57
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { OwnStatus, RetryButton } from './ParticipantBubbleStatus'
|
|
5
|
+
import { ParticipantFailedMenu } from './ParticipantFailedMenu'
|
|
6
|
+
import { declarationsIn } from './participantCss.test-helper'
|
|
7
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as LABELS } from './participantLabels'
|
|
8
|
+
|
|
9
|
+
const noop = (): void => undefined
|
|
10
|
+
|
|
11
|
+
describe('failed message: markup', () => {
|
|
12
|
+
it('puts retry in a round icon button with the long text as its name, no visible text', () => {
|
|
13
|
+
const markup = renderToStaticMarkup(<RetryButton labels={LABELS} onRetry={noop} />)
|
|
14
|
+
|
|
15
|
+
expect(markup).toContain('aria-label="Failed — tap to retry"')
|
|
16
|
+
expect(markup).toContain('aria-hidden="true"')
|
|
17
|
+
expect(markup.replace(/<svg.*<\/svg>/, '')).not.toMatch(/>[A-Za-z]/)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps the meta of a failed bubble to the red icon, with the short text for screen readers only', () => {
|
|
21
|
+
const withRetry = renderToStaticMarkup(<OwnStatus status="failed" labels={LABELS} onRetry={noop} />)
|
|
22
|
+
const withoutRetry = renderToStaticMarkup(<OwnStatus status="failed" labels={LABELS} />)
|
|
23
|
+
|
|
24
|
+
expect(withRetry).not.toContain('<button')
|
|
25
|
+
expect(withRetry).toContain('cv-status-ticks--failed')
|
|
26
|
+
expect(withRetry).toContain('cv-p-sr-only">Failed<')
|
|
27
|
+
expect(withoutRetry).toContain('cv-p-bubble__status-text">Failed<')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders a closed menu trigger with the menu button semantics, and no menu without handlers', () => {
|
|
31
|
+
const markup = renderToStaticMarkup(<ParticipantFailedMenu labels={LABELS} onEdit={noop} onDiscard={noop} />)
|
|
32
|
+
|
|
33
|
+
expect(markup).toContain('aria-haspopup="menu"')
|
|
34
|
+
expect(markup).toContain('aria-expanded="false"')
|
|
35
|
+
expect(markup).toContain('data-cv-tooltip="Message options"')
|
|
36
|
+
expect(markup).toContain('aria-label="Message options"')
|
|
37
|
+
expect(markup).not.toContain('role="menu"')
|
|
38
|
+
expect(renderToStaticMarkup(<ParticipantFailedMenu labels={LABELS} />)).toBe('')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('failed message: styles', () => {
|
|
43
|
+
it('hangs the retry button beside the bubble: 44px target, 2rem circle, no layout space taken', () => {
|
|
44
|
+
const button = declarationsIn('.cv-p-failed-retry')
|
|
45
|
+
const icon = declarationsIn('.cv-p-failed-retry__icon')
|
|
46
|
+
|
|
47
|
+
expect(button.get('position')).toBe('absolute')
|
|
48
|
+
expect(button.get('right')).toContain('100%')
|
|
49
|
+
expect(button.get('width')).toBe('var(--cv-p-i-touch)')
|
|
50
|
+
expect(button.get('height')).toBe('var(--cv-p-i-touch)')
|
|
51
|
+
expect(icon.get('width')).toBe('2rem')
|
|
52
|
+
expect(icon.get('border-radius')).toBe('50%')
|
|
53
|
+
expect(button.get('color')).toBe('var(--cv-p-i-danger)')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps the bubble cap of 85% by not wrapping the bubble in a row', () => {
|
|
57
|
+
expect(declarationsIn('.cv-p-bubble-row--failed').size).toBe(0)
|
|
58
|
+
expect(declarationsIn('.cv-p-bubble').get('max-width')).toBe('85%')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('flips the menu below the bubble with a modifier', () => {
|
|
62
|
+
expect(declarationsIn('.cv-p-failed-menu__list--below').get('top')).toContain('100%')
|
|
63
|
+
expect(declarationsIn('.cv-p-failed-menu__list--below').get('bottom')).toBe('auto')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('draws the menu with square corners, text and danger tokens, 44px items', () => {
|
|
67
|
+
expect(declarationsIn('.cv-p-failed-menu__list').get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
68
|
+
expect(declarationsIn('.cv-p-failed-menu__list').get('bottom')).toContain('100%')
|
|
69
|
+
expect(declarationsIn('.cv-p-failed-menu__item').get('color')).toBe('var(--cv-p-i-text)')
|
|
70
|
+
expect(declarationsIn('.cv-p-failed-menu__item').get('min-height')).toBe('var(--cv-p-i-touch)')
|
|
71
|
+
expect(declarationsIn('.cv-p-failed-menu__item--danger').get('color')).toBe('var(--cv-p-i-danger)')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('keeps the trigger hit area inside the bubble padding (0.5rem), never into other rows', () => {
|
|
75
|
+
expect(declarationsIn('.cv-p-failed-menu__trigger::after').get('inset')).toBe('-0.5rem -0.4rem')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('has no leftover action-row or bordered-retry rules', () => {
|
|
79
|
+
for (const selector of ['.cv-p-bubble__retry', '.cv-p-bubble__action', '.cv-p-bubble__actions']) {
|
|
80
|
+
expect(declarationsIn(selector).size).toBe(0)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -15,7 +15,7 @@ export type ValidateParticipantFilesResult = {
|
|
|
15
15
|
readonly rejection?: ParticipantFileRejection
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function matchesAcceptedType(mimeType: string, acceptedTypes: readonly string[]): boolean {
|
|
18
|
+
export function matchesAcceptedType(mimeType: string, acceptedTypes: readonly string[]): boolean {
|
|
19
19
|
return acceptedTypes.some((accepted) =>
|
|
20
20
|
accepted.endsWith('/*') ? mimeType.startsWith(accepted.slice(0, -1)) : mimeType === accepted,
|
|
21
21
|
)
|
|
@@ -20,7 +20,17 @@ const ITEMS = [
|
|
|
20
20
|
{ kind: 'pending', origin: 'local', displayState: 'failed', pending: { clientMessageId: 'c', subject: { subjectType: 'invoice', subjectId: '1' }, text: 'W', createdAt: '2026-10-02T12:06:00.000Z', state: 'failed' } },
|
|
21
21
|
]
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
export const EMPTY_DRAFT = { value: '', onChange: NOOP, files: [], onFilesChange: NOOP }
|
|
24
|
+
|
|
25
|
+
const AUDIO_NATIVE_ITEMS = [
|
|
26
|
+
{ kind: 'server', message: buildMessage({ id: 'au', direction: 'inbound', text: '', attachments: [{ id: 'att-1', kind: 'audio', filename: 'voice.webm', mimeType: 'audio/webm', sizeBytes: 2048 }], createdAt: '2026-10-01T12:00:00.000Z' }) },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const AUDIO_PLAYER_ITEMS = [
|
|
30
|
+
{ kind: 'server', message: buildMessage({ id: 'au', direction: 'inbound', text: '', attachments: [{ id: 'att-1', kind: 'audio', filename: 'voice.webm', mimeType: 'audio/webm', sizeBytes: 2048, durationMs: 65_000 }], createdAt: '2026-10-01T12:00:00.000Z' }) },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export function buildThreadProps(labels: unknown): Record<string, unknown> {
|
|
24
34
|
return {
|
|
25
35
|
conversation: buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521', protocol: 'P-1', channels: ['app', 'whatsapp'] }),
|
|
26
36
|
items: ITEMS, hasMore: true, onLoadOlder: NOOP, labels, resolveAttachmentUrl: async () => 'x',
|
|
@@ -40,6 +50,9 @@ const THREAD_VARIANTS: Record<string, Record<string, unknown>> = {
|
|
|
40
50
|
'thread-error': { status: 'error', items: [] },
|
|
41
51
|
'thread-loading': { status: 'loading', items: [] },
|
|
42
52
|
'thread-closed': { conversation: buildConversation({ subjectId: '2', status: 'closed' }) },
|
|
53
|
+
'thread-empty-draft': { draft: EMPTY_DRAFT },
|
|
54
|
+
'thread-audio-native': { items: AUDIO_NATIVE_ITEMS },
|
|
55
|
+
'thread-audio-player': { items: AUDIO_PLAYER_ITEMS },
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
export function renderGoldenScenarios({ ParticipantThread, ParticipantConversations, labels }: GoldenComponents): Record<string, string> {
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { DEFAULT_PARTICIPANT_AUDIO_LABELS, type ParticipantAudioLabels } from './participantAudioLabels'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MESSAGE_OPTIONS_LABEL = 'Message options'
|
|
4
|
+
|
|
1
5
|
export type ParticipantConversationsLabels = {
|
|
2
6
|
readonly inboxTitle: string
|
|
3
7
|
readonly sectionAwaiting: string
|
|
@@ -33,9 +37,12 @@ export type ParticipantConversationsLabels = {
|
|
|
33
37
|
readonly statusRead: string
|
|
34
38
|
readonly statusFailed: string
|
|
35
39
|
readonly statusFailedShort: string
|
|
40
|
+
/** @deprecated No longer rendered by the default view: the retry button is icon-only and named by `statusFailed`. */
|
|
36
41
|
readonly retry: string
|
|
37
42
|
readonly discard: string
|
|
38
43
|
readonly edit: string
|
|
44
|
+
/** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
|
|
45
|
+
readonly messageOptions?: string
|
|
39
46
|
readonly notFound: string
|
|
40
47
|
readonly loading: string
|
|
41
48
|
readonly loadError: string
|
|
@@ -60,7 +67,7 @@ export type ParticipantConversationsLabels = {
|
|
|
60
67
|
/** Prefix of the accessible name of the copy buttons: "Copy 529.982.247-25". */
|
|
61
68
|
readonly copyValue: string
|
|
62
69
|
readonly valueCopied: string
|
|
63
|
-
}
|
|
70
|
+
} & Readonly<Partial<ParticipantAudioLabels>>
|
|
64
71
|
|
|
65
72
|
export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsLabels = {
|
|
66
73
|
inboxTitle: 'Conversations',
|
|
@@ -98,6 +105,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
|
|
|
98
105
|
retry: 'Retry',
|
|
99
106
|
discard: 'Discard',
|
|
100
107
|
edit: 'Edit',
|
|
108
|
+
messageOptions: DEFAULT_MESSAGE_OPTIONS_LABEL,
|
|
101
109
|
notFound: 'Conversation not found',
|
|
102
110
|
loading: 'Loading…',
|
|
103
111
|
loadError: 'Could not load. Check your connection and try again.',
|
|
@@ -119,6 +127,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
|
|
|
119
127
|
externalLinkHint: 'opens in a new tab',
|
|
120
128
|
copyValue: 'Copy',
|
|
121
129
|
valueCopied: 'Copied',
|
|
130
|
+
...DEFAULT_PARTICIPANT_AUDIO_LABELS,
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
export function formatParticipantLabel(template: string, count: number): string {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
type MediaState = {
|
|
2
|
+
paused: boolean
|
|
3
|
+
currentTime: number
|
|
4
|
+
duration: number
|
|
5
|
+
defaultPlaybackRate: number
|
|
6
|
+
playbackRate: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type FakeMediaControls = {
|
|
10
|
+
/** Elements in the order `play()` was called on them. */
|
|
11
|
+
readonly playCalls: HTMLMediaElement[]
|
|
12
|
+
readonly pauseCalls: HTMLMediaElement[]
|
|
13
|
+
/** What the next `play()` promises: the iOS gesture refusal is a rejection. */
|
|
14
|
+
playOutcome: 'resolve' | 'reject'
|
|
15
|
+
readonly stateOf: (audio: HTMLMediaElement) => MediaState
|
|
16
|
+
readonly emit: (audio: HTMLMediaElement, type: string) => void
|
|
17
|
+
readonly restore: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const OVERRIDDEN = ['paused', 'currentTime', 'duration', 'defaultPlaybackRate', 'playbackRate', 'play', 'pause', 'load'] as const
|
|
21
|
+
|
|
22
|
+
/** Replaces the HTMLMediaElement members the player uses with an in-memory media element. */
|
|
23
|
+
export function installFakeMedia(): FakeMediaControls {
|
|
24
|
+
const prototype = HTMLMediaElement.prototype
|
|
25
|
+
const previous = OVERRIDDEN.map((name) => [name, Object.getOwnPropertyDescriptor(prototype, name)] as const)
|
|
26
|
+
const states = new WeakMap<object, MediaState>()
|
|
27
|
+
const stateOf = (audio: HTMLMediaElement): MediaState => {
|
|
28
|
+
const existing = states.get(audio)
|
|
29
|
+
if (existing) return existing
|
|
30
|
+
const created: MediaState = { paused: true, currentTime: 0, duration: Number.NaN, defaultPlaybackRate: 1, playbackRate: 1 }
|
|
31
|
+
states.set(audio, created)
|
|
32
|
+
return created
|
|
33
|
+
}
|
|
34
|
+
const emit = (audio: HTMLMediaElement, type: string): void => {
|
|
35
|
+
audio.dispatchEvent(new Event(type))
|
|
36
|
+
}
|
|
37
|
+
const controls: FakeMediaControls = {
|
|
38
|
+
playCalls: [],
|
|
39
|
+
pauseCalls: [],
|
|
40
|
+
playOutcome: 'resolve',
|
|
41
|
+
stateOf,
|
|
42
|
+
emit,
|
|
43
|
+
restore: () => {
|
|
44
|
+
for (const [name, descriptor] of previous) {
|
|
45
|
+
if (descriptor) Object.defineProperty(prototype, name, descriptor)
|
|
46
|
+
else delete (prototype as unknown as Record<string, unknown>)[name]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
for (const name of ['paused', 'currentTime', 'duration', 'defaultPlaybackRate', 'playbackRate'] as const) {
|
|
51
|
+
Object.defineProperty(prototype, name, {
|
|
52
|
+
configurable: true,
|
|
53
|
+
get(this: HTMLMediaElement) {
|
|
54
|
+
return stateOf(this)[name]
|
|
55
|
+
},
|
|
56
|
+
set(this: HTMLMediaElement, value: never) {
|
|
57
|
+
;(stateOf(this) as Record<string, unknown>)[name] = value
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
Object.defineProperty(prototype, 'play', {
|
|
62
|
+
configurable: true,
|
|
63
|
+
value(this: HTMLMediaElement): Promise<void> {
|
|
64
|
+
controls.playCalls.push(this)
|
|
65
|
+
if (controls.playOutcome === 'reject') return Promise.reject(new Error('NotAllowedError'))
|
|
66
|
+
stateOf(this).paused = false
|
|
67
|
+
emit(this, 'play')
|
|
68
|
+
return Promise.resolve()
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
Object.defineProperty(prototype, 'pause', {
|
|
72
|
+
configurable: true,
|
|
73
|
+
value(this: HTMLMediaElement): void {
|
|
74
|
+
if (stateOf(this).paused) return
|
|
75
|
+
controls.pauseCalls.push(this)
|
|
76
|
+
stateOf(this).paused = true
|
|
77
|
+
emit(this, 'pause')
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
Object.defineProperty(prototype, 'load', { configurable: true, value: () => undefined })
|
|
81
|
+
return controls
|
|
82
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ComponentType } 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 { buildThreadProps } from './participantGoldenScenarios.test-helper'
|
|
7
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
8
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
9
|
+
|
|
10
|
+
const UntypedThread = ParticipantThread as unknown as ComponentType<Record<string, unknown>>
|
|
11
|
+
|
|
12
|
+
describe('empty composer in a recording browser without the audioRecording prop (contract 2.4 item 3)', () => {
|
|
13
|
+
let harness: DomHarness
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
harness = await setupDom()
|
|
17
|
+
const globals = globalThis as Record<string, unknown>
|
|
18
|
+
globals.MediaRecorder = class FakeMediaRecorder {
|
|
19
|
+
static isTypeSupported(): boolean {
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
;(navigator as unknown as Record<string, unknown>).mediaDevices = { getUserMedia: async () => ({ getTracks: () => [] }) }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterAll(() => {
|
|
27
|
+
delete (globalThis as Record<string, unknown>).MediaRecorder
|
|
28
|
+
harness.teardown()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders no microphone button even though MediaRecorder and getUserMedia exist', () => {
|
|
32
|
+
const props = {
|
|
33
|
+
...buildThreadProps(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS),
|
|
34
|
+
draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined },
|
|
35
|
+
}
|
|
36
|
+
const { container } = harness.mount(<UntypedThread {...props} />)
|
|
37
|
+
|
|
38
|
+
expect(typeof (globalThis as Record<string, unknown>).MediaRecorder).toBe('function')
|
|
39
|
+
expect(container.querySelector('.cv-p-composer__send') !== null).toBe(true)
|
|
40
|
+
expect(container.querySelector('.cv-p-composer__record') === null).toBe(true)
|
|
41
|
+
expect(container.querySelector('button[aria-label="Record audio"]') === null).toBe(true)
|
|
42
|
+
expect(Array.from(container.querySelectorAll('button')).some((button) => /record/i.test(button.getAttribute('aria-label') ?? ''))).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantAttachment, ParticipantTranscription } from './index'
|
|
4
|
+
|
|
5
|
+
describe('the /participant entry re-exports the transcription type next to ParticipantAttachment', () => {
|
|
6
|
+
it('lets a host type the field without importing the contracts package (checked by tsc)', () => {
|
|
7
|
+
const transcription: ParticipantTranscription = { status: 'pending' }
|
|
8
|
+
const attachment: Pick<ParticipantAttachment, 'transcription'> = { transcription }
|
|
9
|
+
expect(attachment.transcription?.status).toBe('pending')
|
|
10
|
+
})
|
|
11
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { act } from 'react'
|
|
2
|
+
|
|
3
|
+
import { afterAll, afterEach, beforeAll, beforeEach, jest } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { setupDom, type DomHarness } from '../domHarness.test-helper'
|
|
6
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
7
|
+
import { useParticipantAudioRecorder } from './useParticipantAudioRecorder'
|
|
8
|
+
|
|
9
|
+
export type RecorderApi = ReturnType<typeof useParticipantAudioRecorder>
|
|
10
|
+
|
|
11
|
+
export class FakeTrack {
|
|
12
|
+
stopCount = 0
|
|
13
|
+
onended: (() => void) | null = null
|
|
14
|
+
onmute: (() => void) | null = null
|
|
15
|
+
onunmute: (() => void) | null = null
|
|
16
|
+
stop(): void {
|
|
17
|
+
this.stopCount += 1
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class FakeStream {
|
|
22
|
+
readonly track = new FakeTrack()
|
|
23
|
+
getTracks(): FakeTrack[] {
|
|
24
|
+
return [this.track]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Mutable state shared by the recorder DOM suites; each suite file builds its own through `setupRecorderDom`. */
|
|
29
|
+
export type RecorderDomKit = {
|
|
30
|
+
harness: DomHarness
|
|
31
|
+
api: RecorderApi
|
|
32
|
+
stream: FakeStream
|
|
33
|
+
supportedTypes: readonly string[]
|
|
34
|
+
chunkBytes: number
|
|
35
|
+
revoked: string[]
|
|
36
|
+
captureEvents: boolean[]
|
|
37
|
+
resolveStream: ((value: FakeStream) => void) | undefined
|
|
38
|
+
getUserMediaImpl: () => Promise<FakeStream>
|
|
39
|
+
mountProbe: (options?: ParticipantAudioRecordingOptions) => ReturnType<DomHarness['mount']>
|
|
40
|
+
start: () => Promise<void>
|
|
41
|
+
advance: (milliseconds: number) => Promise<void>
|
|
42
|
+
recordFor: (milliseconds: number) => Promise<void>
|
|
43
|
+
deferStream: () => void
|
|
44
|
+
hidePage: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function setupRecorderDom(): RecorderDomKit {
|
|
48
|
+
class FakeMediaRecorder {
|
|
49
|
+
static isTypeSupported(mimeType: string): boolean {
|
|
50
|
+
return kit.supportedTypes.includes(mimeType)
|
|
51
|
+
}
|
|
52
|
+
state: 'inactive' | 'recording' = 'inactive'
|
|
53
|
+
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
|
54
|
+
onstop: (() => void) | null = null
|
|
55
|
+
onerror: (() => void) | null = null
|
|
56
|
+
constructor(readonly stream: FakeStream, readonly options: { mimeType: string }) {}
|
|
57
|
+
start(): void {
|
|
58
|
+
this.state = 'recording'
|
|
59
|
+
}
|
|
60
|
+
stop(): void {
|
|
61
|
+
this.state = 'inactive'
|
|
62
|
+
this.ondataavailable?.({ data: new Blob([new Uint8Array(kit.chunkBytes)]) })
|
|
63
|
+
this.onstop?.()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Probe({ options, channelMaxBytes }: { options?: ParticipantAudioRecordingOptions; channelMaxBytes?: number }) {
|
|
68
|
+
kit.api = useParticipantAudioRecorder({ options: { ...options, onCaptureChange: (isCapturing) => kit.captureEvents.push(isCapturing) }, channelMaxBytes })
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const kit = {
|
|
73
|
+
mountProbe: (options?: ParticipantAudioRecordingOptions) => kit.harness.mount(<Probe options={options} />),
|
|
74
|
+
start: async () => {
|
|
75
|
+
await act(async () => {
|
|
76
|
+
void kit.api.start()
|
|
77
|
+
})
|
|
78
|
+
},
|
|
79
|
+
advance: async (milliseconds: number) => {
|
|
80
|
+
await act(async () => {
|
|
81
|
+
jest.advanceTimersByTime(milliseconds)
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
recordFor: async (milliseconds: number) => {
|
|
85
|
+
await kit.start()
|
|
86
|
+
await kit.advance(milliseconds)
|
|
87
|
+
},
|
|
88
|
+
deferStream: () => {
|
|
89
|
+
kit.getUserMediaImpl = () => new Promise<FakeStream>((resolve) => (kit.resolveStream = resolve))
|
|
90
|
+
},
|
|
91
|
+
hidePage: () => {
|
|
92
|
+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
|
|
93
|
+
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
|
|
94
|
+
act(() => {
|
|
95
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
96
|
+
})
|
|
97
|
+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' })
|
|
98
|
+
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false })
|
|
99
|
+
},
|
|
100
|
+
} as unknown as RecorderDomKit
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
kit.harness = await setupDom()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
afterAll(() => kit.harness.teardown())
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
jest.useFakeTimers()
|
|
110
|
+
kit.supportedTypes = ['audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm']
|
|
111
|
+
kit.chunkBytes = 4096
|
|
112
|
+
kit.revoked = []
|
|
113
|
+
kit.captureEvents = []
|
|
114
|
+
kit.stream = new FakeStream()
|
|
115
|
+
kit.resolveStream = undefined
|
|
116
|
+
kit.getUserMediaImpl = async () => kit.stream
|
|
117
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = FakeMediaRecorder
|
|
118
|
+
;(navigator as unknown as Record<string, unknown>).mediaDevices = { getUserMedia: () => kit.getUserMediaImpl() }
|
|
119
|
+
URL.createObjectURL = () => 'blob:fake-recording'
|
|
120
|
+
URL.revokeObjectURL = (url: string) => {
|
|
121
|
+
kit.revoked.push(url)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
jest.useRealTimers()
|
|
127
|
+
delete (globalThis as Record<string, unknown>).MediaRecorder
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
return kit
|
|
131
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type FakeRecordingControls = {
|
|
2
|
+
chunkBytes: number
|
|
3
|
+
readonly restore: () => void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
class FakeTrack {
|
|
7
|
+
onended: (() => void) | null = null
|
|
8
|
+
onmute: (() => void) | null = null
|
|
9
|
+
onunmute: (() => void) | null = null
|
|
10
|
+
stop(): void {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class FakeStream {
|
|
14
|
+
getTracks(): FakeTrack[] {
|
|
15
|
+
return [new FakeTrack()]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Installs a MediaRecorder and getUserMedia that record `controls.chunkBytes` bytes on stop. */
|
|
20
|
+
export function installFakeRecording(initialChunkBytes = 4096): FakeRecordingControls {
|
|
21
|
+
const globals = globalThis as Record<string, unknown>
|
|
22
|
+
const navigatorObject = globalThis.navigator as unknown as Record<string, unknown>
|
|
23
|
+
const previousRecorder = globals.MediaRecorder
|
|
24
|
+
const previousMediaDevices = navigatorObject.mediaDevices
|
|
25
|
+
const controls: FakeRecordingControls = {
|
|
26
|
+
chunkBytes: initialChunkBytes,
|
|
27
|
+
restore: () => {
|
|
28
|
+
globals.MediaRecorder = previousRecorder
|
|
29
|
+
navigatorObject.mediaDevices = previousMediaDevices
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
globals.MediaRecorder = class FakeMediaRecorder {
|
|
33
|
+
static isTypeSupported(mimeType: string): boolean {
|
|
34
|
+
return mimeType === 'audio/ogg;codecs=opus'
|
|
35
|
+
}
|
|
36
|
+
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
|
37
|
+
onstop: (() => void) | null = null
|
|
38
|
+
onerror: (() => void) | null = null
|
|
39
|
+
start(): void {}
|
|
40
|
+
stop(): void {
|
|
41
|
+
this.ondataavailable?.({ data: new Blob([new Uint8Array(controls.chunkBytes)]) })
|
|
42
|
+
this.onstop?.()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
navigatorObject.mediaDevices = { getUserMedia: async () => new FakeStream() }
|
|
46
|
+
return controls
|
|
47
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export class SpecTrack {
|
|
2
|
+
stopCount = 0
|
|
3
|
+
onended: (() => void) | null = null
|
|
4
|
+
onmute: (() => void) | null = null
|
|
5
|
+
onunmute: (() => void) | null = null
|
|
6
|
+
stop(): void {
|
|
7
|
+
this.stopCount += 1
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SpecStream {
|
|
12
|
+
readonly track = new SpecTrack()
|
|
13
|
+
getTracks(): SpecTrack[] {
|
|
14
|
+
return [this.track]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Follows the MediaRecorder spec: stop() makes state inactive at once; dataavailable and stop arrive later. */
|
|
19
|
+
export class SpecMediaRecorder {
|
|
20
|
+
static instances: SpecMediaRecorder[] = []
|
|
21
|
+
static chunkBytes = 4096
|
|
22
|
+
static isTypeSupported(mimeType: string): boolean {
|
|
23
|
+
return mimeType === 'audio/ogg;codecs=opus'
|
|
24
|
+
}
|
|
25
|
+
state: 'inactive' | 'recording' = 'inactive'
|
|
26
|
+
stopCalls = 0
|
|
27
|
+
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
|
28
|
+
onstop: (() => void) | null = null
|
|
29
|
+
onerror: (() => void) | null = null
|
|
30
|
+
constructor() {
|
|
31
|
+
SpecMediaRecorder.instances.push(this)
|
|
32
|
+
}
|
|
33
|
+
start(): void {
|
|
34
|
+
this.state = 'recording'
|
|
35
|
+
}
|
|
36
|
+
stop(): void {
|
|
37
|
+
if (this.state === 'inactive') throw new DOMException('The MediaRecorder is already inactive', 'InvalidStateError')
|
|
38
|
+
this.stopCalls += 1
|
|
39
|
+
this.finishAsynchronously()
|
|
40
|
+
}
|
|
41
|
+
/** The user agent stops the recorder by itself (for example when the track ends). */
|
|
42
|
+
autoStop(): void {
|
|
43
|
+
this.finishAsynchronously()
|
|
44
|
+
}
|
|
45
|
+
private finishAsynchronously(): void {
|
|
46
|
+
this.state = 'inactive'
|
|
47
|
+
void Promise.resolve().then(() => {
|
|
48
|
+
this.ondataavailable?.({ data: new Blob([new Uint8Array(SpecMediaRecorder.chunkBytes)]) })
|
|
49
|
+
this.onstop?.()
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|