@adatechnology/conversations-ui 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +106 -0
  2. package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
  3. package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
  4. package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
  5. package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
  6. package/dist/chunk-ZSOD5AQT.js +19 -0
  7. package/dist/flows/index.js +4 -2
  8. package/dist/index.d.ts +10 -3
  9. package/dist/index.js +8 -6
  10. package/dist/participant/index.d.ts +54 -3
  11. package/dist/participant/index.js +1722 -418
  12. package/dist/preview/index.d.ts +2 -2
  13. package/dist/preview/index.js +2 -2
  14. package/dist/styles.css +281 -23
  15. package/package.json +3 -2
  16. package/src/AudioRecorderButton.tsx +4 -28
  17. package/src/Tooltip.tsx +5 -12
  18. package/src/TooltipDom.test.tsx +78 -0
  19. package/src/audioRecorderFormat.ts +27 -0
  20. package/src/domHarness.test-helper.ts +53 -0
  21. package/src/participant/ConversationThread.tsx +4 -0
  22. package/src/participant/ParticipantAttachmentItem.tsx +7 -10
  23. package/src/participant/ParticipantAudioAttachment.tsx +34 -0
  24. package/src/participant/ParticipantAudioPlayer.tsx +68 -0
  25. package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
  26. package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
  27. package/src/participant/ParticipantAudioTranscription.tsx +90 -0
  28. package/src/participant/ParticipantBackButton.tsx +1 -1
  29. package/src/participant/ParticipantBubbleBody.tsx +54 -0
  30. package/src/participant/ParticipantBubbleStatus.tsx +22 -28
  31. package/src/participant/ParticipantChannelBadges.tsx +10 -2
  32. package/src/participant/ParticipantComposer.tsx +51 -16
  33. package/src/participant/ParticipantComposerAudio.tsx +95 -0
  34. package/src/participant/ParticipantComposerControls.tsx +2 -0
  35. package/src/participant/ParticipantConversations.tsx +3 -0
  36. package/src/participant/ParticipantCopyableToken.tsx +1 -0
  37. package/src/participant/ParticipantFailedMenu.tsx +40 -0
  38. package/src/participant/ParticipantFailedMenuList.tsx +41 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
  40. package/src/participant/ParticipantMessageBubble.tsx +20 -52
  41. package/src/participant/ParticipantMessageText.test.tsx +1 -1
  42. package/src/participant/ParticipantMessageText.tsx +4 -2
  43. package/src/participant/ParticipantProtocolBadge.tsx +1 -0
  44. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  45. package/src/participant/ParticipantScrollToLatest.tsx +1 -1
  46. package/src/participant/ParticipantThread.tsx +30 -12
  47. package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
  48. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  49. package/src/participant/failedMenuKeyboard.ts +29 -0
  50. package/src/participant/index.ts +3 -0
  51. package/src/participant/participantAudio.types.ts +10 -0
  52. package/src/participant/participantAudioCapture.test.ts +15 -0
  53. package/src/participant/participantAudioCapture.ts +85 -0
  54. package/src/participant/participantAudioCoordinator.ts +13 -0
  55. package/src/participant/participantAudioFormat.test.ts +93 -0
  56. package/src/participant/participantAudioFormat.ts +72 -0
  57. package/src/participant/participantAudioGolden.test.tsx +168 -0
  58. package/src/participant/participantAudioLabels.test.ts +22 -0
  59. package/src/participant/participantAudioLabels.ts +76 -0
  60. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  61. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  62. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  63. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  64. package/src/participant/participantAudioPlayerState.ts +35 -0
  65. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  66. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  67. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  68. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  69. package/src/participant/participantAudioRecorderState.ts +93 -0
  70. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  71. package/src/participant/participantAudioSupport.ts +36 -0
  72. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  73. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  74. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  75. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  76. package/src/participant/participantFailedMenuDom.test.tsx +154 -0
  77. package/src/participant/participantFailedMenuState.test.ts +61 -0
  78. package/src/participant/participantFailedMenuState.ts +57 -0
  79. package/src/participant/participantFailedMessage.test.tsx +83 -0
  80. package/src/participant/participantFileValidation.ts +1 -1
  81. package/src/participant/participantGolden.test.tsx +2 -0
  82. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  83. package/src/participant/participantLabels.ts +10 -1
  84. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  85. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  86. package/src/participant/participantPublicTypes.test.ts +11 -0
  87. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  88. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  89. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  90. package/src/participant/participantThemeContrast.test.ts +17 -0
  91. package/src/participant/participantTooltips.test.tsx +74 -0
  92. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  93. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  94. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  95. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  96. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  97. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  98. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  99. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  100. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  101. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  102. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
  103. package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
  104. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  105. package/src/participant/test-fixtures/thread-default.golden.html +1 -1
  106. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  107. package/src/participant/test-fixtures/thread-error.golden.html +1 -1
  108. package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
  109. package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
  110. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
  111. package/src/participant/useFailedMenuEffects.ts +59 -0
  112. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  113. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  114. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  115. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  116. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  117. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  118. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  119. package/src/participant/useParticipantFailedMenu.ts +60 -0
  120. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  121. package/src/participant/useParticipantSendAudio.ts +28 -0
  122. package/src/participant/useParticipantThreadController.ts +20 -7
  123. package/src/participant/useStableAttachmentUrl.ts +71 -0
  124. package/src/participant/useSuppressTouchTooltips.ts +38 -0
  125. package/src/participant/useTranscriptionOverflow.ts +25 -0
  126. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  127. package/src/styles.css +48 -5
  128. package/src/tooltipLayerRegistry.test.ts +56 -0
  129. package/src/tooltipLayerRegistry.ts +34 -0
@@ -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><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--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>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="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"><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"><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
+ }