@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
|
@@ -1 +1 @@
|
|
|
1
|
-
<div class="cv-p cv-p-thread" aria-busy="true"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-loading"><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-sr-only">Loading…</span></div><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
|
|
1
|
+
<div class="cv-p cv-p-thread" aria-busy="true"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back" data-cv-tooltip="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1" data-cv-tooltip="Copy protocol"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app" data-cv-tooltip="App"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp" data-cv-tooltip="WhatsApp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-loading"><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-sr-only">Loading…</span></div><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach" data-cv-tooltip="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send" data-cv-tooltip="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div></div></div><div class="cv-p-thread__live" role="status" aria-live="polite"></div><form class="cv-p-composer"><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
|
|
1
|
+
<div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back" data-cv-tooltip="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1" data-cv-tooltip="Copy protocol"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app" data-cv-tooltip="App"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp" data-cv-tooltip="WhatsApp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com" data-cv-tooltip="Copy"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent" data-cv-tooltip="Sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div></div></div><div class="cv-p-thread__live" role="status" aria-live="polite"></div><form class="cv-p-composer"><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach" data-cv-tooltip="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send" data-cv-tooltip="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble cv-p-bubble--no-tail"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><
|
|
1
|
+
<div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back" data-cv-tooltip="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1" data-cv-tooltip="Copy protocol"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app" data-cv-tooltip="App"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp" data-cv-tooltip="WhatsApp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com" data-cv-tooltip="Copy"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent" data-cv-tooltip="Sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble cv-p-bubble--no-tail"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered" data-cv-tooltip="Delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--failed cv-p-bubble--no-tail"><button type="button" class="cv-p-failed-retry" aria-label="Failed — tap to retry" aria-describedby="_R_1kd_" data-cv-tooltip="Failed — tap to retry"><span class="cv-p-failed-retry__icon"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><path d="M13 8a5 5 0 1 1-1.6-3.7M13 2.5v3h-3" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg></span></button><span class="cv-p-sr-only">Me</span><div class="cv-p-text" id="_R_1kd_">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--failed" data-cv-tooltip="Failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span class="cv-p-sr-only">Failed</span></span><span class="cv-p-failed-menu"><button type="button" id="_R_erkd_" class="cv-p-failed-menu__trigger" aria-haspopup="menu" aria-expanded="false" aria-label="Message options" data-cv-tooltip="Message options"><span aria-hidden="true">⋯</span></button></span></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message" data-cv-tooltip="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach" data-cv-tooltip="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send" data-cv-tooltip="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState, type Dispatch, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveFailedMenuPlacement,
|
|
5
|
+
type FailedMenuAction,
|
|
6
|
+
type FailedMenuPlacement,
|
|
7
|
+
type FailedMenuState,
|
|
8
|
+
} from './participantFailedMenuState'
|
|
9
|
+
|
|
10
|
+
const SCROLLER_SELECTOR = '.cv-p-thread__scroll'
|
|
11
|
+
|
|
12
|
+
export function useFailedMenuInitialFocus(state: FailedMenuState, menuRef: RefObject<HTMLDivElement | null>): void {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!state.isOpen) return
|
|
15
|
+
const items = Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])
|
|
16
|
+
const target = state.initialFocus === 'last' ? items[items.length - 1] : items[0]
|
|
17
|
+
target?.focus()
|
|
18
|
+
}, [state, menuRef])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useFailedMenuOutsideClose(
|
|
22
|
+
isOpen: boolean,
|
|
23
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
24
|
+
dispatch: Dispatch<FailedMenuAction>,
|
|
25
|
+
): void {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!isOpen) return
|
|
28
|
+
function handlePointerDown(event: PointerEvent): void {
|
|
29
|
+
if (event.target instanceof Node && containerRef.current?.contains(event.target)) return
|
|
30
|
+
dispatch({ type: 'close' })
|
|
31
|
+
}
|
|
32
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
33
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
34
|
+
}, [isOpen, containerRef, dispatch])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type PlacementRefs = {
|
|
38
|
+
readonly triggerRef: RefObject<HTMLElement | null>
|
|
39
|
+
readonly menuRef: RefObject<HTMLElement | null>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useFailedMenuPlacement(isOpen: boolean, { triggerRef, menuRef }: PlacementRefs): FailedMenuPlacement {
|
|
43
|
+
const [placement, setPlacement] = useState<FailedMenuPlacement>('above')
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
const trigger = triggerRef.current
|
|
46
|
+
const menu = menuRef.current
|
|
47
|
+
if (!isOpen || !trigger || !menu) return
|
|
48
|
+
const anchor = trigger.closest('.cv-p-bubble') ?? trigger
|
|
49
|
+
const scroller = trigger.closest(SCROLLER_SELECTOR)
|
|
50
|
+
setPlacement(
|
|
51
|
+
resolveFailedMenuPlacement({
|
|
52
|
+
anchorTop: anchor.getBoundingClientRect().top,
|
|
53
|
+
scrollerTop: scroller?.getBoundingClientRect().top ?? 0,
|
|
54
|
+
menuHeight: menu.getBoundingClientRect().height,
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
}, [isOpen, triggerRef, menuRef])
|
|
58
|
+
return placement
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SyntheticEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
import { claimAudioPlayback, releaseAudioPlayback } from './participantAudioCoordinator'
|
|
4
|
+
import { resolveMediaDurationSeconds } from './participantAudioFormat'
|
|
5
|
+
import type { PlayerContext } from './participantAudioPlayerState'
|
|
6
|
+
import { useParticipantAudioSourceRetry } from './useParticipantAudioSourceRetry'
|
|
7
|
+
|
|
8
|
+
export type UseParticipantAudioMediaEventsParams = {
|
|
9
|
+
readonly context: PlayerContext
|
|
10
|
+
readonly url: string | undefined
|
|
11
|
+
readonly onSourceError: (() => Promise<string | undefined>) | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Media element events of the player. */
|
|
15
|
+
export function useParticipantAudioMediaEvents({ context, url, onSourceError }: UseParticipantAudioMediaEventsParams) {
|
|
16
|
+
const { handleRef, state, update } = context
|
|
17
|
+
const { handleError, allowRetry } = useParticipantAudioSourceRetry({ context, url, onSourceError })
|
|
18
|
+
|
|
19
|
+
function handleStop(): void {
|
|
20
|
+
releaseAudioPlayback(handleRef.current)
|
|
21
|
+
update({ isPlaying: false })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function handleDuration(event: SyntheticEvent<HTMLAudioElement>): void {
|
|
25
|
+
update({ mediaDuration: resolveMediaDurationSeconds(event.currentTarget.duration) })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
onPlay: () => {
|
|
30
|
+
claimAudioPlayback(handleRef.current)
|
|
31
|
+
update({ isPlaying: true })
|
|
32
|
+
},
|
|
33
|
+
onPause: handleStop,
|
|
34
|
+
onEnded: handleStop,
|
|
35
|
+
onTimeUpdate: (event: SyntheticEvent<HTMLAudioElement>) => {
|
|
36
|
+
const position = event.currentTarget.currentTime
|
|
37
|
+
update({ position, maxSeen: Math.max(state.maxSeen, position) })
|
|
38
|
+
},
|
|
39
|
+
onLoadedMetadata: (event: SyntheticEvent<HTMLAudioElement>) => {
|
|
40
|
+
allowRetry()
|
|
41
|
+
handleDuration(event)
|
|
42
|
+
},
|
|
43
|
+
onDurationChange: handleDuration,
|
|
44
|
+
onError: handleError,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type Ref } from 'react'
|
|
2
|
+
|
|
3
|
+
import { releaseAudioPlayback, type AudioPlaybackHandle } from './participantAudioCoordinator'
|
|
4
|
+
import { PLAYBACK_RATES, resolveServerDurationSeconds } from './participantAudioFormat'
|
|
5
|
+
import { INITIAL_PLAYER_STATE, type PlayerState } from './participantAudioPlayerState'
|
|
6
|
+
import { useParticipantAudioMediaEvents } from './useParticipantAudioMediaEvents'
|
|
7
|
+
import { useParticipantAudioPlayerActions } from './useParticipantAudioPlayerActions'
|
|
8
|
+
|
|
9
|
+
export type UseParticipantAudioPlayerParams = {
|
|
10
|
+
readonly url: string | undefined
|
|
11
|
+
readonly durationMs: number | undefined
|
|
12
|
+
/** Fetches a fresh URL after a media error; without it the first error is final. */
|
|
13
|
+
readonly onSourceError?: () => Promise<string | undefined>
|
|
14
|
+
readonly mediaRef?: Ref<HTMLAudioElement>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assignRef(target: Ref<HTMLAudioElement> | undefined, element: HTMLAudioElement | null): void {
|
|
18
|
+
if (typeof target === 'function') target(element)
|
|
19
|
+
else if (target) (target as { current: HTMLAudioElement | null }).current = element
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useParticipantAudioPlayer({ url, durationMs, onSourceError, mediaRef }: UseParticipantAudioPlayerParams) {
|
|
23
|
+
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
24
|
+
const handleRef = useRef<AudioPlaybackHandle>({ pause: () => audioRef.current?.pause() })
|
|
25
|
+
const [state, setState] = useState<PlayerState>(INITIAL_PLAYER_STATE)
|
|
26
|
+
const update = useCallback((patch: Partial<PlayerState>) => setState((previous) => ({ ...previous, ...patch })), [])
|
|
27
|
+
const mediaEvents = useParticipantAudioMediaEvents({ context: { audioRef, handleRef, state, update }, url, onSourceError })
|
|
28
|
+
const duration = resolveServerDurationSeconds(durationMs) ?? state.mediaDuration
|
|
29
|
+
|
|
30
|
+
const setMedia = useCallback(
|
|
31
|
+
(element: HTMLAudioElement | null) => {
|
|
32
|
+
audioRef.current = element
|
|
33
|
+
assignRef(mediaRef, element)
|
|
34
|
+
},
|
|
35
|
+
[mediaRef],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const handle = handleRef.current
|
|
40
|
+
return () => releaseAudioPlayback(handle)
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const actions = useParticipantAudioPlayerActions({ context: { audioRef, handleRef, state, update }, url, duration })
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
state,
|
|
47
|
+
duration,
|
|
48
|
+
rate: PLAYBACK_RATES[state.rateIndex] ?? 1,
|
|
49
|
+
mediaProps: { ...mediaEvents, ref: setMedia },
|
|
50
|
+
...actions,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { KeyboardEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
import { claimAudioPlayback } from './participantAudioCoordinator'
|
|
4
|
+
import { nextRateIndex, resolveSeekKeyTarget } from './participantAudioFormat'
|
|
5
|
+
import { applyPlaybackRate, type PlayerContext } from './participantAudioPlayerState'
|
|
6
|
+
|
|
7
|
+
export type UseParticipantAudioPlayerActionsParams = {
|
|
8
|
+
readonly context: PlayerContext
|
|
9
|
+
readonly url: string | undefined
|
|
10
|
+
readonly duration: number | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useParticipantAudioPlayerActions({ context, url, duration }: UseParticipantAudioPlayerActionsParams) {
|
|
14
|
+
const { audioRef, handleRef, state, update } = context
|
|
15
|
+
|
|
16
|
+
function handleToggle(): void {
|
|
17
|
+
const audio = audioRef.current
|
|
18
|
+
if (!audio || !url) return
|
|
19
|
+
if (!audio.paused) return audio.pause()
|
|
20
|
+
claimAudioPlayback(handleRef.current)
|
|
21
|
+
applyPlaybackRate(audio, state.rateIndex)
|
|
22
|
+
Promise.resolve(audio.play()).catch(() => update({ isPlaying: false }))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function seekTo(seconds: number): void {
|
|
26
|
+
const audio = audioRef.current
|
|
27
|
+
if (!audio || !url) return
|
|
28
|
+
audio.currentTime = seconds
|
|
29
|
+
update({ position: seconds, maxSeen: Math.max(state.maxSeen, seconds) })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
|
|
33
|
+
const position = audioRef.current?.currentTime ?? state.position
|
|
34
|
+
const target = resolveSeekKeyTarget({ key: event.key, position, duration })
|
|
35
|
+
if (target === undefined) return
|
|
36
|
+
event.preventDefault()
|
|
37
|
+
seekTo(target)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handleRateClick(): void {
|
|
41
|
+
const rateIndex = nextRateIndex(state.rateIndex)
|
|
42
|
+
if (audioRef.current) applyPlaybackRate(audioRef.current, rateIndex)
|
|
43
|
+
update({ rateIndex })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { handleToggle, seekTo, handleKeyDown, handleRateClick }
|
|
47
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useCallback, useMemo, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { resolveRecordingFormat, type RecordingFormat } from '../audioRecorderFormat'
|
|
4
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
5
|
+
import { buildRecordedFile, openCaptureSession, stopStream } from './participantAudioCapture'
|
|
6
|
+
import {
|
|
7
|
+
canStartRecording,
|
|
8
|
+
classifyCapture,
|
|
9
|
+
classifyMicrophoneError,
|
|
10
|
+
resolveInterruptionAction,
|
|
11
|
+
resolveRecordingLimits,
|
|
12
|
+
type RecorderStatus,
|
|
13
|
+
type RecordingLimits,
|
|
14
|
+
} from './participantAudioRecorderState'
|
|
15
|
+
import { useRecorderCore, type RecorderCore, type RecordedAudio } from './useParticipantAudioRecorderCore'
|
|
16
|
+
import { useRecorderInterruptionListeners, useRecorderLifecycle, useRecordingClock } from './useParticipantAudioRecorderEffects'
|
|
17
|
+
|
|
18
|
+
export type { RecordedAudio }
|
|
19
|
+
|
|
20
|
+
export type UseParticipantAudioRecorderParams = {
|
|
21
|
+
readonly options?: ParticipantAudioRecordingOptions
|
|
22
|
+
readonly channelMaxBytes?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ParticipantAudioRecorder = {
|
|
26
|
+
readonly status: RecorderStatus
|
|
27
|
+
readonly elapsedMs: number
|
|
28
|
+
readonly limits: RecordingLimits
|
|
29
|
+
readonly recorded: RecordedAudio | undefined
|
|
30
|
+
readonly audioRef: RefObject<HTMLAudioElement | null>
|
|
31
|
+
readonly start: () => Promise<void>
|
|
32
|
+
readonly stop: () => void
|
|
33
|
+
readonly discard: () => void
|
|
34
|
+
readonly confirm: () => File | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function useRecorderFinalize(core: RecorderCore): (blob: Blob, durationMs: number) => void {
|
|
38
|
+
const { statusRef, sessionRef, recordedRef, limitsRef, setRecorded, moveTo, releaseSession } = core
|
|
39
|
+
return useCallback(
|
|
40
|
+
(blob: Blob, durationMs: number): void => {
|
|
41
|
+
const format = sessionRef.current?.format
|
|
42
|
+
if (statusRef.current !== 'recording' || !format) return
|
|
43
|
+
releaseSession()
|
|
44
|
+
const event = classifyCapture({ sizeBytes: blob.size, durationMs }, limitsRef.current)
|
|
45
|
+
if (event === 'capturedReview') {
|
|
46
|
+
const file = buildRecordedFile(blob, format)
|
|
47
|
+
recordedRef.current = { file, objectUrl: URL.createObjectURL(file), durationMs }
|
|
48
|
+
setRecorded(recordedRef.current)
|
|
49
|
+
}
|
|
50
|
+
moveTo(event)
|
|
51
|
+
},
|
|
52
|
+
[statusRef, sessionRef, recordedRef, limitsRef, setRecorded, moveTo, releaseSession],
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function useRecorderInterruption(core: RecorderCore): () => void {
|
|
57
|
+
const { statusRef, sessionRef, tokenRef, audioRef, moveTo } = core
|
|
58
|
+
return useCallback((): void => {
|
|
59
|
+
const action = resolveInterruptionAction(statusRef.current)
|
|
60
|
+
if (action === 'stop') sessionRef.current?.requestStop()
|
|
61
|
+
if (action === 'pause') audioRef.current?.pause()
|
|
62
|
+
if (action === 'cancel') {
|
|
63
|
+
tokenRef.current += 1
|
|
64
|
+
moveTo('cancel')
|
|
65
|
+
}
|
|
66
|
+
}, [statusRef, sessionRef, tokenRef, audioRef, moveTo])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type RecorderStartParams = {
|
|
70
|
+
readonly onInterrupt: () => void
|
|
71
|
+
readonly onStopped: (blob: Blob, durationMs: number) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function useRecorderStart(core: RecorderCore, { onInterrupt, onStopped }: RecorderStartParams): () => Promise<void> {
|
|
75
|
+
const { statusRef, tokenRef, sessionRef, setElapsedMs, moveTo } = core
|
|
76
|
+
const beginRecording = useCallback(
|
|
77
|
+
(stream: MediaStream, format: RecordingFormat): void => {
|
|
78
|
+
try {
|
|
79
|
+
sessionRef.current = openCaptureSession({ stream, format, onInterrupt, onStopped })
|
|
80
|
+
} catch {
|
|
81
|
+
stopStream(stream)
|
|
82
|
+
moveTo('microphoneUnavailable')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
setElapsedMs(0)
|
|
86
|
+
moveTo('granted')
|
|
87
|
+
},
|
|
88
|
+
[sessionRef, setElapsedMs, onInterrupt, onStopped, moveTo],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return useCallback(async (): Promise<void> => {
|
|
92
|
+
if (!canStartRecording(statusRef.current)) return
|
|
93
|
+
const token = (tokenRef.current += 1)
|
|
94
|
+
moveTo('request')
|
|
95
|
+
const format = resolveRecordingFormat()
|
|
96
|
+
if (!format || !navigator.mediaDevices?.getUserMedia) return moveTo('microphoneUnavailable')
|
|
97
|
+
let stream: MediaStream
|
|
98
|
+
try {
|
|
99
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (token === tokenRef.current) moveTo(classifyMicrophoneError(error))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (token !== tokenRef.current) return stopStream(stream)
|
|
105
|
+
beginRecording(stream, format)
|
|
106
|
+
}, [statusRef, tokenRef, moveTo, beginRecording])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type RecorderActions = Pick<ParticipantAudioRecorder, 'stop' | 'discard' | 'confirm'> & { readonly teardown: () => void }
|
|
110
|
+
|
|
111
|
+
function useRecorderActions(core: RecorderCore): RecorderActions {
|
|
112
|
+
const { statusRef, tokenRef, sessionRef, recordedRef, moveTo, releaseSession, releaseRecorded } = core
|
|
113
|
+
const stop = useCallback((): void => sessionRef.current?.requestStop(), [sessionRef])
|
|
114
|
+
const discard = useCallback((): void => {
|
|
115
|
+
tokenRef.current += 1
|
|
116
|
+
releaseSession()
|
|
117
|
+
releaseRecorded()
|
|
118
|
+
moveTo('cancel')
|
|
119
|
+
}, [tokenRef, moveTo, releaseRecorded, releaseSession])
|
|
120
|
+
const confirm = useCallback((): File | undefined => {
|
|
121
|
+
if (statusRef.current !== 'review') return undefined
|
|
122
|
+
const file = recordedRef.current?.file
|
|
123
|
+
releaseRecorded()
|
|
124
|
+
moveTo('cancel')
|
|
125
|
+
return file
|
|
126
|
+
}, [statusRef, recordedRef, moveTo, releaseRecorded])
|
|
127
|
+
const teardown = useCallback((): void => {
|
|
128
|
+
tokenRef.current += 1
|
|
129
|
+
releaseSession()
|
|
130
|
+
if (recordedRef.current) URL.revokeObjectURL(recordedRef.current.objectUrl)
|
|
131
|
+
recordedRef.current = undefined
|
|
132
|
+
}, [tokenRef, recordedRef, releaseSession])
|
|
133
|
+
return { stop, discard, confirm, teardown }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function useParticipantAudioRecorder({ options, channelMaxBytes }: UseParticipantAudioRecorderParams = {}): ParticipantAudioRecorder {
|
|
137
|
+
const limits = useMemo(
|
|
138
|
+
() => resolveRecordingLimits({ options, channelMaxBytes }),
|
|
139
|
+
[options?.maxDurationMs, options?.maxSizeBytes, channelMaxBytes],
|
|
140
|
+
)
|
|
141
|
+
const core = useRecorderCore(limits)
|
|
142
|
+
const onInterrupt = useRecorderInterruption(core)
|
|
143
|
+
const onStopped = useRecorderFinalize(core)
|
|
144
|
+
const start = useRecorderStart(core, { onInterrupt, onStopped })
|
|
145
|
+
const { stop, discard, confirm, teardown } = useRecorderActions(core)
|
|
146
|
+
|
|
147
|
+
useRecorderInterruptionListeners(onInterrupt)
|
|
148
|
+
useRecordingClock({ status: core.status, maxDurationMs: limits.maxDurationMs, sessionRef: core.sessionRef, setElapsedMs: core.setElapsedMs, stop })
|
|
149
|
+
useRecorderLifecycle({ status: core.status, onCaptureChange: options?.onCaptureChange, teardown })
|
|
150
|
+
|
|
151
|
+
const { status, elapsedMs, recorded, audioRef } = core
|
|
152
|
+
return { status, elapsedMs, limits, recorded, audioRef, start, stop, discard, confirm }
|
|
153
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { CaptureSession } from './participantAudioCapture'
|
|
4
|
+
import { reduceRecorderStatus, type RecorderEvent, type RecorderStatus, type RecordingLimits } from './participantAudioRecorderState'
|
|
5
|
+
|
|
6
|
+
export type RecordedAudio = { readonly file: File; readonly objectUrl: string; readonly durationMs: number }
|
|
7
|
+
|
|
8
|
+
export type RecorderCore = {
|
|
9
|
+
readonly status: RecorderStatus
|
|
10
|
+
readonly elapsedMs: number
|
|
11
|
+
readonly setElapsedMs: Dispatch<SetStateAction<number>>
|
|
12
|
+
readonly recorded: RecordedAudio | undefined
|
|
13
|
+
readonly setRecorded: (recorded: RecordedAudio | undefined) => void
|
|
14
|
+
readonly limitsRef: RefObject<RecordingLimits>
|
|
15
|
+
readonly statusRef: RefObject<RecorderStatus>
|
|
16
|
+
readonly tokenRef: RefObject<number>
|
|
17
|
+
readonly sessionRef: RefObject<CaptureSession | undefined>
|
|
18
|
+
readonly recordedRef: RefObject<RecordedAudio | undefined>
|
|
19
|
+
readonly audioRef: RefObject<HTMLAudioElement | null>
|
|
20
|
+
readonly moveTo: (event: RecorderEvent) => void
|
|
21
|
+
readonly releaseSession: () => void
|
|
22
|
+
readonly releaseRecorded: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useRecorderCore(limits: RecordingLimits): RecorderCore {
|
|
26
|
+
const [status, setStatus] = useState<RecorderStatus>('idle')
|
|
27
|
+
const [elapsedMs, setElapsedMs] = useState(0)
|
|
28
|
+
const [recorded, setRecorded] = useState<RecordedAudio | undefined>()
|
|
29
|
+
const statusRef = useRef<RecorderStatus>('idle')
|
|
30
|
+
const limitsRef = useRef(limits)
|
|
31
|
+
const tokenRef = useRef(0)
|
|
32
|
+
const sessionRef = useRef<CaptureSession | undefined>(undefined)
|
|
33
|
+
const recordedRef = useRef<RecordedAudio | undefined>(undefined)
|
|
34
|
+
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
35
|
+
limitsRef.current = limits
|
|
36
|
+
|
|
37
|
+
const moveTo = useCallback((event: RecorderEvent): void => {
|
|
38
|
+
statusRef.current = reduceRecorderStatus(statusRef.current, event)
|
|
39
|
+
setStatus(statusRef.current)
|
|
40
|
+
}, [])
|
|
41
|
+
const releaseSession = useCallback((): void => {
|
|
42
|
+
sessionRef.current?.release()
|
|
43
|
+
sessionRef.current = undefined
|
|
44
|
+
}, [])
|
|
45
|
+
const releaseRecorded = useCallback((): void => {
|
|
46
|
+
if (recordedRef.current) URL.revokeObjectURL(recordedRef.current.objectUrl)
|
|
47
|
+
recordedRef.current = undefined
|
|
48
|
+
setRecorded(undefined)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
status, elapsedMs, setElapsedMs, recorded, setRecorded, limitsRef, statusRef, tokenRef,
|
|
53
|
+
sessionRef, recordedRef, audioRef, moveTo, releaseSession, releaseRecorded,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { CaptureSession } from './participantAudioCapture'
|
|
4
|
+
import type { RecorderStatus } from './participantAudioRecorderState'
|
|
5
|
+
|
|
6
|
+
const ELAPSED_TICK_MS = 250
|
|
7
|
+
|
|
8
|
+
export function useRecorderInterruptionListeners(onInterruption: () => void): void {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const handleVisibilityChange = (): void => {
|
|
11
|
+
if (document.visibilityState === 'hidden') onInterruption()
|
|
12
|
+
}
|
|
13
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
14
|
+
window.addEventListener('pagehide', onInterruption)
|
|
15
|
+
return () => {
|
|
16
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
17
|
+
window.removeEventListener('pagehide', onInterruption)
|
|
18
|
+
}
|
|
19
|
+
}, [onInterruption])
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type RecordingClockParams = {
|
|
23
|
+
readonly status: RecorderStatus
|
|
24
|
+
readonly maxDurationMs: number
|
|
25
|
+
readonly sessionRef: MutableRefObject<CaptureSession | undefined>
|
|
26
|
+
readonly setElapsedMs: Dispatch<SetStateAction<number>>
|
|
27
|
+
readonly stop: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useRecordingClock({ status, maxDurationMs, sessionRef, setElapsedMs, stop }: RecordingClockParams): void {
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (status !== 'recording') return undefined
|
|
33
|
+
const startedAt = sessionRef.current?.startedAt ?? Date.now()
|
|
34
|
+
const tick = setInterval(() => setElapsedMs(Date.now() - startedAt), ELAPSED_TICK_MS)
|
|
35
|
+
const ceiling = setTimeout(stop, maxDurationMs)
|
|
36
|
+
return () => {
|
|
37
|
+
clearInterval(tick)
|
|
38
|
+
clearTimeout(ceiling)
|
|
39
|
+
}
|
|
40
|
+
}, [status, maxDurationMs, sessionRef, setElapsedMs, stop])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RecorderLifecycleParams = {
|
|
44
|
+
readonly status: RecorderStatus
|
|
45
|
+
readonly onCaptureChange: ((isCapturing: boolean) => void) | undefined
|
|
46
|
+
readonly teardown: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useRecorderLifecycle({ status, onCaptureChange, teardown }: RecorderLifecycleParams): void {
|
|
50
|
+
const isCapturing = status === 'requesting' || status === 'recording' || status === 'review'
|
|
51
|
+
const reportedRef = useRef(false)
|
|
52
|
+
const onCaptureChangeRef = useRef(onCaptureChange)
|
|
53
|
+
onCaptureChangeRef.current = onCaptureChange
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (reportedRef.current === isCapturing) return
|
|
57
|
+
reportedRef.current = isCapturing
|
|
58
|
+
onCaptureChangeRef.current?.(isCapturing)
|
|
59
|
+
}, [isCapturing])
|
|
60
|
+
|
|
61
|
+
useEffect(
|
|
62
|
+
() => () => {
|
|
63
|
+
teardown()
|
|
64
|
+
if (!reportedRef.current) return
|
|
65
|
+
reportedRef.current = false
|
|
66
|
+
onCaptureChangeRef.current?.(false)
|
|
67
|
+
},
|
|
68
|
+
[teardown],
|
|
69
|
+
)
|
|
70
|
+
}
|