@adatechnology/conversations-ui 0.1.0-rc.3 → 0.1.0-rc.31
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/dist/chunk-CUYYYZWD.js +2317 -0
- package/dist/chunk-DXPSPUWF.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +338 -4
- package/dist/flows/index.js +1331 -55
- package/dist/index.d.ts +1074 -42
- package/dist/index.js +3572 -742
- package/dist/preview/index.d.ts +328 -8
- package/dist/preview/index.js +902 -115
- package/dist/styles.css +899 -0
- package/dist/types-De5aN-E_.d.ts +502 -0
- package/package.json +3 -3
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +42 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +98 -22
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +164 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +35 -8
- package/src/flows/FlowNodePanel.tsx +149 -38
- package/src/flows/FlowPalette.tsx +13 -3
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1023 -0
- package/src/flows/flowCanvasModel.test.ts +312 -0
- package/src/flows/flowCanvasModel.ts +368 -0
- package/src/flows/flowEditorOps.test.ts +241 -0
- package/src/flows/flowEditorOps.ts +177 -0
- package/src/flows/flowGraph.ts +6 -6
- package/src/flows/index.ts +47 -1
- package/src/flows/labels.ts +141 -0
- package/src/flows/workspaceContract.test.ts +95 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +114 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +199 -13
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +89 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +127 -4
- package/src/preview/index.ts +33 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -0
- package/src/settings/MessagesWorkspace.tsx +468 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.tsx +9 -1
- package/src/styles.css +862 -0
- package/src/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +346 -0
- package/src/workspace/index.ts +12 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-N7B24WYD.js +0 -719
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gravação de áudio no simulador, pelo microfone do próprio navegador.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
|
|
5
|
+
* poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
|
|
6
|
+
*
|
|
7
|
+
* O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
|
|
8
|
+
* hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
12
|
+
|
|
13
|
+
import { AudioPlayer } from './AudioPlayer'
|
|
14
|
+
|
|
15
|
+
export interface AudioRecorderButtonLabels {
|
|
16
|
+
start: string
|
|
17
|
+
stop: string
|
|
18
|
+
unsupported: string
|
|
19
|
+
denied: string
|
|
20
|
+
review: string
|
|
21
|
+
send: string
|
|
22
|
+
discard: string
|
|
23
|
+
empty: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
|
|
27
|
+
start: 'Gravar áudio',
|
|
28
|
+
stop: 'Parar gravação',
|
|
29
|
+
unsupported: 'Este navegador não grava áudio.',
|
|
30
|
+
denied: 'Sem permissão para usar o microfone.',
|
|
31
|
+
review: 'Ouça antes de enviar',
|
|
32
|
+
send: 'Enviar áudio',
|
|
33
|
+
discard: 'Descartar áudio',
|
|
34
|
+
empty: 'Nada foi captado pelo microfone.',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AudioRecorderButtonProps {
|
|
38
|
+
onRecorded: (file: File) => void | Promise<void>
|
|
39
|
+
onFailure?: (message: string) => void
|
|
40
|
+
/**
|
|
41
|
+
* Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
|
|
42
|
+
* envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
|
|
43
|
+
* que o microfone está quebrado.
|
|
44
|
+
*/
|
|
45
|
+
onRecordingChange?: (isRecording: boolean) => void
|
|
46
|
+
/**
|
|
47
|
+
* Abre uma etapa de revisão quando a gravação para: o áudio toca ali mesmo e só sai depois de
|
|
48
|
+
* confirmado. Ligado por padrão — voz é o único anexo que quem envia não viu antes de mandar, e
|
|
49
|
+
* sem ouvir não há como saber se o microfone captou alguma coisa. Desligar volta ao envio direto.
|
|
50
|
+
*/
|
|
51
|
+
reviewBeforeSend?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
|
|
54
|
+
* tem. Produto com limite próprio sobrescreve.
|
|
55
|
+
*/
|
|
56
|
+
maxDurationMilliseconds?: number
|
|
57
|
+
labels?: Partial<AudioRecorderButtonLabels>
|
|
58
|
+
disabled?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
|
|
63
|
+
* 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
|
|
64
|
+
* existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
|
|
70
|
+
* saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
|
|
71
|
+
* do envio que o formato é inválido é pior do que gravar já no formato certo.
|
|
72
|
+
*/
|
|
73
|
+
const RECORDING_FORMATS = [
|
|
74
|
+
{ mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
|
|
75
|
+
{ mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
|
|
76
|
+
{ mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
|
|
77
|
+
] as const
|
|
78
|
+
|
|
79
|
+
export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
|
|
80
|
+
|
|
81
|
+
/** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
|
|
82
|
+
export function resolveRecordingFormat(): RecordingFormat | undefined {
|
|
83
|
+
if (typeof MediaRecorder === 'undefined') return undefined
|
|
84
|
+
// `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
|
|
85
|
+
if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
|
|
86
|
+
return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type PendingRecording = { file: File; objectURL: string }
|
|
90
|
+
|
|
91
|
+
export function AudioRecorderButton({
|
|
92
|
+
onRecorded,
|
|
93
|
+
onFailure,
|
|
94
|
+
onRecordingChange,
|
|
95
|
+
reviewBeforeSend = true,
|
|
96
|
+
maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
97
|
+
labels,
|
|
98
|
+
disabled,
|
|
99
|
+
}: AudioRecorderButtonProps) {
|
|
100
|
+
const labelOf = (key: keyof AudioRecorderButtonLabels): string =>
|
|
101
|
+
labels?.[key] ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS[key]
|
|
102
|
+
const [isRecording, setIsRecording] = useState(false)
|
|
103
|
+
const [pending, setPending] = useState<PendingRecording | undefined>(undefined)
|
|
104
|
+
const recorderRef = useRef<MediaRecorder | null>(null)
|
|
105
|
+
const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
106
|
+
|
|
107
|
+
// O `objectURL` é um recurso do documento, não do React: sem revogar, cada gravação descartada
|
|
108
|
+
// deixa o blob inteiro preso na memória da aba até um reload.
|
|
109
|
+
const discard = useCallback(() => {
|
|
110
|
+
setPending((current) => {
|
|
111
|
+
if (current) URL.revokeObjectURL(current.objectURL)
|
|
112
|
+
return undefined
|
|
113
|
+
})
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
useEffect(() => discard, [discard])
|
|
117
|
+
|
|
118
|
+
const confirm = useCallback(() => {
|
|
119
|
+
if (!pending) return
|
|
120
|
+
void onRecorded(pending.file)
|
|
121
|
+
discard()
|
|
122
|
+
}, [discard, onRecorded, pending])
|
|
123
|
+
|
|
124
|
+
const stop = useCallback(() => {
|
|
125
|
+
recorderRef.current?.stop()
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
const start = useCallback(async () => {
|
|
129
|
+
const format = resolveRecordingFormat()
|
|
130
|
+
if (!format || !navigator.mediaDevices?.getUserMedia) {
|
|
131
|
+
onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
137
|
+
const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
|
|
138
|
+
const chunks: Blob[] = []
|
|
139
|
+
|
|
140
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
141
|
+
if (event.data.size > 0) chunks.push(event.data)
|
|
142
|
+
})
|
|
143
|
+
recorder.addEventListener('stop', () => {
|
|
144
|
+
// Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
|
|
145
|
+
// aceso depois do envio, e o operador acha que o simulador continua ouvindo.
|
|
146
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
147
|
+
clearTimeout(autoStopRef.current)
|
|
148
|
+
setIsRecording(false)
|
|
149
|
+
onRecordingChange?.(false)
|
|
150
|
+
recorderRef.current = null
|
|
151
|
+
// O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
|
|
152
|
+
// gravador, mas quem valida upload compara com `audio/ogg` puro.
|
|
153
|
+
const blob = new Blob(chunks, { type: format.uploadMimeType })
|
|
154
|
+
const file = new File([blob], `audio-${Date.now()}.${format.extension}`, {
|
|
155
|
+
type: format.uploadMimeType,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Gravação vazia não vira anexo: microfone mudo ou permissão revogada no meio produzem um
|
|
159
|
+
// blob de zero byte, e mandá-lo adiante só falha lá na frente, sem dizer por quê.
|
|
160
|
+
if (blob.size === 0) {
|
|
161
|
+
onFailure?.(labelOf('empty'))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!reviewBeforeSend) {
|
|
166
|
+
void onRecorded(file)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setPending({ file, objectURL: URL.createObjectURL(blob) })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
recorderRef.current = recorder
|
|
174
|
+
recorder.start()
|
|
175
|
+
autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
|
|
176
|
+
setIsRecording(true)
|
|
177
|
+
onRecordingChange?.(true)
|
|
178
|
+
} catch {
|
|
179
|
+
onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
|
|
180
|
+
}
|
|
181
|
+
// `labelOf` lê `labels` a cada render e não entra aqui; o que importa para recriar o gravador
|
|
182
|
+
// são o teto de duração, o destino da gravação e se há etapa de revisão.
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
|
+
}, [maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange, reviewBeforeSend])
|
|
185
|
+
|
|
186
|
+
const toggleLabel = isRecording ? labelOf('stop') : labelOf('start')
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
/* O painel de revisão flutua sobre o botão em vez de ocupar espaço na barra: o microfone mora
|
|
190
|
+
na caixa do botão de enviar, e empurrar o composer para cima a cada gravação faria a
|
|
191
|
+
conversa saltar. */
|
|
192
|
+
<div className="relative flex-shrink-0">
|
|
193
|
+
{pending && (
|
|
194
|
+
<div
|
|
195
|
+
role="group"
|
|
196
|
+
aria-label={labelOf('review')}
|
|
197
|
+
/* Largura presa à viewport, não ao conteúdo: ancorado à direita do microfone, qualquer
|
|
198
|
+
largura fixa maior que a tela sangra para fora no celular. `flex-wrap` é a segunda
|
|
199
|
+
rede — se o player e os dois botões não couberem lado a lado, os botões descem. */
|
|
200
|
+
className="absolute bottom-full right-0 z-20 mb-2 flex w-[calc(100vw-2rem)] max-w-[20rem] flex-wrap items-center justify-end gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
|
201
|
+
>
|
|
202
|
+
<div className="min-w-0 flex-1">
|
|
203
|
+
<AudioPlayer src={pending.objectURL} />
|
|
204
|
+
</div>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={discard}
|
|
208
|
+
data-cv-tooltip={labelOf('discard')}
|
|
209
|
+
aria-label={labelOf('discard')}
|
|
210
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:hover:bg-gray-700"
|
|
211
|
+
>
|
|
212
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
|
213
|
+
</button>
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={confirm}
|
|
217
|
+
data-cv-tooltip={labelOf('send')}
|
|
218
|
+
aria-label={labelOf('send')}
|
|
219
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
|
|
220
|
+
>
|
|
221
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
disabled={disabled || pending !== undefined}
|
|
228
|
+
onClick={() => (isRecording ? stop() : void start())}
|
|
229
|
+
data-cv-tooltip={toggleLabel}
|
|
230
|
+
aria-label={toggleLabel}
|
|
231
|
+
aria-pressed={isRecording}
|
|
232
|
+
/* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
|
|
233
|
+
vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
|
|
234
|
+
className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${
|
|
235
|
+
isRecording
|
|
236
|
+
? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
|
|
237
|
+
: 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
238
|
+
}`}
|
|
239
|
+
>
|
|
240
|
+
{isRecording ? (
|
|
241
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
|
|
242
|
+
) : (
|
|
243
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
|
|
244
|
+
)}
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { AudioTranscription } from './AudioTranscription'
|
|
5
|
+
import type { MessageTranscription } from './types'
|
|
6
|
+
|
|
7
|
+
function render(props: {
|
|
8
|
+
transcription?: MessageTranscription | null
|
|
9
|
+
onTranscribe?: () => Promise<void>
|
|
10
|
+
}): string {
|
|
11
|
+
return renderToStaticMarkup(<AudioTranscription {...props} />)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const noop = async () => undefined
|
|
15
|
+
|
|
16
|
+
describe('AudioTranscription', () => {
|
|
17
|
+
it('mostra o texto e oferece copiar', () => {
|
|
18
|
+
const markup = render({ transcription: { status: 'done', text: 'quero dois pães na chapa' } })
|
|
19
|
+
|
|
20
|
+
expect(markup).toContain('quero dois pães na chapa')
|
|
21
|
+
expect(markup).toContain('Copiar')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('deixa o texto selecionável — é o caminho de quem não tem clipboard disponível', () => {
|
|
25
|
+
const markup = render({ transcription: { status: 'done', text: 'oi' } })
|
|
26
|
+
|
|
27
|
+
expect(markup).toContain('select-all')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('não oferece copiar quando não há texto para copiar', () => {
|
|
31
|
+
const markup = render({ transcription: { status: 'done', text: '' } })
|
|
32
|
+
|
|
33
|
+
expect(markup).not.toContain('Copiar')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Silêncio processado é diferente de não transcrito: sem essa distinção o operador clica em
|
|
37
|
+
// transcrever de novo atrás de um texto que não existe.
|
|
38
|
+
it('diz "sem fala detectada" em áudio processado e vazio, sem oferecer transcrever', () => {
|
|
39
|
+
const markup = render({ transcription: { status: 'done', text: ' ' } })
|
|
40
|
+
|
|
41
|
+
expect(markup).toContain('Sem fala detectada')
|
|
42
|
+
expect(markup).not.toContain('Transcrever áudio')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('oferece transcrever quando nada foi avaliado ainda', () => {
|
|
46
|
+
const markup = render({ transcription: null, onTranscribe: noop })
|
|
47
|
+
|
|
48
|
+
expect(markup).toContain('Transcrever áudio')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Mesmo padrão de takeover/release: sem a porta, a afordância não existe — melhor que um botão
|
|
52
|
+
// que estoura no clique.
|
|
53
|
+
it('não desenha nada quando não há transcrição nem forma de pedir uma', () => {
|
|
54
|
+
expect(render({ transcription: null })).toBe('')
|
|
55
|
+
expect(render({})).toBe('')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('mostra falha com convite a tentar de novo', () => {
|
|
59
|
+
const markup = render({ transcription: { status: 'failed' }, onTranscribe: noop })
|
|
60
|
+
|
|
61
|
+
expect(markup).toContain('Falha ao transcrever')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('trata pendente como em andamento — já foi tentado e vai sair', () => {
|
|
65
|
+
const markup = render({ transcription: { status: 'pending' }, onTranscribe: noop })
|
|
66
|
+
|
|
67
|
+
expect(markup).toContain('Transcrevendo...')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('avisa formato não suportado sem oferecer retry — retentar não conserta codec', () => {
|
|
71
|
+
const markup = render({ transcription: { status: 'unsupported' }, onTranscribe: noop })
|
|
72
|
+
|
|
73
|
+
expect(markup).toContain('não suportado')
|
|
74
|
+
expect(markup).not.toContain('Transcrever novamente')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Medido: 1147 caracteres produziram uma bolha de 854px, mais alta que a área visível da conversa.
|
|
79
|
+
* Sem o recolhimento, uma nota de voz longa esconde as mensagens seguintes.
|
|
80
|
+
*/
|
|
81
|
+
it('recolhe transcrição longa e oferece ver o texto completo', () => {
|
|
82
|
+
const markup = render({ transcription: { status: 'done', text: 'palavra '.repeat(60).trim() } })
|
|
83
|
+
|
|
84
|
+
expect(markup).toContain('ver transcrição completa')
|
|
85
|
+
expect(markup).toContain('-webkit-line-clamp')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('não recolhe transcrição curta', () => {
|
|
89
|
+
const markup = render({ transcription: { status: 'done', text: 'quero dois pães' } })
|
|
90
|
+
|
|
91
|
+
expect(markup).not.toContain('ver transcrição completa')
|
|
92
|
+
expect(markup).not.toContain('-webkit-line-clamp')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Recolher é sobre altura na tela; o operador cola o pedido inteiro no sistema interno.
|
|
96
|
+
it('mantém o texto inteiro no DOM mesmo recolhido, para o copiar levar tudo', () => {
|
|
97
|
+
const markup = render({ transcription: { status: 'done', text: 'primeira ' + 'meio '.repeat(70) + 'ultima' } })
|
|
98
|
+
|
|
99
|
+
expect(markup).toContain('primeira')
|
|
100
|
+
expect(markup).toContain('ultima')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('oferece retranscrever quando já há texto e o host sabe transcrever', () => {
|
|
104
|
+
const markup = render({ transcription: { status: 'done', text: 'ruim' }, onTranscribe: noop })
|
|
105
|
+
|
|
106
|
+
expect(markup).toContain('Transcrever novamente')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('não oferece retranscrever quando o host não sabe transcrever', () => {
|
|
110
|
+
const markup = render({ transcription: { status: 'done', text: 'ok' } })
|
|
111
|
+
|
|
112
|
+
expect(markup).toContain('ok')
|
|
113
|
+
expect(markup).not.toContain('Transcrever novamente')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { Check, Copy, FileText, Loader2, RefreshCw } from 'lucide-react'
|
|
3
|
+
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
4
|
+
import { cn } from './lib/cn'
|
|
5
|
+
import type { MessageTranscription } from './types'
|
|
6
|
+
|
|
7
|
+
export interface AudioTranscriptionProps {
|
|
8
|
+
transcription?: MessageTranscription | null
|
|
9
|
+
/**
|
|
10
|
+
* Pede a transcrição ao backend. Ausente, o bloco nunca oferece o botão — um host em modo
|
|
11
|
+
* automático não tem rota para isso, e desenhar um botão que estoura no clique é pior do que não
|
|
12
|
+
* desenhar nada.
|
|
13
|
+
*
|
|
14
|
+
* O que for devolvido é exibido na hora. Sem isso o texto só apareceria no próximo refetch da
|
|
15
|
+
* lista, e transcrição não emite evento de tempo real: o operador clicaria, veria o spinner
|
|
16
|
+
* terminar e continuaria sem o texto na tela.
|
|
17
|
+
*/
|
|
18
|
+
onTranscribe?: () => Promise<MessageTranscription | void>
|
|
19
|
+
isMine?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Feedback de "copiado" some sozinho — confirmação que exige clique para fechar é ruído. */
|
|
23
|
+
const COPIED_FEEDBACK_MS = 2000
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Acima disto a transcrição nasce recolhida.
|
|
27
|
+
*
|
|
28
|
+
* Medido: 1147 caracteres (cerca de um minuto de fala) produziram uma bolha de 854px — mais alta que
|
|
29
|
+
* a área visível da conversa, empurrando todo o resto para fora. Nota de voz de três minutos
|
|
30
|
+
* triplicaria. O limite é em caracteres, e não em linhas de CSS, porque a decisão precisa acontecer
|
|
31
|
+
* antes de renderizar: `line-clamp` sozinho esconde o texto mas ainda paga o layout inteiro.
|
|
32
|
+
*/
|
|
33
|
+
const COLLAPSE_ABOVE_CHARS = 320
|
|
34
|
+
|
|
35
|
+
/** Linhas visíveis quando recolhido — o bastante para saber do que o cliente está falando. */
|
|
36
|
+
const COLLAPSED_LINE_CLAMP = 4
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Transcrição sob a nota de voz, com botão de copiar.
|
|
40
|
+
*
|
|
41
|
+
* Copiar é o motivo de o bloco existir: o operador cola o pedido do cliente no sistema interno, no
|
|
42
|
+
* orçamento, na busca. Seleção manual de texto dentro de um balão de chat é exatamente onde o
|
|
43
|
+
* arrasto do mouse falha — pega o balão vizinho, o horário, o nome do remetente.
|
|
44
|
+
*
|
|
45
|
+
* `navigator.clipboard.writeText` com try/catch silencioso, mesmo padrão de `MessageText.tsx`: a API
|
|
46
|
+
* exige contexto seguro e permissão, e um host servindo em HTTP simples não a tem. Falhar sem alarme
|
|
47
|
+
* é o certo — o texto continua na tela para seleção manual.
|
|
48
|
+
*/
|
|
49
|
+
export function AudioTranscription({ transcription, onTranscribe, isMine = false }: AudioTranscriptionProps) {
|
|
50
|
+
const { transcription: locales } = useConversationLocales()
|
|
51
|
+
const [hasCopied, setHasCopied] = useState(false)
|
|
52
|
+
const [isTranscribing, setIsTranscribing] = useState(false)
|
|
53
|
+
const [hasRequestFailed, setHasRequestFailed] = useState(false)
|
|
54
|
+
/**
|
|
55
|
+
* Resultado que acabou de voltar do backend, exibido antes de a lista ser refeita.
|
|
56
|
+
*
|
|
57
|
+
* Tem precedência sobre a prop porque é o dado mais novo: o refetch, quando vier, traz a mesma
|
|
58
|
+
* coisa. Sem isto o clique terminaria em nada visível.
|
|
59
|
+
*/
|
|
60
|
+
const [justTranscribed, setJustTranscribed] = useState<MessageTranscription | undefined>()
|
|
61
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
62
|
+
|
|
63
|
+
const effective = justTranscribed ?? transcription
|
|
64
|
+
const text = effective?.text?.trim() ?? ''
|
|
65
|
+
const status = effective?.status
|
|
66
|
+
|
|
67
|
+
// Nasce recolhido e o operador decide: abrir tudo por padrão faria a nota de voz longa esconder as
|
|
68
|
+
// mensagens seguintes, que é justamente o contexto de que ele precisa para responder.
|
|
69
|
+
const isLong = text.length > COLLAPSE_ABOVE_CHARS
|
|
70
|
+
const isTruncated = isLong && !isExpanded
|
|
71
|
+
const isDone = status === 'done'
|
|
72
|
+
const hasText = isDone && text.length > 0
|
|
73
|
+
// Silêncio já processado: dizer "sem fala detectada" evita o operador clicar em transcrever de novo
|
|
74
|
+
// atrás de um texto que não existe.
|
|
75
|
+
const isSilent = isDone && text.length === 0
|
|
76
|
+
|
|
77
|
+
const handleCopy = useCallback(async () => {
|
|
78
|
+
if (!text) return
|
|
79
|
+
try {
|
|
80
|
+
await navigator.clipboard.writeText(text)
|
|
81
|
+
setHasCopied(true)
|
|
82
|
+
setTimeout(() => setHasCopied(false), COPIED_FEEDBACK_MS)
|
|
83
|
+
} catch {
|
|
84
|
+
// Clipboard indisponível (contexto não seguro, permissão negada) — o texto segue selecionável.
|
|
85
|
+
}
|
|
86
|
+
}, [text])
|
|
87
|
+
|
|
88
|
+
const handleTranscribe = useCallback(async () => {
|
|
89
|
+
if (!onTranscribe || isTranscribing) return
|
|
90
|
+
setIsTranscribing(true)
|
|
91
|
+
setHasRequestFailed(false)
|
|
92
|
+
try {
|
|
93
|
+
const result = await onTranscribe()
|
|
94
|
+
if (result) setJustTranscribed(result)
|
|
95
|
+
} catch {
|
|
96
|
+
setHasRequestFailed(true)
|
|
97
|
+
} finally {
|
|
98
|
+
setIsTranscribing(false)
|
|
99
|
+
}
|
|
100
|
+
}, [onTranscribe, isTranscribing])
|
|
101
|
+
|
|
102
|
+
const dividerClass = isMine ? 'border-black/10 dark:border-white/10' : 'border-black/10 dark:border-white/10'
|
|
103
|
+
|
|
104
|
+
// Nada avaliado e sem rota para pedir: o balão fica como era antes da transcrição existir.
|
|
105
|
+
if (!status && !onTranscribe) return null
|
|
106
|
+
|
|
107
|
+
if (!status || status === 'pending' || status === 'failed') {
|
|
108
|
+
return (
|
|
109
|
+
<div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
|
|
110
|
+
<TranscribeButton
|
|
111
|
+
label={resolveActionLabel({ status, hasRequestFailed, isTranscribing, locales })}
|
|
112
|
+
isBusy={isTranscribing}
|
|
113
|
+
onClick={handleTranscribe}
|
|
114
|
+
isDisabled={!onTranscribe}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (status === 'unsupported') {
|
|
121
|
+
return (
|
|
122
|
+
<div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
|
|
123
|
+
<p className="text-xs italic text-gray-500 dark:text-gray-400">{locales.unsupported}</p>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
|
|
130
|
+
<div className="mb-0.5 flex items-center gap-1.5">
|
|
131
|
+
<FileText size={11} className="flex-shrink-0 text-gray-500 dark:text-gray-400" aria-hidden />
|
|
132
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
133
|
+
{locales.label}
|
|
134
|
+
</span>
|
|
135
|
+
|
|
136
|
+
{hasText && (
|
|
137
|
+
<button
|
|
138
|
+
onClick={handleCopy}
|
|
139
|
+
data-cv-tooltip={locales.copy}
|
|
140
|
+
aria-label={hasCopied ? locales.copied : locales.copy}
|
|
141
|
+
className="ml-auto flex flex-shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
|
142
|
+
>
|
|
143
|
+
{hasCopied ? (
|
|
144
|
+
<>
|
|
145
|
+
<Check size={11} aria-hidden />
|
|
146
|
+
<span>{locales.copied}</span>
|
|
147
|
+
</>
|
|
148
|
+
) : (
|
|
149
|
+
<>
|
|
150
|
+
<Copy size={11} aria-hidden />
|
|
151
|
+
<span>{locales.copy}</span>
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{isSilent ? (
|
|
159
|
+
<p className="text-xs italic text-gray-500 dark:text-gray-400">{locales.empty}</p>
|
|
160
|
+
) : (
|
|
161
|
+
<>
|
|
162
|
+
{/* `select-all` para o clique único selecionar tudo: é o fallback quando o clipboard não
|
|
163
|
+
está disponível, e o caminho de quem prefere copiar com o teclado. */}
|
|
164
|
+
<p
|
|
165
|
+
className={cn(
|
|
166
|
+
'select-all whitespace-pre-wrap break-words text-[13px] leading-[18px] text-gray-700 dark:text-gray-200',
|
|
167
|
+
isTruncated && 'overflow-hidden',
|
|
168
|
+
)}
|
|
169
|
+
style={
|
|
170
|
+
isTruncated
|
|
171
|
+
? {
|
|
172
|
+
display: '-webkit-box',
|
|
173
|
+
WebkitBoxOrient: 'vertical',
|
|
174
|
+
WebkitLineClamp: COLLAPSED_LINE_CLAMP,
|
|
175
|
+
}
|
|
176
|
+
: undefined
|
|
177
|
+
}
|
|
178
|
+
>
|
|
179
|
+
{text}
|
|
180
|
+
</p>
|
|
181
|
+
{/* O botão de copiar leva o texto INTEIRO, recolhido ou não — recolher é sobre altura na
|
|
182
|
+
tela, não sobre o que o operador cola no sistema interno. */}
|
|
183
|
+
{isLong && (
|
|
184
|
+
<button
|
|
185
|
+
data-cv-tooltip={isExpanded ? locales.showLess : locales.showMore} aria-label={isExpanded ? locales.showLess : locales.showMore}
|
|
186
|
+
onClick={() => setIsExpanded((current) => !current)}
|
|
187
|
+
className="mt-1 text-[11px] font-medium text-gray-500 underline decoration-dotted hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
188
|
+
>
|
|
189
|
+
{isExpanded ? locales.showLess : locales.showMore}
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Retranscrever fica escondido atrás do hover do balão: é ação rara e paga cota de engine. */}
|
|
196
|
+
{onTranscribe && (
|
|
197
|
+
<button
|
|
198
|
+
data-cv-tooltip={isTranscribing ? locales.transcribing : locales.retry} aria-label={isTranscribing ? locales.transcribing : locales.retry}
|
|
199
|
+
onClick={handleTranscribe}
|
|
200
|
+
disabled={isTranscribing}
|
|
201
|
+
className="mt-1 flex items-center gap-1 text-[11px] text-gray-400 opacity-0 transition-opacity hover:text-gray-600 focus:opacity-100 group-hover:opacity-100 disabled:opacity-50 dark:text-gray-500 dark:hover:text-gray-300"
|
|
202
|
+
>
|
|
203
|
+
{isTranscribing ? <Loader2 size={10} className="animate-spin" aria-hidden /> : <RefreshCw size={10} aria-hidden />}
|
|
204
|
+
<span>{isTranscribing ? locales.transcribing : locales.retry}</span>
|
|
205
|
+
</button>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveActionLabel(params: {
|
|
212
|
+
status?: MessageTranscription['status']
|
|
213
|
+
hasRequestFailed: boolean
|
|
214
|
+
isTranscribing: boolean
|
|
215
|
+
locales: { transcribe: string; transcribing: string; retry: string; failed: string }
|
|
216
|
+
}): string {
|
|
217
|
+
if (params.isTranscribing) return params.locales.transcribing
|
|
218
|
+
if (params.hasRequestFailed) return params.locales.retry
|
|
219
|
+
// `pending` é cota estourada ou falha transitória do lado do servidor: já foi tentado e vai sair,
|
|
220
|
+
// mas o operador que não quer esperar a retomada pode forçar agora.
|
|
221
|
+
if (params.status === 'pending') return params.locales.transcribing
|
|
222
|
+
if (params.status === 'failed') return params.locales.failed
|
|
223
|
+
return params.locales.transcribe
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function TranscribeButton({
|
|
227
|
+
label,
|
|
228
|
+
isBusy,
|
|
229
|
+
isDisabled,
|
|
230
|
+
onClick,
|
|
231
|
+
}: {
|
|
232
|
+
label: string
|
|
233
|
+
isBusy: boolean
|
|
234
|
+
isDisabled: boolean
|
|
235
|
+
onClick: () => void
|
|
236
|
+
}) {
|
|
237
|
+
return (
|
|
238
|
+
<button
|
|
239
|
+
data-cv-tooltip={label} aria-label={label}
|
|
240
|
+
onClick={onClick}
|
|
241
|
+
disabled={isBusy || isDisabled}
|
|
242
|
+
className="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 disabled:opacity-60 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
|
243
|
+
>
|
|
244
|
+
{isBusy ? (
|
|
245
|
+
<Loader2 size={11} className="animate-spin" aria-hidden />
|
|
246
|
+
) : (
|
|
247
|
+
<FileText size={11} aria-hidden />
|
|
248
|
+
)}
|
|
249
|
+
<span>{label}</span>
|
|
250
|
+
</button>
|
|
251
|
+
)
|
|
252
|
+
}
|
package/src/Avatar.tsx
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
export interface AvatarLabels {
|
|
2
|
+
/** Lido por leitor de tela quando não há nome nem imagem — a silhueta genérica. */
|
|
3
|
+
unnamedContact: string
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
export interface AvatarProps {
|
|
2
7
|
name?: string | null
|
|
3
8
|
avatarUrl?: string
|
|
4
9
|
size?: 'sm' | 'md' | 'lg'
|
|
5
10
|
className?: string
|
|
11
|
+
labels?: Partial<AvatarLabels>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_AVATAR_LABELS: AvatarLabels = {
|
|
15
|
+
unnamedContact: 'Contato sem nome',
|
|
6
16
|
}
|
|
7
17
|
|
|
8
18
|
const sizeClasses = {
|
|
@@ -36,8 +46,9 @@ function getBgColor(name: string | undefined | null): string {
|
|
|
36
46
|
|
|
37
47
|
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/Avatar.tsx —
|
|
38
48
|
// mesmas iniciais (primeira+última letra), mesmo hash de cor, mesmas classes de tamanho.
|
|
39
|
-
export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarProps) {
|
|
49
|
+
export function Avatar({ name, avatarUrl, size = 'md', className = '', labels }: AvatarProps) {
|
|
40
50
|
const sizeClass = sizeClasses[size]
|
|
51
|
+
const unnamedContactLabel = labels?.unnamedContact ?? DEFAULT_AVATAR_LABELS.unnamedContact
|
|
41
52
|
|
|
42
53
|
if (avatarUrl) {
|
|
43
54
|
return (
|
|
@@ -58,7 +69,7 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarP
|
|
|
58
69
|
<div
|
|
59
70
|
className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white flex-shrink-0 ${className}`}
|
|
60
71
|
role="img"
|
|
61
|
-
aria-label=
|
|
72
|
+
aria-label={unnamedContactLabel}
|
|
62
73
|
>
|
|
63
74
|
<svg viewBox="0 0 24 24" fill="currentColor" className="h-[60%] w-[60%]" aria-hidden>
|
|
64
75
|
<path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5Z" />
|
|
@@ -70,7 +81,7 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarP
|
|
|
70
81
|
return (
|
|
71
82
|
<div
|
|
72
83
|
className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 ${className}`}
|
|
73
|
-
|
|
84
|
+
data-cv-tooltip={name}
|
|
74
85
|
>
|
|
75
86
|
{getInitials(name)}
|
|
76
87
|
</div>
|