@adatechnology/conversations-ui 0.9.0 → 0.11.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 +107 -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 +55 -3
- package/dist/participant/index.js +1369 -319
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +187 -0
- package/package.json +2 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/audioRecorderFormat.ts +27 -0
- package/src/participant/ConversationThread.tsx +7 -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 +5 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantThread.test.tsx +46 -0
- package/src/participant/ParticipantThread.tsx +17 -0
- package/src/participant/ParticipantThreadScreen.tsx +2 -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 +30 -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
|
@@ -1812,6 +1812,11 @@
|
|
|
1812
1812
|
white-space: nowrap;
|
|
1813
1813
|
border: 0;
|
|
1814
1814
|
}
|
|
1815
|
+
.cv-p-thread__lock {
|
|
1816
|
+
padding: 1rem;
|
|
1817
|
+
color: var(--cv-p-i-text-muted);
|
|
1818
|
+
border-top: 1px solid var(--cv-p-i-border);
|
|
1819
|
+
}
|
|
1815
1820
|
.cv-p-thread__closed {
|
|
1816
1821
|
margin: 0;
|
|
1817
1822
|
padding: 1rem;
|
|
@@ -2125,11 +2130,129 @@
|
|
|
2125
2130
|
width: 100%;
|
|
2126
2131
|
min-height: var(--cv-p-i-touch);
|
|
2127
2132
|
}
|
|
2133
|
+
.cv-p-audio {
|
|
2134
|
+
display: flex;
|
|
2135
|
+
flex-wrap: wrap;
|
|
2136
|
+
align-items: center;
|
|
2137
|
+
gap: 0.25rem 0.5rem;
|
|
2138
|
+
width: 100%;
|
|
2139
|
+
min-width: min(15rem, 100%);
|
|
2140
|
+
box-sizing: border-box;
|
|
2141
|
+
}
|
|
2142
|
+
.cv-p-audio__toggle,
|
|
2143
|
+
.cv-p-audio__rate {
|
|
2144
|
+
flex: 0 0 auto;
|
|
2145
|
+
display: inline-grid;
|
|
2146
|
+
place-items: center;
|
|
2147
|
+
min-width: var(--cv-p-i-touch);
|
|
2148
|
+
min-height: var(--cv-p-i-touch);
|
|
2149
|
+
padding: 0;
|
|
2150
|
+
background: transparent;
|
|
2151
|
+
border: 1px solid var(--cv-p-i-border);
|
|
2152
|
+
border-radius: var(--cv-p-i-radius);
|
|
2153
|
+
color: inherit;
|
|
2154
|
+
font: inherit;
|
|
2155
|
+
font-size: 0.75rem;
|
|
2156
|
+
cursor: pointer;
|
|
2157
|
+
}
|
|
2158
|
+
.cv-p-audio__toggle:disabled {
|
|
2159
|
+
opacity: 0.45;
|
|
2160
|
+
cursor: not-allowed;
|
|
2161
|
+
}
|
|
2162
|
+
.cv-p-audio__toggle svg {
|
|
2163
|
+
display: block;
|
|
2164
|
+
}
|
|
2165
|
+
.cv-p-audio__seek {
|
|
2166
|
+
flex: 1 1 5rem;
|
|
2167
|
+
min-width: 5rem;
|
|
2168
|
+
min-height: var(--cv-p-i-touch);
|
|
2169
|
+
margin: 0;
|
|
2170
|
+
accent-color: var(--cv-p-i-accent);
|
|
2171
|
+
cursor: pointer;
|
|
2172
|
+
}
|
|
2173
|
+
.cv-p-audio__time {
|
|
2174
|
+
flex: 0 0 auto;
|
|
2175
|
+
font-size: 0.75rem;
|
|
2176
|
+
font-variant-numeric: tabular-nums;
|
|
2177
|
+
color: var(--cv-p-i-text-muted);
|
|
2178
|
+
}
|
|
2179
|
+
.cv-p-audio__media {
|
|
2180
|
+
display: none;
|
|
2181
|
+
}
|
|
2182
|
+
.cv-p-audio__error {
|
|
2183
|
+
flex: 1 0 100%;
|
|
2184
|
+
font-size: 0.75rem;
|
|
2185
|
+
}
|
|
2186
|
+
.cv-p-recorder .cv-p-audio {
|
|
2187
|
+
flex: 1 1 8rem;
|
|
2188
|
+
width: auto;
|
|
2189
|
+
min-width: 0;
|
|
2190
|
+
}
|
|
2128
2191
|
.cv-p-attachment__name,
|
|
2129
2192
|
.cv-p-attachment__size {
|
|
2130
2193
|
font-size: 0.75rem;
|
|
2131
2194
|
overflow-wrap: anywhere;
|
|
2132
2195
|
}
|
|
2196
|
+
.cv-p-transcription {
|
|
2197
|
+
display: flex;
|
|
2198
|
+
flex-direction: column;
|
|
2199
|
+
gap: 0.25rem;
|
|
2200
|
+
margin: 0;
|
|
2201
|
+
font-size: 0.8rem;
|
|
2202
|
+
line-height: 1.4;
|
|
2203
|
+
color: var(--cv-p-i-text);
|
|
2204
|
+
}
|
|
2205
|
+
.cv-p-transcription--pending,
|
|
2206
|
+
.cv-p-transcription--too-long {
|
|
2207
|
+
font-size: 0.75rem;
|
|
2208
|
+
color: var(--cv-p-i-text-muted);
|
|
2209
|
+
}
|
|
2210
|
+
.cv-p-transcription__text,
|
|
2211
|
+
.cv-p-transcription__empty,
|
|
2212
|
+
.cv-p-transcription__notice {
|
|
2213
|
+
margin: 0;
|
|
2214
|
+
overflow-wrap: anywhere;
|
|
2215
|
+
}
|
|
2216
|
+
.cv-p-transcription__text {
|
|
2217
|
+
display: -webkit-box;
|
|
2218
|
+
-webkit-box-orient: vertical;
|
|
2219
|
+
-webkit-line-clamp: 6;
|
|
2220
|
+
line-clamp: 6;
|
|
2221
|
+
overflow: hidden;
|
|
2222
|
+
-webkit-user-select: text;
|
|
2223
|
+
user-select: text;
|
|
2224
|
+
}
|
|
2225
|
+
.cv-p-transcription__text[data-expanded=true] {
|
|
2226
|
+
display: block;
|
|
2227
|
+
overflow: visible;
|
|
2228
|
+
}
|
|
2229
|
+
.cv-p-transcription__empty {
|
|
2230
|
+
color: var(--cv-p-i-text-muted);
|
|
2231
|
+
}
|
|
2232
|
+
.cv-p-transcription__notice {
|
|
2233
|
+
order: 1;
|
|
2234
|
+
font-size: 0.7rem;
|
|
2235
|
+
color: var(--cv-p-i-text-muted);
|
|
2236
|
+
}
|
|
2237
|
+
.cv-p-transcription__toggle {
|
|
2238
|
+
order: 2;
|
|
2239
|
+
align-self: flex-start;
|
|
2240
|
+
min-width: var(--cv-p-i-touch);
|
|
2241
|
+
min-height: var(--cv-p-i-touch);
|
|
2242
|
+
padding: 0 0.5rem;
|
|
2243
|
+
background: transparent;
|
|
2244
|
+
border: 0;
|
|
2245
|
+
border-radius: var(--cv-p-i-radius);
|
|
2246
|
+
color: var(--cv-p-i-accent);
|
|
2247
|
+
font: inherit;
|
|
2248
|
+
font-size: 0.75rem;
|
|
2249
|
+
text-decoration: underline;
|
|
2250
|
+
cursor: pointer;
|
|
2251
|
+
}
|
|
2252
|
+
.cv-p-transcription__toggle:focus-visible {
|
|
2253
|
+
outline: 2px solid var(--cv-p-i-accent);
|
|
2254
|
+
outline-offset: 2px;
|
|
2255
|
+
}
|
|
2133
2256
|
.cv-p-composer {
|
|
2134
2257
|
display: flex;
|
|
2135
2258
|
flex-direction: column;
|
|
@@ -2190,6 +2313,66 @@
|
|
|
2190
2313
|
font-size: 0.8rem;
|
|
2191
2314
|
color: var(--cv-p-i-danger);
|
|
2192
2315
|
}
|
|
2316
|
+
.cv-p-composer__record {
|
|
2317
|
+
flex: 0 0 auto;
|
|
2318
|
+
padding: 0;
|
|
2319
|
+
margin-inline-start: 0.25rem;
|
|
2320
|
+
}
|
|
2321
|
+
.cv-p-composer__record svg,
|
|
2322
|
+
.cv-p-recorder svg {
|
|
2323
|
+
display: block;
|
|
2324
|
+
}
|
|
2325
|
+
.cv-p-recorder {
|
|
2326
|
+
display: flex;
|
|
2327
|
+
flex: 1 1 auto;
|
|
2328
|
+
flex-wrap: wrap;
|
|
2329
|
+
align-items: center;
|
|
2330
|
+
gap: 0.5rem;
|
|
2331
|
+
min-width: 0;
|
|
2332
|
+
}
|
|
2333
|
+
.cv-p-recorder__hint {
|
|
2334
|
+
flex: 1 1 100%;
|
|
2335
|
+
margin: 0;
|
|
2336
|
+
font-size: 0.8rem;
|
|
2337
|
+
color: var(--cv-p-i-text-muted);
|
|
2338
|
+
}
|
|
2339
|
+
.cv-p-recorder__discard {
|
|
2340
|
+
flex: 0 0 auto;
|
|
2341
|
+
padding: 0;
|
|
2342
|
+
color: var(--cv-p-i-text-muted);
|
|
2343
|
+
}
|
|
2344
|
+
.cv-p-recorder__dot {
|
|
2345
|
+
flex: 0 0 auto;
|
|
2346
|
+
width: 0.625rem;
|
|
2347
|
+
height: 0.625rem;
|
|
2348
|
+
border-radius: 50%;
|
|
2349
|
+
background: var(--cv-p-i-danger);
|
|
2350
|
+
animation: cv-p-recorder-pulse 1.2s ease-in-out infinite;
|
|
2351
|
+
}
|
|
2352
|
+
.cv-p-recorder__elapsed {
|
|
2353
|
+
flex: 1 1 auto;
|
|
2354
|
+
font:
|
|
2355
|
+
0.85rem "SFMono-Regular",
|
|
2356
|
+
Consolas,
|
|
2357
|
+
monospace;
|
|
2358
|
+
font-variant-numeric: tabular-nums;
|
|
2359
|
+
color: var(--cv-p-i-text);
|
|
2360
|
+
}
|
|
2361
|
+
.cv-p-recorder__stop,
|
|
2362
|
+
.cv-p-recorder__send {
|
|
2363
|
+
flex: 0 0 auto;
|
|
2364
|
+
padding: 0;
|
|
2365
|
+
}
|
|
2366
|
+
@keyframes cv-p-recorder-pulse {
|
|
2367
|
+
50% {
|
|
2368
|
+
opacity: 0.3;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2372
|
+
.cv-p-recorder__dot {
|
|
2373
|
+
animation: none;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2193
2376
|
.cv-p-quick {
|
|
2194
2377
|
--cv-p-i-fade: 0.75rem;
|
|
2195
2378
|
display: flex;
|
|
@@ -2594,6 +2777,10 @@
|
|
|
2594
2777
|
.cv-p-thread__latest-badge {
|
|
2595
2778
|
border: 1px solid ButtonText;
|
|
2596
2779
|
}
|
|
2780
|
+
.cv-p-transcription__toggle {
|
|
2781
|
+
color: LinkText;
|
|
2782
|
+
border: 1px solid ButtonText;
|
|
2783
|
+
}
|
|
2597
2784
|
.cv-p-bubble .cv-status-ticks,
|
|
2598
2785
|
.cv-p-copyable__done {
|
|
2599
2786
|
color: CanvasText;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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,10 @@ 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
|
|
56
|
+
/** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
|
|
57
|
+
readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
|
|
@@ -105,6 +110,8 @@ function ConversationThreadBody(props: ConversationThreadProps) {
|
|
|
105
110
|
tail={props.tail}
|
|
106
111
|
counterpartLabel={props.counterpartLabel}
|
|
107
112
|
headerActions={props.headerActions}
|
|
113
|
+
audioRecording={props.audioRecording}
|
|
114
|
+
renderComposerLock={props.renderComposerLock}
|
|
108
115
|
/>
|
|
109
116
|
</div>
|
|
110
117
|
</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
|
+
}
|