@adatechnology/conversations-ui 0.9.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 (90) 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-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
  4. package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
  5. package/dist/index.d.ts +10 -3
  6. package/dist/index.js +3 -3
  7. package/dist/participant/index.d.ts +51 -3
  8. package/dist/participant/index.js +1363 -319
  9. package/dist/preview/index.d.ts +2 -2
  10. package/dist/preview/index.js +2 -2
  11. package/dist/styles.css +182 -0
  12. package/package.json +2 -2
  13. package/src/AudioRecorderButton.tsx +4 -28
  14. package/src/audioRecorderFormat.ts +27 -0
  15. package/src/participant/ConversationThread.tsx +4 -0
  16. package/src/participant/ParticipantAttachmentItem.tsx +7 -10
  17. package/src/participant/ParticipantAudioAttachment.tsx +34 -0
  18. package/src/participant/ParticipantAudioPlayer.tsx +68 -0
  19. package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
  20. package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
  21. package/src/participant/ParticipantAudioTranscription.tsx +90 -0
  22. package/src/participant/ParticipantComposer.tsx +50 -15
  23. package/src/participant/ParticipantComposerAudio.tsx +95 -0
  24. package/src/participant/ParticipantConversations.tsx +3 -0
  25. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  26. package/src/participant/ParticipantThread.tsx +7 -0
  27. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  28. package/src/participant/index.ts +3 -0
  29. package/src/participant/participantAudio.types.ts +10 -0
  30. package/src/participant/participantAudioCapture.test.ts +15 -0
  31. package/src/participant/participantAudioCapture.ts +85 -0
  32. package/src/participant/participantAudioCoordinator.ts +13 -0
  33. package/src/participant/participantAudioFormat.test.ts +93 -0
  34. package/src/participant/participantAudioFormat.ts +72 -0
  35. package/src/participant/participantAudioGolden.test.tsx +168 -0
  36. package/src/participant/participantAudioLabels.test.ts +22 -0
  37. package/src/participant/participantAudioLabels.ts +76 -0
  38. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  39. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  40. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  41. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  42. package/src/participant/participantAudioPlayerState.ts +35 -0
  43. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  44. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  45. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  46. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  47. package/src/participant/participantAudioRecorderState.ts +93 -0
  48. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  49. package/src/participant/participantAudioSupport.ts +36 -0
  50. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  51. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  52. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  53. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  54. package/src/participant/participantFileValidation.ts +1 -1
  55. package/src/participant/participantGolden.test.tsx +2 -0
  56. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  57. package/src/participant/participantLabels.ts +4 -1
  58. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  59. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  60. package/src/participant/participantPublicTypes.test.ts +11 -0
  61. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  62. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  63. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  64. package/src/participant/participantThemeContrast.test.ts +9 -0
  65. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  66. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  67. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  68. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  69. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  70. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  71. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  72. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  73. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  74. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  75. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  76. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  77. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  78. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  79. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  80. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  81. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  82. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  83. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  84. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  85. package/src/participant/useParticipantSendAudio.ts +28 -0
  86. package/src/participant/useParticipantThreadController.ts +20 -7
  87. package/src/participant/useStableAttachmentUrl.ts +71 -0
  88. package/src/participant/useTranscriptionOverflow.ts +25 -0
  89. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  90. package/src/styles.css +29 -0
@@ -1,6 +1,6 @@
1
1
  import { M as MessagePayload } from '../quickReply.types-DzWH3NYV.js';
2
- import { C as ConversationSummary, a as ConversationEventSource, b as ConversationsApi, L as ListConversationsParams, c as ConversationPage, S as SSEProvider, d as ConversationDocument, e as SendPreviewMediaParams, P as PreviewUploadedMedia, f as PreviewWebhookClient, R as ResolveMediaUrl } from '../ConversationSimulatorPanel-q-wVgBCN.js';
3
- export { A as AudioRecorderButton, g as AudioRecorderButtonLabels, h as AudioRecorderButtonProps, i as ConversationPreview, j as ConversationPreviewProps, k as ConversationSimulatorClient, l as ConversationSimulatorPanel, m as ConversationSimulatorPanelLabels, n as ConversationSimulatorPanelProps, o as CreatePreviewMediaUploaderParams, p as CreatePreviewWebhookClientParams, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, q as DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, r as DEFAULT_MEDIA_UPLOAD_PATH, s as PreviewInProductionError, t as PreviewMediaUploadRejectedError, u as PreviewMediaUploadRequest, v as PreviewUploadedMedia, w as PreviewWebhookRejectedError, x as SIMULATOR_FILE_MEDIA_KINDS, y as SendSimulatorMediaParams, z as SimulatorMediaKind, T as ToSimulatorClientParams, B as acceptsMediaKind, E as assertPreviewEnvironment, F as createPreviewMediaPoster, G as createPreviewMediaUploader, H as createPreviewWebhookClient, I as isConversationSimulatorClient, J as mediaKindOf, K as mediaTypeOf, M as signPreviewPayload, N as simulatorPanelLabelsOf, O as toConversationSimulatorClient } from '../ConversationSimulatorPanel-q-wVgBCN.js';
2
+ import { C as ConversationSummary, a as ConversationEventSource, b as ConversationsApi, L as ListConversationsParams, c as ConversationPage, S as SSEProvider, d as ConversationDocument, e as SendPreviewMediaParams, P as PreviewUploadedMedia, f as PreviewWebhookClient, R as ResolveMediaUrl } from '../ConversationSimulatorPanel-P6eqTSAO.js';
3
+ export { A as AudioRecorderButton, g as AudioRecorderButtonLabels, h as AudioRecorderButtonProps, i as ConversationPreview, j as ConversationPreviewProps, k as ConversationSimulatorClient, l as ConversationSimulatorPanel, m as ConversationSimulatorPanelLabels, n as ConversationSimulatorPanelProps, o as CreatePreviewMediaUploaderParams, p as CreatePreviewWebhookClientParams, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, q as DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, r as DEFAULT_MEDIA_UPLOAD_PATH, s as PreviewInProductionError, t as PreviewMediaUploadRejectedError, u as PreviewMediaUploadRequest, v as PreviewUploadedMedia, w as PreviewWebhookRejectedError, x as SIMULATOR_FILE_MEDIA_KINDS, y as SendSimulatorMediaParams, z as SimulatorMediaKind, T as ToSimulatorClientParams, B as acceptsMediaKind, E as assertPreviewEnvironment, F as createPreviewMediaPoster, G as createPreviewMediaUploader, H as createPreviewWebhookClient, I as isConversationSimulatorClient, J as mediaKindOf, K as mediaTypeOf, M as signPreviewPayload, N as simulatorPanelLabelsOf, O as toConversationSimulatorClient } from '../ConversationSimulatorPanel-P6eqTSAO.js';
4
4
  import { InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing';
5
5
  import * as react from 'react';
6
6
  export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts';
@@ -16,9 +16,9 @@ import {
16
16
  mediaTypeOf,
17
17
  simulatorPanelLabelsOf,
18
18
  toConversationSimulatorClient
19
- } from "../chunk-L3OFAQPS.js";
19
+ } from "../chunk-Y326LLEP.js";
20
20
  import "../chunk-PJNR6UMN.js";
21
- import "../chunk-O6457XHK.js";
21
+ import "../chunk-WCZNIJ3M.js";
22
22
 
23
23
  // src/preview/previewStore.ts
24
24
  var GLOBAL_CHANNEL = "global";
package/dist/styles.css CHANGED
@@ -2125,11 +2125,129 @@
2125
2125
  width: 100%;
2126
2126
  min-height: var(--cv-p-i-touch);
2127
2127
  }
2128
+ .cv-p-audio {
2129
+ display: flex;
2130
+ flex-wrap: wrap;
2131
+ align-items: center;
2132
+ gap: 0.25rem 0.5rem;
2133
+ width: 100%;
2134
+ min-width: min(15rem, 100%);
2135
+ box-sizing: border-box;
2136
+ }
2137
+ .cv-p-audio__toggle,
2138
+ .cv-p-audio__rate {
2139
+ flex: 0 0 auto;
2140
+ display: inline-grid;
2141
+ place-items: center;
2142
+ min-width: var(--cv-p-i-touch);
2143
+ min-height: var(--cv-p-i-touch);
2144
+ padding: 0;
2145
+ background: transparent;
2146
+ border: 1px solid var(--cv-p-i-border);
2147
+ border-radius: var(--cv-p-i-radius);
2148
+ color: inherit;
2149
+ font: inherit;
2150
+ font-size: 0.75rem;
2151
+ cursor: pointer;
2152
+ }
2153
+ .cv-p-audio__toggle:disabled {
2154
+ opacity: 0.45;
2155
+ cursor: not-allowed;
2156
+ }
2157
+ .cv-p-audio__toggle svg {
2158
+ display: block;
2159
+ }
2160
+ .cv-p-audio__seek {
2161
+ flex: 1 1 5rem;
2162
+ min-width: 5rem;
2163
+ min-height: var(--cv-p-i-touch);
2164
+ margin: 0;
2165
+ accent-color: var(--cv-p-i-accent);
2166
+ cursor: pointer;
2167
+ }
2168
+ .cv-p-audio__time {
2169
+ flex: 0 0 auto;
2170
+ font-size: 0.75rem;
2171
+ font-variant-numeric: tabular-nums;
2172
+ color: var(--cv-p-i-text-muted);
2173
+ }
2174
+ .cv-p-audio__media {
2175
+ display: none;
2176
+ }
2177
+ .cv-p-audio__error {
2178
+ flex: 1 0 100%;
2179
+ font-size: 0.75rem;
2180
+ }
2181
+ .cv-p-recorder .cv-p-audio {
2182
+ flex: 1 1 8rem;
2183
+ width: auto;
2184
+ min-width: 0;
2185
+ }
2128
2186
  .cv-p-attachment__name,
2129
2187
  .cv-p-attachment__size {
2130
2188
  font-size: 0.75rem;
2131
2189
  overflow-wrap: anywhere;
2132
2190
  }
2191
+ .cv-p-transcription {
2192
+ display: flex;
2193
+ flex-direction: column;
2194
+ gap: 0.25rem;
2195
+ margin: 0;
2196
+ font-size: 0.8rem;
2197
+ line-height: 1.4;
2198
+ color: var(--cv-p-i-text);
2199
+ }
2200
+ .cv-p-transcription--pending,
2201
+ .cv-p-transcription--too-long {
2202
+ font-size: 0.75rem;
2203
+ color: var(--cv-p-i-text-muted);
2204
+ }
2205
+ .cv-p-transcription__text,
2206
+ .cv-p-transcription__empty,
2207
+ .cv-p-transcription__notice {
2208
+ margin: 0;
2209
+ overflow-wrap: anywhere;
2210
+ }
2211
+ .cv-p-transcription__text {
2212
+ display: -webkit-box;
2213
+ -webkit-box-orient: vertical;
2214
+ -webkit-line-clamp: 6;
2215
+ line-clamp: 6;
2216
+ overflow: hidden;
2217
+ -webkit-user-select: text;
2218
+ user-select: text;
2219
+ }
2220
+ .cv-p-transcription__text[data-expanded=true] {
2221
+ display: block;
2222
+ overflow: visible;
2223
+ }
2224
+ .cv-p-transcription__empty {
2225
+ color: var(--cv-p-i-text-muted);
2226
+ }
2227
+ .cv-p-transcription__notice {
2228
+ order: 1;
2229
+ font-size: 0.7rem;
2230
+ color: var(--cv-p-i-text-muted);
2231
+ }
2232
+ .cv-p-transcription__toggle {
2233
+ order: 2;
2234
+ align-self: flex-start;
2235
+ min-width: var(--cv-p-i-touch);
2236
+ min-height: var(--cv-p-i-touch);
2237
+ padding: 0 0.5rem;
2238
+ background: transparent;
2239
+ border: 0;
2240
+ border-radius: var(--cv-p-i-radius);
2241
+ color: var(--cv-p-i-accent);
2242
+ font: inherit;
2243
+ font-size: 0.75rem;
2244
+ text-decoration: underline;
2245
+ cursor: pointer;
2246
+ }
2247
+ .cv-p-transcription__toggle:focus-visible {
2248
+ outline: 2px solid var(--cv-p-i-accent);
2249
+ outline-offset: 2px;
2250
+ }
2133
2251
  .cv-p-composer {
2134
2252
  display: flex;
2135
2253
  flex-direction: column;
@@ -2190,6 +2308,66 @@
2190
2308
  font-size: 0.8rem;
2191
2309
  color: var(--cv-p-i-danger);
2192
2310
  }
2311
+ .cv-p-composer__record {
2312
+ flex: 0 0 auto;
2313
+ padding: 0;
2314
+ margin-inline-start: 0.25rem;
2315
+ }
2316
+ .cv-p-composer__record svg,
2317
+ .cv-p-recorder svg {
2318
+ display: block;
2319
+ }
2320
+ .cv-p-recorder {
2321
+ display: flex;
2322
+ flex: 1 1 auto;
2323
+ flex-wrap: wrap;
2324
+ align-items: center;
2325
+ gap: 0.5rem;
2326
+ min-width: 0;
2327
+ }
2328
+ .cv-p-recorder__hint {
2329
+ flex: 1 1 100%;
2330
+ margin: 0;
2331
+ font-size: 0.8rem;
2332
+ color: var(--cv-p-i-text-muted);
2333
+ }
2334
+ .cv-p-recorder__discard {
2335
+ flex: 0 0 auto;
2336
+ padding: 0;
2337
+ color: var(--cv-p-i-text-muted);
2338
+ }
2339
+ .cv-p-recorder__dot {
2340
+ flex: 0 0 auto;
2341
+ width: 0.625rem;
2342
+ height: 0.625rem;
2343
+ border-radius: 50%;
2344
+ background: var(--cv-p-i-danger);
2345
+ animation: cv-p-recorder-pulse 1.2s ease-in-out infinite;
2346
+ }
2347
+ .cv-p-recorder__elapsed {
2348
+ flex: 1 1 auto;
2349
+ font:
2350
+ 0.85rem "SFMono-Regular",
2351
+ Consolas,
2352
+ monospace;
2353
+ font-variant-numeric: tabular-nums;
2354
+ color: var(--cv-p-i-text);
2355
+ }
2356
+ .cv-p-recorder__stop,
2357
+ .cv-p-recorder__send {
2358
+ flex: 0 0 auto;
2359
+ padding: 0;
2360
+ }
2361
+ @keyframes cv-p-recorder-pulse {
2362
+ 50% {
2363
+ opacity: 0.3;
2364
+ }
2365
+ }
2366
+ @media (prefers-reduced-motion: reduce) {
2367
+ .cv-p-recorder__dot {
2368
+ animation: none;
2369
+ }
2370
+ }
2193
2371
  .cv-p-quick {
2194
2372
  --cv-p-i-fade: 0.75rem;
2195
2373
  display: flex;
@@ -2594,6 +2772,10 @@
2594
2772
  .cv-p-thread__latest-badge {
2595
2773
  border: 1px solid ButtonText;
2596
2774
  }
2775
+ .cv-p-transcription__toggle {
2776
+ color: LinkText;
2777
+ border: 1px solid ButtonText;
2778
+ }
2597
2779
  .cv-p-bubble .cv-status-ticks,
2598
2780
  .cv-p-copyable__done {
2599
2781
  color: CanvasText;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,7 +35,7 @@
35
35
  "clsx": "^2.1.1",
36
36
  "lucide-react": "^1.21.0",
37
37
  "tailwind-merge": "^3.6.0",
38
- "@adatechnology/conversation-contracts": "0.5.0",
38
+ "@adatechnology/conversation-contracts": "0.6.0",
39
39
  "@adatechnology/meta-whatsapp-contracts": "0.6.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -11,6 +11,10 @@
11
11
  import { useCallback, useEffect, useId, useRef, useState } from 'react'
12
12
 
13
13
  import { AudioPlayer } from './AudioPlayer'
14
+ import { DEFAULT_MAX_RECORDING_MILLISECONDS, RECORDING_FORMATS, resolveRecordingFormat } from './audioRecorderFormat'
15
+
16
+ export { DEFAULT_MAX_RECORDING_MILLISECONDS, RECORDING_FORMATS, resolveRecordingFormat }
17
+ export type { RecordingFormat } from './audioRecorderFormat'
14
18
 
15
19
  export interface AudioRecorderButtonLabels {
16
20
  start: string
@@ -64,34 +68,6 @@ export interface AudioRecorderButtonProps {
64
68
  disabledHint?: string
65
69
  }
66
70
 
67
- /**
68
- * Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
69
- * 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
70
- * existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
71
- */
72
- export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
73
-
74
- /**
75
- * Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
76
- * saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
77
- * do envio que o formato é inválido é pior do que gravar já no formato certo.
78
- */
79
- const RECORDING_FORMATS = [
80
- { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
81
- { mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
82
- { mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
83
- ] as const
84
-
85
- export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
86
-
87
- /** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
88
- export function resolveRecordingFormat(): RecordingFormat | undefined {
89
- if (typeof MediaRecorder === 'undefined') return undefined
90
- // `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
91
- if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
92
- return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
93
- }
94
-
95
71
  type PendingRecording = { file: File; objectURL: string }
96
72
 
97
73
  export function AudioRecorderButton({
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
3
+ * 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
4
+ * existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
5
+ */
6
+ export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
7
+
8
+ /**
9
+ * Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
10
+ * saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
11
+ * do envio que o formato é inválido é pior do que gravar já no formato certo.
12
+ */
13
+ export const RECORDING_FORMATS = [
14
+ { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
15
+ { mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
16
+ { mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
17
+ ] as const
18
+
19
+ export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
20
+
21
+ /** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
22
+ export function resolveRecordingFormat(): RecordingFormat | undefined {
23
+ if (typeof MediaRecorder === 'undefined') return undefined
24
+ // `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
25
+ if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
26
+ return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
27
+ }
@@ -10,6 +10,7 @@ import { ParticipantThread } from './ParticipantThread'
10
10
  import { toParticipantApi, type ConversationThreadApi } from './conversationThreadApi'
11
11
  import { buildThreadSummary } from './conversationThreadSummary'
12
12
  import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
13
+ import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
13
14
  import type { ParticipantPendingMessage } from './participantApi.types'
14
15
  import { draftKey, type ParticipantDrafts } from './participantDrafts'
15
16
  import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
@@ -50,6 +51,8 @@ export type ConversationThreadProps = {
50
51
  readonly tail?: boolean
51
52
  readonly avatars?: 'initials'
52
53
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
54
+ /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
55
+ readonly audioRecording?: ParticipantAudioRecordingOptions
53
56
  }
54
57
 
55
58
  function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
@@ -105,6 +108,7 @@ function ConversationThreadBody(props: ConversationThreadProps) {
105
108
  tail={props.tail}
106
109
  counterpartLabel={props.counterpartLabel}
107
110
  headerActions={props.headerActions}
111
+ audioRecording={props.audioRecording}
108
112
  />
109
113
  </div>
110
114
  </div>
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
3
3
  import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
4
 
5
5
  import { formatFileSize } from '../lib/format'
6
+ import { ParticipantAudioAttachment } from './ParticipantAudioAttachment'
6
7
  import { isSafeAttachmentUrl } from './participantAttachmentUrl'
7
8
  import type { ParticipantConversationsLabels } from './participantLabels'
8
9
 
@@ -42,7 +43,12 @@ function useAttachmentUrl(
42
43
  return url
43
44
  }
44
45
 
45
- export function ParticipantAttachmentItem({ attachment, resolveAttachmentUrl, labels }: ParticipantAttachmentItemProps) {
46
+ export function ParticipantAttachmentItem(props: ParticipantAttachmentItemProps) {
47
+ if (props.attachment.kind === 'audio') return <ParticipantAudioAttachment {...props} />
48
+ return <ParticipantFileAttachment {...props} />
49
+ }
50
+
51
+ function ParticipantFileAttachment({ attachment, resolveAttachmentUrl, labels }: ParticipantAttachmentItemProps) {
46
52
  const url = useAttachmentUrl(attachment, resolveAttachmentUrl)
47
53
 
48
54
  if (attachment.kind === 'image') {
@@ -54,15 +60,6 @@ export function ParticipantAttachmentItem({ attachment, resolveAttachmentUrl, la
54
60
  )
55
61
  }
56
62
 
57
- if (attachment.kind === 'audio') {
58
- return (
59
- <div className="cv-p-attachment cv-p-attachment--audio">
60
- <span className="cv-p-attachment__name">{attachment.filename}</span>
61
- <audio className="cv-p-attachment__audio" controls preload="none" src={url} />
62
- </div>
63
- )
64
- }
65
-
66
63
  return (
67
64
  <a
68
65
  className="cv-p-attachment cv-p-attachment--document"
@@ -0,0 +1,34 @@
1
+ import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
2
+
3
+ import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
4
+ import { ParticipantAudioPlayer } from './ParticipantAudioPlayer'
5
+ import { ParticipantAudioTranscription } from './ParticipantAudioTranscription'
6
+ import { resolveParticipantAudioLabels } from './participantAudioLabels'
7
+ import type { ParticipantConversationsLabels } from './participantLabels'
8
+ import { useStableAttachmentUrl } from './useStableAttachmentUrl'
9
+
10
+ export type ParticipantAudioAttachmentProps = {
11
+ readonly attachment: ParticipantAttachment
12
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
13
+ readonly labels: ParticipantConversationsLabels
14
+ }
15
+
16
+ export function ParticipantAudioAttachment({ attachment, resolveAttachmentUrl, labels }: ParticipantAudioAttachmentProps) {
17
+ const source = useStableAttachmentUrl({ attachment, resolveAttachmentUrl })
18
+ const audioLabels = resolveParticipantAudioLabels(labels)
19
+ return (
20
+ <div className="cv-p-attachment cv-p-attachment--audio">
21
+ <ParticipantAudioPlayer
22
+ labels={audioLabels}
23
+ url={source.url}
24
+ durationMs={attachment.durationMs}
25
+ filename={attachment.filename}
26
+ isPending={source.status === 'pending'}
27
+ isUnavailable={source.status === 'failed'}
28
+ onSourceError={source.resolveAgain}
29
+ />
30
+ <span className="cv-p-attachment__name">{attachment.filename}</span>
31
+ <ParticipantAudioTranscription attachmentId={attachment.id} transcription={attachment.transcription} labels={audioLabels} />
32
+ </div>
33
+ )
34
+ }
@@ -0,0 +1,68 @@
1
+ import type { Ref } from 'react'
2
+
3
+ import type { ParticipantAudioLabels } from './participantAudioLabels'
4
+ import {
5
+ formatAudioClock,
6
+ formatPlaybackRate,
7
+ formatPositionValueText,
8
+ resolveSeekMax,
9
+ } from './participantAudioFormat'
10
+ import { AudioRateButton, AudioSeekRange, AudioToggleButton } from './ParticipantAudioPlayerControls'
11
+ import { useParticipantAudioPlayer } from './useParticipantAudioPlayer'
12
+
13
+ export type ParticipantAudioPlayerProps = {
14
+ readonly labels: ParticipantAudioLabels
15
+ readonly url: string | undefined
16
+ readonly durationMs?: number | undefined
17
+ readonly filename?: string
18
+ /** True while the host is still resolving the URL: the toggle answers aria-busy and the user taps again. */
19
+ readonly isPending?: boolean
20
+ readonly isUnavailable?: boolean
21
+ readonly onSourceError?: () => Promise<string | undefined>
22
+ readonly mediaRef?: Ref<HTMLAudioElement>
23
+ }
24
+
25
+ function fillRate(template: string, rate: number): string {
26
+ return template.replace('{rate}', formatPlaybackRate(rate))
27
+ }
28
+
29
+ export function ParticipantAudioPlayer({
30
+ labels,
31
+ url,
32
+ durationMs,
33
+ filename,
34
+ isPending = false,
35
+ isUnavailable = false,
36
+ onSourceError,
37
+ mediaRef,
38
+ }: ParticipantAudioPlayerProps) {
39
+ const player = useParticipantAudioPlayer({ url, durationMs, onSourceError, mediaRef })
40
+ const { state, duration, rate } = player
41
+ const position = Math.floor(state.position)
42
+ const hasFailed = isUnavailable || state.hasFailed
43
+
44
+ return (
45
+ <div className="cv-p-audio" role="group" aria-label={filename ? `${labels.audioPlayer}: ${filename}` : labels.audioPlayer}>
46
+ <AudioToggleButton
47
+ label={state.isPlaying ? labels.pauseAudio : labels.playAudio}
48
+ isPlaying={state.isPlaying}
49
+ isPending={isPending}
50
+ isDisabled={hasFailed}
51
+ onToggle={player.handleToggle}
52
+ />
53
+ <AudioSeekRange
54
+ label={labels.audioPosition}
55
+ max={resolveSeekMax({ duration, maxSeen: state.maxSeen, position })}
56
+ position={position}
57
+ valueText={formatPositionValueText({ template: labels.audioPositionValue, position, duration })}
58
+ isDisabled={hasFailed}
59
+ onSeek={player.seekTo}
60
+ onKeyDown={player.handleKeyDown}
61
+ />
62
+ <span className="cv-p-audio__time">{`${formatAudioClock(position)} / ${formatAudioClock(duration)}`}</span>
63
+ <AudioRateButton label={fillRate(labels.playbackRate, rate)} text={formatPlaybackRate(rate)} onClick={player.handleRateClick} />
64
+ <audio className="cv-p-audio__media" preload="none" src={url} {...player.mediaProps} />
65
+ {hasFailed ? <span className="cv-p-audio__error">{labels.audioUnavailable}</span> : null}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,68 @@
1
+ import { Pause, Play } from 'lucide-react'
2
+ import type { KeyboardEventHandler } from 'react'
3
+
4
+ type AudioToggleButtonProps = {
5
+ readonly label: string
6
+ readonly isPlaying: boolean
7
+ readonly isPending: boolean
8
+ readonly isDisabled: boolean
9
+ readonly onToggle: () => void
10
+ }
11
+
12
+ export function AudioToggleButton({ label, isPlaying, isPending, isDisabled, onToggle }: AudioToggleButtonProps) {
13
+ return (
14
+ <button
15
+ type="button"
16
+ className="cv-p-audio__toggle"
17
+ aria-label={label}
18
+ data-cv-tooltip={label}
19
+ aria-busy={isPending ? true : undefined}
20
+ disabled={isDisabled}
21
+ onClick={onToggle}
22
+ >
23
+ {isPlaying ? <Pause size={20} aria-hidden="true" focusable="false" /> : <Play size={20} aria-hidden="true" focusable="false" />}
24
+ </button>
25
+ )
26
+ }
27
+
28
+ type AudioSeekRangeProps = {
29
+ readonly label: string
30
+ readonly max: number
31
+ readonly position: number
32
+ readonly valueText: string
33
+ readonly isDisabled: boolean
34
+ readonly onSeek: (seconds: number) => void
35
+ readonly onKeyDown: KeyboardEventHandler<HTMLInputElement>
36
+ }
37
+
38
+ export function AudioSeekRange({ label, max, position, valueText, isDisabled, onSeek, onKeyDown }: AudioSeekRangeProps) {
39
+ return (
40
+ <input
41
+ type="range"
42
+ className="cv-p-audio__seek"
43
+ aria-label={label}
44
+ min={0}
45
+ max={max}
46
+ step={1}
47
+ value={Math.min(position, max)}
48
+ aria-valuetext={valueText}
49
+ disabled={isDisabled}
50
+ onChange={(event) => onSeek(Number(event.currentTarget.value))}
51
+ onKeyDown={onKeyDown}
52
+ />
53
+ )
54
+ }
55
+
56
+ type AudioRateButtonProps = {
57
+ readonly label: string
58
+ readonly text: string
59
+ readonly onClick: () => void
60
+ }
61
+
62
+ export function AudioRateButton({ label, text, onClick }: AudioRateButtonProps) {
63
+ return (
64
+ <button type="button" className="cv-p-audio__rate" aria-label={label} onClick={onClick}>
65
+ {text}
66
+ </button>
67
+ )
68
+ }
@@ -0,0 +1,55 @@
1
+ import type { Ref } from 'react'
2
+
3
+ import { DEFAULT_MAX_RECORDING_MILLISECONDS } from '../audioRecorderFormat'
4
+ import { formatRecordingElapsedLabel, resolveParticipantAudioLabels, type ParticipantAudioLabels } from './participantAudioLabels'
5
+ import { formatRecordingClock, type RecorderStatus } from './participantAudioRecorderState'
6
+ import type { ParticipantConversationsLabels } from './participantLabels'
7
+ import { RecordAudioButton, RecorderError, RecordingGroup, ReviewGroup } from './ParticipantRecorderParts'
8
+
9
+ export type ParticipantAudioRecorderActions = {
10
+ readonly onStart?: () => void
11
+ readonly onStop?: () => void
12
+ readonly onSend?: () => void
13
+ readonly onDiscard?: () => void
14
+ }
15
+
16
+ export type ParticipantAudioRecorderViewProps = {
17
+ readonly state: RecorderStatus
18
+ readonly elapsedMs: number
19
+ readonly labels?: Partial<ParticipantAudioLabels> | ParticipantConversationsLabels
20
+ readonly maxDurationMs?: number
21
+ readonly review?: { readonly url?: string; readonly audioRef?: Ref<HTMLAudioElement>; readonly durationMs?: number | undefined }
22
+ readonly actions?: ParticipantAudioRecorderActions
23
+ readonly disabled?: boolean
24
+ }
25
+
26
+ const ERROR_LABEL_BY_STATE = {
27
+ denied: 'microphoneDenied',
28
+ unsupported: 'recordingUnsupported',
29
+ empty: 'audioEmpty',
30
+ tooLarge: 'audioTooLarge',
31
+ } as const satisfies Partial<Record<RecorderStatus, keyof ParticipantAudioLabels>>
32
+
33
+ export function resolveRecorderErrorMessage(state: RecorderStatus, labels: ParticipantAudioLabels): string | undefined {
34
+ return state in ERROR_LABEL_BY_STATE ? labels[ERROR_LABEL_BY_STATE[state as keyof typeof ERROR_LABEL_BY_STATE]] : undefined
35
+ }
36
+
37
+ export function ParticipantAudioRecorderView(props: ParticipantAudioRecorderViewProps) {
38
+ const { state, elapsedMs, maxDurationMs = DEFAULT_MAX_RECORDING_MILLISECONDS, review, actions, disabled = false } = props
39
+ const labels = resolveParticipantAudioLabels(props.labels)
40
+
41
+ if (state === 'recording') {
42
+ const clockText = formatRecordingElapsedLabel(labels.recordingElapsed, formatRecordingClock(elapsedMs), formatRecordingClock(maxDurationMs))
43
+ return <RecordingGroup labels={labels} clockText={clockText} onStop={actions?.onStop} onDiscard={actions?.onDiscard} />
44
+ }
45
+ if (state === 'review') {
46
+ return <ReviewGroup labels={labels} url={review?.url} audioRef={review?.audioRef} durationMs={review?.durationMs} onSend={actions?.onSend} onDiscard={actions?.onDiscard} disabled={disabled} />
47
+ }
48
+ const errorMessage = resolveRecorderErrorMessage(state, labels)
49
+ return (
50
+ <>
51
+ {errorMessage ? <RecorderError message={errorMessage} /> : null}
52
+ <RecordAudioButton label={labels.recordAudio} isBusy={state === 'requesting'} disabled={disabled} onClick={actions?.onStart} />
53
+ </>
54
+ )
55
+ }