@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.
- package/README.md +106 -0
- package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/index.d.ts +10 -3
- package/dist/index.js +3 -3
- package/dist/participant/index.d.ts +51 -3
- package/dist/participant/index.js +1363 -319
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +182 -0
- package/package.json +2 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/audioRecorderFormat.ts +27 -0
- package/src/participant/ConversationThread.tsx +4 -0
- package/src/participant/ParticipantAttachmentItem.tsx +7 -10
- package/src/participant/ParticipantAudioAttachment.tsx +34 -0
- package/src/participant/ParticipantAudioPlayer.tsx +68 -0
- package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
- package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
- package/src/participant/ParticipantAudioTranscription.tsx +90 -0
- package/src/participant/ParticipantComposer.tsx +50 -15
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantThread.tsx +7 -0
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantAudio.types.ts +10 -0
- package/src/participant/participantAudioCapture.test.ts +15 -0
- package/src/participant/participantAudioCapture.ts +85 -0
- package/src/participant/participantAudioCoordinator.ts +13 -0
- package/src/participant/participantAudioFormat.test.ts +93 -0
- package/src/participant/participantAudioFormat.ts +72 -0
- package/src/participant/participantAudioGolden.test.tsx +168 -0
- package/src/participant/participantAudioLabels.test.ts +22 -0
- package/src/participant/participantAudioLabels.ts +76 -0
- package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
- package/src/participant/participantAudioPlayerCss.test.ts +63 -0
- package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
- package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
- package/src/participant/participantAudioPlayerState.ts +35 -0
- package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
- package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
- package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
- package/src/participant/participantAudioRecorderState.test.ts +144 -0
- package/src/participant/participantAudioRecorderState.ts +93 -0
- package/src/participant/participantAudioRecorderView.test.tsx +98 -0
- package/src/participant/participantAudioSupport.ts +36 -0
- package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
- package/src/participant/participantComposerAudioDom.test.tsx +185 -0
- package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
- package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
- package/src/participant/participantFileValidation.ts +1 -1
- package/src/participant/participantGolden.test.tsx +2 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
- package/src/participant/participantLabels.ts +4 -1
- package/src/participant/participantMediaFakes.test-helper.ts +82 -0
- package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
- package/src/participant/participantPublicTypes.test.ts +11 -0
- package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
- package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
- package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
- package/src/participant/participantThemeContrast.test.ts +9 -0
- package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
- package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
- package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
- package/src/participant/useParticipantAudioPlayer.ts +52 -0
- package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
- package/src/participant/useParticipantAudioRecorder.ts +153 -0
- package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
- package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
- package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
- package/src/participant/useParticipantSendAudio.test.tsx +85 -0
- package/src/participant/useParticipantSendAudio.ts +28 -0
- package/src/participant/useParticipantThreadController.ts +20 -7
- package/src/participant/useStableAttachmentUrl.ts +71 -0
- package/src/participant/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- package/src/styles.css +29 -0
package/dist/preview/index.d.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
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';
|
package/dist/preview/index.js
CHANGED
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
mediaTypeOf,
|
|
17
17
|
simulatorPanelLabelsOf,
|
|
18
18
|
toConversationSimulatorClient
|
|
19
|
-
} from "../chunk-
|
|
19
|
+
} from "../chunk-Y326LLEP.js";
|
|
20
20
|
import "../chunk-PJNR6UMN.js";
|
|
21
|
-
import "../chunk-
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
+
}
|