@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,29 @@
|
|
|
1
|
+
import type { Dispatch, KeyboardEvent, RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { resolveFailedMenuKey, type FailedMenuAction } from './participantFailedMenuState'
|
|
4
|
+
|
|
5
|
+
type MenuKeyParams = {
|
|
6
|
+
readonly event: KeyboardEvent<HTMLElement>
|
|
7
|
+
readonly menuRef: RefObject<HTMLElement | null>
|
|
8
|
+
readonly triggerRef: RefObject<HTMLElement | null>
|
|
9
|
+
readonly dispatch: Dispatch<FailedMenuAction>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Escape is consumed here so it does not also close a host dialog or drawer around the thread. */
|
|
13
|
+
export function handleFailedMenuKeyDown({ event, menuRef, triggerRef, dispatch }: MenuKeyParams): void {
|
|
14
|
+
const items = Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])
|
|
15
|
+
const index = items.findIndex((item) => item === document.activeElement)
|
|
16
|
+
const result = resolveFailedMenuKey({ key: event.key, index, count: items.length })
|
|
17
|
+
if (!result) return
|
|
18
|
+
if (result.type === 'focus') {
|
|
19
|
+
event.preventDefault()
|
|
20
|
+
items[result.index]?.focus()
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
if (result.shouldRestoreFocus) {
|
|
24
|
+
event.preventDefault()
|
|
25
|
+
event.stopPropagation()
|
|
26
|
+
triggerRef.current?.focus()
|
|
27
|
+
}
|
|
28
|
+
dispatch({ type: 'close' })
|
|
29
|
+
}
|
package/src/participant/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export type { ConversationThreadApi } from './conversationThreadApi'
|
|
|
4
4
|
export type { ParticipantPerspective } from './participantPerspective'
|
|
5
5
|
export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
6
6
|
export { groupParticipantConversations } from './participantGrouping'
|
|
7
|
+
export { DEFAULT_MAX_RECORDING_BYTES, type ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
8
|
+
export { DEFAULT_PARTICIPANT_AUDIO_LABELS, type ParticipantAudioLabels } from './participantAudioLabels'
|
|
7
9
|
export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
8
10
|
export type {
|
|
9
11
|
ParticipantConversationsClassNames,
|
|
@@ -27,4 +29,5 @@ export type {
|
|
|
27
29
|
ParticipantConversationSummary,
|
|
28
30
|
ParticipantMessage,
|
|
29
31
|
ParticipantSubjectRef,
|
|
32
|
+
ParticipantTranscription,
|
|
30
33
|
} from '@adatechnology/conversation-contracts'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ParticipantAudioRecordingOptions = {
|
|
2
|
+
/** Only lowers the default: a larger value is cut to DEFAULT_MAX_RECORDING_MILLISECONDS (5 min). */
|
|
3
|
+
readonly maxDurationMs?: number
|
|
4
|
+
/** Only lowers the default: a larger value is cut to DEFAULT_MAX_RECORDING_BYTES (16 MiB, the app audio policy). */
|
|
5
|
+
readonly maxSizeBytes?: number
|
|
6
|
+
/** True from `requesting` until it leaves `review`; the host feeds its capture registry. Never receives the audio. */
|
|
7
|
+
readonly onCaptureChange?: (isCapturing: boolean) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_MAX_RECORDING_BYTES = 16 * 1024 * 1024
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { buildRecordedFile, formatRecordedFileName } from './participantAudioCapture'
|
|
4
|
+
|
|
5
|
+
describe('recorded file naming', () => {
|
|
6
|
+
it('names the file audio-YYYYMMDD-HHmmss.<ext> in UTC', () => {
|
|
7
|
+
expect(formatRecordedFileName(new Date('2026-10-11T09:05:03.999Z'), 'm4a')).toBe('audio-20261011-090503.m4a')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('keeps the upload mime type of the chosen format', () => {
|
|
11
|
+
const file = buildRecordedFile(new Blob(['x']), { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' }, new Date('2026-01-02T03:04:05Z'))
|
|
12
|
+
expect(file.name).toBe('audio-20260102-030405.ogg')
|
|
13
|
+
expect(file.type).toBe('audio/ogg')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { RecordingFormat } from '../audioRecorderFormat'
|
|
2
|
+
import { MUTE_INTERRUPTION_DELAY_MS } from './participantAudioRecorderState'
|
|
3
|
+
|
|
4
|
+
export type CaptureSession = {
|
|
5
|
+
readonly format: RecordingFormat
|
|
6
|
+
readonly startedAt: number
|
|
7
|
+
readonly requestStop: () => void
|
|
8
|
+
readonly release: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type OpenCaptureSessionParams = {
|
|
12
|
+
readonly stream: MediaStream
|
|
13
|
+
readonly format: RecordingFormat
|
|
14
|
+
readonly onInterrupt: () => void
|
|
15
|
+
readonly onStopped: (blob: Blob, durationMs: number) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stopStream(stream: MediaStream): void {
|
|
19
|
+
for (const track of stream.getTracks()) track.stop()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatRecordedFileName(moment: Date, extension: string): string {
|
|
23
|
+
const iso = moment.toISOString()
|
|
24
|
+
const day = iso.slice(0, 10).replace(/-/g, '')
|
|
25
|
+
const time = iso.slice(11, 19).replace(/:/g, '')
|
|
26
|
+
return `audio-${day}-${time}.${extension}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildRecordedFile(blob: Blob, format: RecordingFormat, moment: Date = new Date()): File {
|
|
30
|
+
return new File([blob], formatRecordedFileName(moment, format.extension), { type: format.uploadMimeType })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createMuteWatch(onPersistentMute: () => void): { arm: () => void; clear: () => void } {
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
35
|
+
const clear = (): void => {
|
|
36
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
37
|
+
timer = undefined
|
|
38
|
+
}
|
|
39
|
+
const arm = (): void => {
|
|
40
|
+
clear()
|
|
41
|
+
timer = setTimeout(onPersistentMute, MUTE_INTERRUPTION_DELAY_MS)
|
|
42
|
+
}
|
|
43
|
+
return { arm, clear }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function openCaptureSession({ stream, format, onInterrupt, onStopped }: OpenCaptureSessionParams): CaptureSession {
|
|
47
|
+
const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
|
|
48
|
+
const chunks: Blob[] = []
|
|
49
|
+
const startedAt = Date.now()
|
|
50
|
+
const muteWatch = createMuteWatch(onInterrupt)
|
|
51
|
+
for (const track of stream.getTracks()) {
|
|
52
|
+
track.onended = onInterrupt
|
|
53
|
+
track.onmute = muteWatch.arm
|
|
54
|
+
track.onunmute = muteWatch.clear
|
|
55
|
+
}
|
|
56
|
+
recorder.ondataavailable = (event) => {
|
|
57
|
+
if (event.data.size > 0) chunks.push(event.data)
|
|
58
|
+
}
|
|
59
|
+
recorder.onerror = onInterrupt
|
|
60
|
+
recorder.onstop = () => onStopped(new Blob(chunks, { type: format.uploadMimeType }), Date.now() - startedAt)
|
|
61
|
+
recorder.start()
|
|
62
|
+
|
|
63
|
+
let isStopRequested = false
|
|
64
|
+
// stop() makes the state inactive at once and the real `stop` event arrives later, so only that event may finish the capture
|
|
65
|
+
function requestStop(): void {
|
|
66
|
+
if (isStopRequested || recorder.state === 'inactive') return
|
|
67
|
+
isStopRequested = true
|
|
68
|
+
recorder.stop()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function release(): void {
|
|
72
|
+
muteWatch.clear()
|
|
73
|
+
recorder.ondataavailable = null
|
|
74
|
+
recorder.onerror = null
|
|
75
|
+
recorder.onstop = null
|
|
76
|
+
for (const track of stream.getTracks()) {
|
|
77
|
+
track.onended = null
|
|
78
|
+
track.onmute = null
|
|
79
|
+
track.onunmute = null
|
|
80
|
+
}
|
|
81
|
+
stopStream(stream)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { format, startedAt, requestStop, release }
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type AudioPlaybackHandle = { readonly pause: () => void }
|
|
2
|
+
|
|
3
|
+
let activeHandle: AudioPlaybackHandle | undefined
|
|
4
|
+
|
|
5
|
+
/** One audio at a time on the page: whoever starts pauses the previous one. No provider, no context. */
|
|
6
|
+
export function claimAudioPlayback(handle: AudioPlaybackHandle): void {
|
|
7
|
+
if (activeHandle && activeHandle !== handle) activeHandle.pause()
|
|
8
|
+
activeHandle = handle
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function releaseAudioPlayback(handle: AudioPlaybackHandle): void {
|
|
12
|
+
if (activeHandle === handle) activeHandle = undefined
|
|
13
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PLAYBACK_RATES,
|
|
5
|
+
formatAudioClock,
|
|
6
|
+
formatPlaybackRate,
|
|
7
|
+
formatPositionValueText,
|
|
8
|
+
nextRateIndex,
|
|
9
|
+
resolveMediaDurationSeconds,
|
|
10
|
+
resolveSeekKeyTarget,
|
|
11
|
+
resolveSeekMax,
|
|
12
|
+
resolveServerDurationSeconds,
|
|
13
|
+
} from './participantAudioFormat'
|
|
14
|
+
|
|
15
|
+
describe('formatAudioClock', () => {
|
|
16
|
+
it('formats m:ss and pads the seconds', () => {
|
|
17
|
+
expect(formatAudioClock(0)).toBe('0:00')
|
|
18
|
+
expect(formatAudioClock(12.9)).toBe('0:12')
|
|
19
|
+
expect(formatAudioClock(65)).toBe('1:05')
|
|
20
|
+
expect(formatAudioClock(300)).toBe('5:00')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('shows a placeholder when the duration is unknown or infinite', () => {
|
|
24
|
+
expect(formatAudioClock(undefined)).toBe('--:--')
|
|
25
|
+
expect(formatAudioClock(Number.POSITIVE_INFINITY)).toBe('--:--')
|
|
26
|
+
expect(formatAudioClock(Number.NaN)).toBe('--:--')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('playback rate', () => {
|
|
31
|
+
it('cycles 1x, 1.5x, 2x and back', () => {
|
|
32
|
+
expect(PLAYBACK_RATES).toEqual([1, 1.5, 2])
|
|
33
|
+
expect(nextRateIndex(0)).toBe(1)
|
|
34
|
+
expect(nextRateIndex(1)).toBe(2)
|
|
35
|
+
expect(nextRateIndex(2)).toBe(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('names the rate with the multiplication sign', () => {
|
|
39
|
+
expect(formatPlaybackRate(1.5)).toBe('1.5×')
|
|
40
|
+
expect(formatPlaybackRate(2)).toBe('2×')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('durations', () => {
|
|
45
|
+
it('takes the server duration in seconds only when positive and finite', () => {
|
|
46
|
+
expect(resolveServerDurationSeconds(65_000)).toBe(65)
|
|
47
|
+
expect(resolveServerDurationSeconds(undefined)).toBeUndefined()
|
|
48
|
+
expect(resolveServerDurationSeconds(0)).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('ignores an infinite or NaN media duration (Chromium WebM without a header)', () => {
|
|
52
|
+
expect(resolveMediaDurationSeconds(42)).toBe(42)
|
|
53
|
+
expect(resolveMediaDurationSeconds(Number.POSITIVE_INFINITY)).toBeUndefined()
|
|
54
|
+
expect(resolveMediaDurationSeconds(Number.NaN)).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('keyboard step', () => {
|
|
59
|
+
it('moves 5 seconds with the arrows and clamps at the start and the end', () => {
|
|
60
|
+
expect(resolveSeekKeyTarget({ key: 'ArrowRight', position: 10, duration: 65 })).toBe(15)
|
|
61
|
+
expect(resolveSeekKeyTarget({ key: 'ArrowUp', position: 10, duration: 65 })).toBe(15)
|
|
62
|
+
expect(resolveSeekKeyTarget({ key: 'ArrowLeft', position: 10, duration: 65 })).toBe(5)
|
|
63
|
+
expect(resolveSeekKeyTarget({ key: 'ArrowDown', position: 2, duration: 65 })).toBe(0)
|
|
64
|
+
expect(resolveSeekKeyTarget({ key: 'ArrowRight', position: 63, duration: 65 })).toBe(65)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('jumps to the start with Home and to the end with End', () => {
|
|
68
|
+
expect(resolveSeekKeyTarget({ key: 'Home', position: 30, duration: 65 })).toBe(0)
|
|
69
|
+
expect(resolveSeekKeyTarget({ key: 'End', position: 30, duration: 65 })).toBe(65)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not know the end without a finite duration and leaves other keys alone', () => {
|
|
73
|
+
expect(resolveSeekKeyTarget({ key: 'End', position: 30, duration: undefined })).toBeUndefined()
|
|
74
|
+
expect(resolveSeekKeyTarget({ key: 'ArrowRight', position: 30, duration: undefined })).toBe(35)
|
|
75
|
+
expect(resolveSeekKeyTarget({ key: 'a', position: 30, duration: 65 })).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('range bounds and value text', () => {
|
|
80
|
+
it('uses the rounded-up duration as max when known', () => {
|
|
81
|
+
expect(resolveSeekMax({ duration: 64.2, maxSeen: 3, position: 3 })).toBe(65)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('uses the largest position seen as a provisional max when the duration is unknown', () => {
|
|
85
|
+
expect(resolveSeekMax({ duration: undefined, maxSeen: 12, position: 12 })).toBe(12)
|
|
86
|
+
expect(resolveSeekMax({ duration: undefined, maxSeen: 0, position: 0 })).toBe(0)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('writes position and duration, or only the position without a duration', () => {
|
|
90
|
+
expect(formatPositionValueText({ template: '{position} of {duration}', position: 12, duration: 65 })).toBe('0:12 of 1:05')
|
|
91
|
+
expect(formatPositionValueText({ template: '{position} of {duration}', position: 12, duration: undefined })).toBe('0:12')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const PLAYBACK_RATES = [1, 1.5, 2] as const
|
|
2
|
+
export const SEEK_STEP_SECONDS = 5
|
|
3
|
+
|
|
4
|
+
const UNKNOWN_CLOCK = '--:--'
|
|
5
|
+
|
|
6
|
+
export function formatAudioClock(seconds: number | undefined): string {
|
|
7
|
+
if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return UNKNOWN_CLOCK
|
|
8
|
+
const wholeSeconds = Math.floor(seconds)
|
|
9
|
+
const remainder = String(wholeSeconds % 60).padStart(2, '0')
|
|
10
|
+
return `${Math.floor(wholeSeconds / 60)}:${remainder}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function nextRateIndex(index: number): number {
|
|
14
|
+
return (index + 1) % PLAYBACK_RATES.length
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatPlaybackRate(rate: number): string {
|
|
18
|
+
return `${rate}×`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveServerDurationSeconds(durationMs: number | undefined): number | undefined {
|
|
22
|
+
if (durationMs === undefined || !Number.isFinite(durationMs) || durationMs <= 0) return undefined
|
|
23
|
+
return durationMs / 1000
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveMediaDurationSeconds(duration: number): number | undefined {
|
|
27
|
+
return Number.isFinite(duration) && duration > 0 ? duration : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SeekKeyParams = {
|
|
31
|
+
readonly key: string
|
|
32
|
+
readonly position: number
|
|
33
|
+
readonly duration: number | undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clampPosition(position: number, duration: number | undefined): number {
|
|
37
|
+
const floored = Math.max(0, position)
|
|
38
|
+
return duration === undefined ? floored : Math.min(duration, floored)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The position a key moves the bar to, or undefined when the key is not ours to handle. */
|
|
42
|
+
export function resolveSeekKeyTarget({ key, position, duration }: SeekKeyParams): number | undefined {
|
|
43
|
+
if (key === 'ArrowRight' || key === 'ArrowUp') return clampPosition(position + SEEK_STEP_SECONDS, duration)
|
|
44
|
+
if (key === 'ArrowLeft' || key === 'ArrowDown') return clampPosition(position - SEEK_STEP_SECONDS, duration)
|
|
45
|
+
if (key === 'Home') return 0
|
|
46
|
+
if (key === 'End') return duration
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type SeekMaxParams = {
|
|
51
|
+
readonly duration: number | undefined
|
|
52
|
+
readonly maxSeen: number
|
|
53
|
+
readonly position: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Known duration wins; otherwise the largest position seen so far keeps the bar coherent while it grows. */
|
|
57
|
+
export function resolveSeekMax({ duration, maxSeen, position }: SeekMaxParams): number {
|
|
58
|
+
if (duration !== undefined) return Math.ceil(duration)
|
|
59
|
+
return Math.ceil(Math.max(maxSeen, position))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type PositionValueTextParams = {
|
|
63
|
+
readonly template: string
|
|
64
|
+
readonly position: number
|
|
65
|
+
readonly duration: number | undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatPositionValueText({ template, position, duration }: PositionValueTextParams): string {
|
|
69
|
+
const positionText = formatAudioClock(position)
|
|
70
|
+
if (duration === undefined) return positionText
|
|
71
|
+
return template.replace('{position}', positionText).replace('{duration}', formatAudioClock(duration))
|
|
72
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goldens of the participant thread (264, contract.md §2.4, §3): the empty composer must not change, and the
|
|
3
|
+
* audio player and transcription blocks are pinned byte for byte. The composer goldens live in
|
|
4
|
+
* `participantComposerAudioGolden.test.tsx`.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeAll, describe, expect, it } from 'bun:test'
|
|
10
|
+
import type { ComponentType } from 'react'
|
|
11
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
12
|
+
|
|
13
|
+
import { ParticipantConversations } from './ParticipantConversations'
|
|
14
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
15
|
+
import {
|
|
16
|
+
buildThreadProps,
|
|
17
|
+
renderGoldenScenarios,
|
|
18
|
+
type GoldenComponents,
|
|
19
|
+
} from './participantGoldenScenarios.test-helper'
|
|
20
|
+
import { buildMessage } from './participantFixtures.test-helper'
|
|
21
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
22
|
+
import { installFakeRecording } from './participantRecorderFakes.test-helper'
|
|
23
|
+
|
|
24
|
+
const FIXTURES_DIRECTORY = join(import.meta.dir, 'test-fixtures')
|
|
25
|
+
const PRE_EXISTING_GOLDENS = [
|
|
26
|
+
'thread-default',
|
|
27
|
+
'thread-plain',
|
|
28
|
+
'thread-avatars-initials',
|
|
29
|
+
'thread-tail-off',
|
|
30
|
+
'thread-error',
|
|
31
|
+
'thread-loading',
|
|
32
|
+
'thread-closed',
|
|
33
|
+
'conversations-list',
|
|
34
|
+
'conversations-selected',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
function readGolden(name: string): string {
|
|
38
|
+
return readFileSync(join(FIXTURES_DIRECTORY, `${name}.golden.html`), 'utf8')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const UntypedThread = ParticipantThread as unknown as ComponentType<Record<string, unknown>>
|
|
42
|
+
|
|
43
|
+
function renderThread(extra: Record<string, unknown>): string {
|
|
44
|
+
const props = { ...buildThreadProps(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS), ...extra }
|
|
45
|
+
return renderToStaticMarkup(<UntypedThread {...props} />)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('empty composer stays byte-identical to origin/main a7c5c85 (contract 2.4)', () => {
|
|
49
|
+
let restoreEnvironment: (() => void) | undefined
|
|
50
|
+
|
|
51
|
+
beforeAll(() => {
|
|
52
|
+
process.env.TZ = 'UTC'
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
restoreEnvironment?.()
|
|
57
|
+
restoreEnvironment = undefined
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('(a) without the audioRecording prop', () => {
|
|
61
|
+
expect(renderThread({ draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined } })).toBe(
|
|
62
|
+
readGolden('thread-empty-draft'),
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('(b) with audioRecording={{}} on the server render (no MediaRecorder)', () => {
|
|
67
|
+
const markup = renderThread({
|
|
68
|
+
draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined },
|
|
69
|
+
audioRecording: {},
|
|
70
|
+
})
|
|
71
|
+
expect(markup).toBe(readGolden('thread-empty-draft'))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('(c) with audioRecording={{}}, a fake MediaRecorder and channel portal (portal never records)', () => {
|
|
75
|
+
restoreEnvironment = installFakeRecording().restore
|
|
76
|
+
const markup = renderThread({
|
|
77
|
+
draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined },
|
|
78
|
+
audioRecording: {},
|
|
79
|
+
channel: 'portal',
|
|
80
|
+
})
|
|
81
|
+
expect(markup).toBe(readGolden('thread-empty-draft'))
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('the existing goldens carry no audio; the audio goldens document the player that replaced the native one', () => {
|
|
86
|
+
it('none of the 9 recorded goldens has an <audio> element', () => {
|
|
87
|
+
expect(PRE_EXISTING_GOLDENS).toHaveLength(9)
|
|
88
|
+
for (const name of PRE_EXISTING_GOLDENS) expect(readGolden(name).match(/<audio/g)?.length ?? 0).toBe(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('thread-audio-native was regrouped by 264 T1.5: same attachment without durationMs, now the player (no native controls)', () => {
|
|
92
|
+
const markup = readGolden('thread-audio-native')
|
|
93
|
+
expect(markup.match(/<audio/g)).toHaveLength(1)
|
|
94
|
+
expect(markup).not.toContain('controls')
|
|
95
|
+
expect(markup).not.toContain('cv-p-attachment__audio')
|
|
96
|
+
expect(markup).toContain('0:00 / --:--')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('the helper renders the new scenarios and the 9 existing ones', () => {
|
|
100
|
+
const rendered = renderGoldenScenarios({
|
|
101
|
+
ParticipantThread,
|
|
102
|
+
ParticipantConversations,
|
|
103
|
+
labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
|
|
104
|
+
} as unknown as GoldenComponents)
|
|
105
|
+
for (const name of [...PRE_EXISTING_GOLDENS, 'thread-empty-draft', 'thread-audio-native', 'thread-audio-player'])
|
|
106
|
+
expect(rendered[name]).toBe(readGolden(name))
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
function renderThreadGoldens(): Record<string, string> {
|
|
111
|
+
return renderGoldenScenarios({
|
|
112
|
+
ParticipantThread,
|
|
113
|
+
ParticipantConversations,
|
|
114
|
+
labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
|
|
115
|
+
} as unknown as GoldenComponents)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const AUDIO_ATTACHMENT = { id: 'att-1', kind: 'audio', filename: 'voice.webm', mimeType: 'audio/webm', sizeBytes: 2048, durationMs: 65_000 }
|
|
119
|
+
const GENERATED_AT = '2026-10-01T12:01:00.000Z'
|
|
120
|
+
|
|
121
|
+
function audioItems(transcription?: Record<string, unknown>): Record<string, unknown>[] {
|
|
122
|
+
const attachments = [{ ...AUDIO_ATTACHMENT, ...(transcription === undefined ? {} : { transcription }) }]
|
|
123
|
+
return [{ kind: 'server', message: buildMessage({ id: 'au', direction: 'inbound', text: '', attachments: attachments as never, createdAt: '2026-10-01T12:00:00.000Z' }) }]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('[T1.5] thread-audio-player: the custom player replaces <audio controls> (contract 3.1-3.2)', () => {
|
|
127
|
+
it('thread-audio-player is byte-identical to its golden', () => {
|
|
128
|
+
expect(renderThread({ items: audioItems() })).toBe(readGolden('thread-audio-player'))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders the contract 3.1 markup: group, toggle, range, time, rate and a preload="none" audio after which comes the file name', () => {
|
|
132
|
+
const markup = readGolden('thread-audio-player')
|
|
133
|
+
expect(markup).toContain('<div class="cv-p-audio" role="group" aria-label="Audio: voice.webm">')
|
|
134
|
+
expect(markup).toContain('type="range" class="cv-p-audio__seek"')
|
|
135
|
+
expect(markup).toContain('0:00 / 1:05')
|
|
136
|
+
expect(markup).toContain('<audio class="cv-p-audio__media" preload="none"></audio>')
|
|
137
|
+
expect(markup.indexOf('cv-p-audio__media')).toBeLessThan(markup.indexOf('cv-p-attachment__name'))
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('no other recorded golden changes: the 9 old ones and the empty draft stay without the player', () => {
|
|
141
|
+
for (const name of [...PRE_EXISTING_GOLDENS, 'thread-empty-draft']) expect(readGolden(name)).not.toContain('cv-p-audio')
|
|
142
|
+
expect(Object.keys(renderThreadGoldens()).sort()).toEqual(
|
|
143
|
+
[...PRE_EXISTING_GOLDENS, 'thread-empty-draft', 'thread-audio-native', 'thread-audio-player'].sort(),
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('[T1.6] thread-audio-transcription-*: the block under the player (contract 3.3)', () => {
|
|
149
|
+
const scenarios: ReadonlyArray<readonly [string, Record<string, unknown>]> = [
|
|
150
|
+
['pending', { status: 'pending' }],
|
|
151
|
+
['ready', { status: 'ready', text: 'Chego em dez minutos.', language: 'pt', engine: 'whisper-local', generatedAt: GENERATED_AT }],
|
|
152
|
+
['empty', { status: 'ready', text: '', generatedAt: GENERATED_AT }],
|
|
153
|
+
['too_long', { status: 'failed', reason: 'too_long', maxDurationMs: 300_000 }],
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
for (const [state, transcription] of scenarios) {
|
|
157
|
+
it(`thread-audio-transcription-${state} is byte-identical to its golden`, () => {
|
|
158
|
+
expect(renderThread({ items: audioItems(transcription) })).toBe(readGolden(`thread-audio-transcription-${state}`))
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
it('absent, disabled and plain failed transcriptions render only the player', () => {
|
|
163
|
+
const playerOnly = renderThread({ items: audioItems() })
|
|
164
|
+
for (const transcription of [{ status: 'disabled' }, { status: 'failed' }]) {
|
|
165
|
+
expect(renderThread({ items: audioItems(transcription) })).toBe(playerOnly)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_PARTICIPANT_AUDIO_LABELS, formatRecordingElapsedLabel, resolveParticipantAudioLabels } from './participantAudioLabels'
|
|
4
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
5
|
+
|
|
6
|
+
describe('participant audio labels', () => {
|
|
7
|
+
it('fills every missing key with the English default', () => {
|
|
8
|
+
expect(resolveParticipantAudioLabels(undefined)).toEqual(DEFAULT_PARTICIPANT_AUDIO_LABELS)
|
|
9
|
+
expect(resolveParticipantAudioLabels({ sendAudio: 'Enviar áudio' }).stopRecording).toBe('Stop recording')
|
|
10
|
+
expect(resolveParticipantAudioLabels({ sendAudio: 'Enviar áudio' }).sendAudio).toBe('Enviar áudio')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('is carried by the default map of the whole view', () => {
|
|
14
|
+
for (const key of Object.keys(DEFAULT_PARTICIPANT_AUDIO_LABELS) as (keyof typeof DEFAULT_PARTICIPANT_AUDIO_LABELS)[]) {
|
|
15
|
+
expect(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS[key]).toBe(DEFAULT_PARTICIPANT_AUDIO_LABELS[key])
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('fills the elapsed and limit placeholders', () => {
|
|
20
|
+
expect(formatRecordingElapsedLabel('{elapsed} of {limit}', '0:12', '5:00')).toBe('0:12 of 5:00')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
2
|
+
|
|
3
|
+
export type ParticipantAudioLabels = {
|
|
4
|
+
readonly recordAudio: string
|
|
5
|
+
readonly recordingInProgress: string
|
|
6
|
+
readonly stopRecording: string
|
|
7
|
+
/** Uses the `{elapsed}` and `{limit}` placeholders. */
|
|
8
|
+
readonly recordingElapsed: string
|
|
9
|
+
readonly reviewAudio: string
|
|
10
|
+
readonly sendAudio: string
|
|
11
|
+
readonly discardAudio: string
|
|
12
|
+
readonly microphoneDenied: string
|
|
13
|
+
readonly recordingUnsupported: string
|
|
14
|
+
readonly audioEmpty: string
|
|
15
|
+
readonly audioTooLarge: string
|
|
16
|
+
readonly audioPlayer: string
|
|
17
|
+
readonly playAudio: string
|
|
18
|
+
readonly pauseAudio: string
|
|
19
|
+
readonly audioPosition: string
|
|
20
|
+
/** Uses the `{position}` and `{duration}` placeholders; without a duration only the position is spoken. */
|
|
21
|
+
readonly audioPositionValue: string
|
|
22
|
+
/** Uses the `{rate}` placeholder. */
|
|
23
|
+
readonly playbackRate: string
|
|
24
|
+
readonly audioUnavailable: string
|
|
25
|
+
readonly transcribing: string
|
|
26
|
+
/** Spoken by the thread's live region when a transcription finishes. */
|
|
27
|
+
readonly transcriptionReady: string
|
|
28
|
+
readonly machineTranscription: string
|
|
29
|
+
readonly emptyTranscription: string
|
|
30
|
+
/** Uses the `{minutes}` placeholder, filled from the limit the server applied. */
|
|
31
|
+
readonly transcriptionTooLong: string
|
|
32
|
+
readonly showFullTranscription: string
|
|
33
|
+
readonly collapseTranscription: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_PARTICIPANT_AUDIO_LABELS: ParticipantAudioLabels = {
|
|
37
|
+
recordAudio: 'Record audio',
|
|
38
|
+
recordingInProgress: 'Recording',
|
|
39
|
+
stopRecording: 'Stop recording',
|
|
40
|
+
recordingElapsed: '{elapsed} of {limit}',
|
|
41
|
+
reviewAudio: 'Listen before sending',
|
|
42
|
+
sendAudio: 'Send audio',
|
|
43
|
+
discardAudio: 'Discard audio',
|
|
44
|
+
microphoneDenied: 'No permission to use the microphone.',
|
|
45
|
+
recordingUnsupported: 'This device cannot record audio.',
|
|
46
|
+
audioEmpty: 'Nothing was captured by the microphone.',
|
|
47
|
+
audioTooLarge: 'Audio too large to send.',
|
|
48
|
+
audioPlayer: 'Audio',
|
|
49
|
+
playAudio: 'Play audio',
|
|
50
|
+
pauseAudio: 'Pause audio',
|
|
51
|
+
audioPosition: 'Audio position',
|
|
52
|
+
audioPositionValue: '{position} of {duration}',
|
|
53
|
+
playbackRate: 'Playback speed {rate}',
|
|
54
|
+
audioUnavailable: 'Audio unavailable',
|
|
55
|
+
transcribing: 'Transcribing…',
|
|
56
|
+
transcriptionReady: 'Transcription ready',
|
|
57
|
+
machineTranscription: 'Transcription generated by machine',
|
|
58
|
+
emptyTranscription: 'Nothing was said in the audio',
|
|
59
|
+
transcriptionTooLong: 'Transcription only for audios shorter than {minutes} minutes',
|
|
60
|
+
showFullTranscription: 'Show all',
|
|
61
|
+
collapseTranscription: 'Show less',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveParticipantAudioLabels(
|
|
65
|
+
labels: Partial<ParticipantAudioLabels> | ParticipantConversationsLabels | undefined,
|
|
66
|
+
): ParticipantAudioLabels {
|
|
67
|
+
const resolved = { ...DEFAULT_PARTICIPANT_AUDIO_LABELS }
|
|
68
|
+
for (const key of Object.keys(resolved) as (keyof ParticipantAudioLabels)[]) {
|
|
69
|
+
resolved[key] = labels?.[key] ?? DEFAULT_PARTICIPANT_AUDIO_LABELS[key]
|
|
70
|
+
}
|
|
71
|
+
return resolved
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatRecordingElapsedLabel(template: string, elapsed: string, limit: string): string {
|
|
75
|
+
return template.replace('{elapsed}', elapsed).replace('{limit}', limit)
|
|
76
|
+
}
|