@traky-app/ui 1.6.3 → 1.6.5
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/package.json +15 -1
- package/registry/traky/json/city-select.json +19 -0
- package/registry/traky/json/country-select.json +24 -0
- package/registry/traky/json/geo-search-select.json +24 -0
- package/registry/traky/json/geo-selectors.json +21 -0
- package/registry/traky/json/google-maps-address-picker.json +22 -0
- package/registry/traky/json/internal-chat-panel.json +1 -1
- package/registry/traky/json/lib-geo-city-codes.json +14 -0
- package/registry/traky/json/lib-geo-countries.json +14 -0
- package/registry/traky/json/lib-geo-csc.json +17 -0
- package/registry/traky/json/lib-geo-locations.json +14 -0
- package/registry/traky/json/lib-geo-overrides.json +14 -0
- package/registry/traky/json/lib-geo-types.json +14 -0
- package/registry/traky/json/lib-google-maps-address.json +14 -0
- package/registry/traky/json/lib-phone-countries.json +14 -0
- package/registry/traky/json/lib-phone-e164.json +14 -0
- package/registry/traky/json/lib-utils.json +1 -1
- package/registry/traky/json/office-address-form-dialog.json +17 -0
- package/registry/traky/json/office-address-form.json +35 -0
- package/registry/traky/json/phone-number-input.json +25 -0
- package/registry/traky/json/state-select.json +19 -0
- package/registry/traky/lib/geo-city-codes.ts +234 -0
- package/registry/traky/lib/geo-countries.ts +75 -0
- package/registry/traky/lib/geo-csc.ts +82 -0
- package/registry/traky/lib/geo-locations.ts +171 -0
- package/registry/traky/lib/geo-overrides.ts +375 -0
- package/registry/traky/lib/geo-types.ts +34 -0
- package/registry/traky/lib/google-maps-address.ts +100 -0
- package/registry/traky/lib/phone-countries.ts +380 -0
- package/registry/traky/lib/phone-e164.ts +66 -0
- package/registry/traky/lib/utils.ts +24 -0
- package/registry/traky/registry.json +180 -0
- package/registry/traky/ui/city-select.tsx +113 -0
- package/registry/traky/ui/country-select.tsx +233 -0
- package/registry/traky/ui/geo-search-select.tsx +136 -0
- package/registry/traky/ui/geo-selectors.tsx +162 -0
- package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
- package/registry/traky/ui/internal-chat-panel.tsx +1417 -71
- package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
- package/registry/traky/ui/office-address-form.tsx +914 -0
- package/registry/traky/ui/phone-number-input.tsx +253 -0
- package/registry/traky/ui/state-select.tsx +107 -0
- package/src/lib/geo-city-codes.ts +234 -0
- package/src/lib/geo-countries.ts +75 -0
- package/src/lib/geo-csc.ts +82 -0
- package/src/lib/geo-locations.ts +171 -0
- package/src/lib/geo-overrides.ts +375 -0
- package/src/lib/geo-types.ts +34 -0
- package/src/lib/google-maps-address.ts +100 -0
- package/src/lib/phone-countries.ts +380 -0
- package/src/lib/phone-e164.ts +66 -0
- package/src/test/setup.ts +31 -0
- package/src/ui/__tests__/geo-selects.test.tsx +504 -0
- package/src/ui/city-select.tsx +113 -0
- package/src/ui/country-select.tsx +233 -0
- package/src/ui/geo-search-select.tsx +136 -0
- package/src/ui/geo-selectors.tsx +162 -0
- package/src/ui/google-maps-address-picker.tsx +422 -0
- package/src/ui/internal-chat-panel.tsx +1417 -71
- package/src/ui/office-address-form-dialog.tsx +16 -0
- package/src/ui/office-address-form.tsx +914 -0
- package/src/ui/phone-number-input.tsx +253 -0
- package/src/ui/state-select.tsx +107 -0
|
@@ -2,12 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
IconArrowUp,
|
|
5
|
+
IconCheck,
|
|
6
|
+
IconChecks,
|
|
5
7
|
IconDotsVertical,
|
|
8
|
+
IconFile,
|
|
9
|
+
IconHeadset,
|
|
6
10
|
IconLoader2,
|
|
11
|
+
IconMicrophone,
|
|
7
12
|
IconMessages,
|
|
8
13
|
IconPackage,
|
|
9
14
|
IconPaperclip,
|
|
15
|
+
IconPhoto,
|
|
16
|
+
IconPlayerPause,
|
|
17
|
+
IconPlayerPlay,
|
|
18
|
+
IconSquare,
|
|
10
19
|
IconPlus,
|
|
20
|
+
IconScreenShare,
|
|
11
21
|
IconSparkles,
|
|
12
22
|
IconTrash,
|
|
13
23
|
IconTruckLoading,
|
|
@@ -105,6 +115,29 @@ export type InternalChatRoom = {
|
|
|
105
115
|
id: string;
|
|
106
116
|
name: string;
|
|
107
117
|
isOrganization?: boolean;
|
|
118
|
+
/** Canal reservado con el administrador / soporte Traky. */
|
|
119
|
+
isSupport?: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Acuse de recibo estilo mensajería (1 palomita = enviado, 2 grises = llegó, 2 azules = leído). */
|
|
123
|
+
export type InternalChatMessageReceiptStatus =
|
|
124
|
+
| "pending"
|
|
125
|
+
| "sent"
|
|
126
|
+
| "delivered"
|
|
127
|
+
| "read";
|
|
128
|
+
|
|
129
|
+
export type InternalChatMessageAttachment = {
|
|
130
|
+
kind: "image" | "file" | "video";
|
|
131
|
+
name: string;
|
|
132
|
+
url?: string;
|
|
133
|
+
sizeLabel?: string;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const SCREEN_RECORD_MAX_MS = 60_000;
|
|
137
|
+
|
|
138
|
+
export type InternalChatMessageAudio = {
|
|
139
|
+
src: string;
|
|
140
|
+
durationMs?: number;
|
|
108
141
|
};
|
|
109
142
|
|
|
110
143
|
export type InternalChatMessageLine = {
|
|
@@ -114,6 +147,11 @@ export type InternalChatMessageLine = {
|
|
|
114
147
|
initials: string;
|
|
115
148
|
text: string;
|
|
116
149
|
at: string;
|
|
150
|
+
kind?: "text" | "audio";
|
|
151
|
+
audio?: InternalChatMessageAudio;
|
|
152
|
+
attachments?: InternalChatMessageAttachment[];
|
|
153
|
+
/** Solo mensajes propios (`role: "me"`). */
|
|
154
|
+
receiptStatus?: InternalChatMessageReceiptStatus;
|
|
117
155
|
};
|
|
118
156
|
|
|
119
157
|
export type InternalChatMessagesStatus =
|
|
@@ -151,11 +189,36 @@ export type InternalChatPanelLabels = {
|
|
|
151
189
|
messageAriaLabel: string;
|
|
152
190
|
attachAriaLabel: string;
|
|
153
191
|
attachPhotos: string;
|
|
192
|
+
attachFiles: string;
|
|
193
|
+
attachAudio: string;
|
|
194
|
+
attachAudioFile: string;
|
|
195
|
+
attachmentPending: string;
|
|
196
|
+
voiceRecordTitle: string;
|
|
197
|
+
voiceRecordHint: string;
|
|
198
|
+
voiceRecordStart: string;
|
|
199
|
+
voiceRecordStop: string;
|
|
200
|
+
voiceRecordSend: string;
|
|
201
|
+
voiceRecordCancel: string;
|
|
202
|
+
voiceRecordPermissionError: string;
|
|
203
|
+
screenCaptureAriaLabel: string;
|
|
204
|
+
screenRecordTitle: string;
|
|
205
|
+
screenRecordHint: string;
|
|
206
|
+
screenRecordStart: string;
|
|
207
|
+
screenRecordStop: string;
|
|
208
|
+
screenRecordSend: string;
|
|
209
|
+
screenRecordCancel: string;
|
|
210
|
+
screenRecordPermissionError: string;
|
|
211
|
+
screenRecordMaxReached: string;
|
|
212
|
+
screenRecordBackgroundHint: string;
|
|
213
|
+
screenRecordOpenChatToFinish: string;
|
|
154
214
|
sendSrOnly: string;
|
|
155
215
|
orgRoomSuffix: string;
|
|
156
216
|
orgRoomTooltip: string;
|
|
157
217
|
slashMentionHint: string;
|
|
158
218
|
selectRoomHint: string;
|
|
219
|
+
supportChat: string;
|
|
220
|
+
supportChatTooltip: string;
|
|
221
|
+
supportRoomDescription: string;
|
|
159
222
|
};
|
|
160
223
|
|
|
161
224
|
const DEFAULT_LABELS: InternalChatPanelLabels = {
|
|
@@ -189,12 +252,42 @@ const DEFAULT_LABELS: InternalChatPanelLabels = {
|
|
|
189
252
|
messagePlaceholderNoRoom: "Selecciona una sala…",
|
|
190
253
|
messageAriaLabel: "Mensaje",
|
|
191
254
|
attachAriaLabel: "Adjuntar",
|
|
192
|
-
attachPhotos: "Fotos
|
|
255
|
+
attachPhotos: "Fotos",
|
|
256
|
+
attachFiles: "Archivos",
|
|
257
|
+
attachAudio: "Grabar nota de voz",
|
|
258
|
+
attachAudioFile: "Enviar audio guardado",
|
|
259
|
+
attachmentPending: "Vista previa · conexión lista",
|
|
260
|
+
voiceRecordTitle: "Grabar nota de voz",
|
|
261
|
+
voiceRecordHint: "Pulsa grabar, habla y envía cuando termines.",
|
|
262
|
+
voiceRecordStart: "Grabar",
|
|
263
|
+
voiceRecordStop: "Detener",
|
|
264
|
+
voiceRecordSend: "Enviar audio",
|
|
265
|
+
voiceRecordCancel: "Cancelar",
|
|
266
|
+
voiceRecordPermissionError:
|
|
267
|
+
"No pudimos usar el micrófono. Revisa los permisos del navegador.",
|
|
268
|
+
screenCaptureAriaLabel: "Capturar pantalla",
|
|
269
|
+
screenRecordTitle: "Captura de pantalla",
|
|
270
|
+
screenRecordHint:
|
|
271
|
+
"Inicia y elige compartir la pestaña de Traky (no solo el panel del chat). Puedes cerrar el chat y seguir navegando (máx. 1 min).",
|
|
272
|
+
screenRecordStart: "Iniciar captura",
|
|
273
|
+
screenRecordStop: "Detener",
|
|
274
|
+
screenRecordSend: "Enviar video",
|
|
275
|
+
screenRecordCancel: "Cancelar",
|
|
276
|
+
screenRecordPermissionError:
|
|
277
|
+
"No se pudo iniciar la captura. Revisa los permisos del navegador.",
|
|
278
|
+
screenRecordMaxReached: "Se alcanzó el máximo de 1 minuto.",
|
|
279
|
+
screenRecordBackgroundHint:
|
|
280
|
+
"Puedes cerrar el chat; la grabación continúa. Vuelve aquí para detener y enviar.",
|
|
281
|
+
screenRecordOpenChatToFinish: "Abrir chat · captura en curso",
|
|
193
282
|
sendSrOnly: "Enviar",
|
|
194
283
|
orgRoomSuffix: " · Sala de toda la organización",
|
|
195
284
|
orgRoomTooltip: " — toda la organización (no se elimina)",
|
|
196
285
|
slashMentionHint: "menciones (guías y paquetes)",
|
|
197
286
|
selectRoomHint: "Selecciona una sala o crea una nueva.",
|
|
287
|
+
supportChat: "Soporte Traky",
|
|
288
|
+
supportChatTooltip: "Chat con el equipo de soporte de Traky",
|
|
289
|
+
supportRoomDescription:
|
|
290
|
+
"Canal directo con soporte Traky para tu organización.",
|
|
198
291
|
};
|
|
199
292
|
|
|
200
293
|
const DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {
|
|
@@ -249,6 +342,867 @@ function stepMentionHighlight(
|
|
|
249
342
|
return current;
|
|
250
343
|
}
|
|
251
344
|
|
|
345
|
+
function formatUnreadBadge(count: number): string {
|
|
346
|
+
if (count <= 0) return "";
|
|
347
|
+
if (count > 9) return "9+";
|
|
348
|
+
return String(count);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Contador en círculo perfecto (sin `min-w`/`px` que lo ovalan). */
|
|
352
|
+
function unreadCountBadgeClasses(label: string): string {
|
|
353
|
+
const wide = label.length > 1;
|
|
354
|
+
return cn(
|
|
355
|
+
"pointer-events-none absolute flex shrink-0 items-center justify-center rounded-full bg-destructive font-bold tabular-nums leading-none text-destructive-foreground shadow-sm",
|
|
356
|
+
wide
|
|
357
|
+
? "-right-1 -top-1 size-5 text-[0.5rem]"
|
|
358
|
+
: "-right-0.5 -top-0.5 size-4 text-[0.625rem]",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function MessageReceiptTicks({
|
|
363
|
+
status,
|
|
364
|
+
}: {
|
|
365
|
+
status: InternalChatMessageReceiptStatus;
|
|
366
|
+
}) {
|
|
367
|
+
if (status === "pending") {
|
|
368
|
+
return (
|
|
369
|
+
<IconLoader2
|
|
370
|
+
className="size-3 shrink-0 animate-spin text-muted-foreground"
|
|
371
|
+
aria-hidden
|
|
372
|
+
/>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
if (status === "sent") {
|
|
376
|
+
return (
|
|
377
|
+
<IconCheck
|
|
378
|
+
className="size-3.5 shrink-0 text-muted-foreground"
|
|
379
|
+
aria-hidden
|
|
380
|
+
/>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const read = status === "read";
|
|
384
|
+
return (
|
|
385
|
+
<IconChecks
|
|
386
|
+
className={cn(
|
|
387
|
+
"size-3.5 shrink-0",
|
|
388
|
+
read ? "text-sky-500 dark:text-sky-400" : "text-muted-foreground",
|
|
389
|
+
)}
|
|
390
|
+
aria-hidden
|
|
391
|
+
/>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function formatAudioDuration(ms: number): string {
|
|
396
|
+
const totalSec = Math.max(0, Math.round(ms / 1000));
|
|
397
|
+
const m = Math.floor(totalSec / 60);
|
|
398
|
+
const s = totalSec % 60;
|
|
399
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function pseudoWaveformBars(seed: string, count = 28): number[] {
|
|
403
|
+
let h = 0;
|
|
404
|
+
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
|
405
|
+
return Array.from({ length: count }, (_, i) => {
|
|
406
|
+
h = (h * 1103515245 + 12345 + i) >>> 0;
|
|
407
|
+
return 0.25 + (h % 75) / 100;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function InternalChatAudioBubble({
|
|
412
|
+
audio,
|
|
413
|
+
seed,
|
|
414
|
+
mine,
|
|
415
|
+
}: {
|
|
416
|
+
audio: InternalChatMessageAudio;
|
|
417
|
+
seed: string;
|
|
418
|
+
mine: boolean;
|
|
419
|
+
}) {
|
|
420
|
+
const ref = useRef<HTMLAudioElement>(null);
|
|
421
|
+
const [playing, setPlaying] = useState(false);
|
|
422
|
+
const [durationMs, setDurationMs] = useState(audio.durationMs ?? 0);
|
|
423
|
+
const [progress, setProgress] = useState(0);
|
|
424
|
+
|
|
425
|
+
const bars = useMemo(() => pseudoWaveformBars(seed), [seed]);
|
|
426
|
+
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
const el = ref.current;
|
|
429
|
+
if (!el) return;
|
|
430
|
+
const onMeta = () => {
|
|
431
|
+
if (Number.isFinite(el.duration) && el.duration > 0) {
|
|
432
|
+
setDurationMs(Math.round(el.duration * 1000));
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const onTime = () => {
|
|
436
|
+
if (!el.duration) return;
|
|
437
|
+
setProgress(el.currentTime / el.duration);
|
|
438
|
+
};
|
|
439
|
+
const onEnd = () => {
|
|
440
|
+
setPlaying(false);
|
|
441
|
+
setProgress(0);
|
|
442
|
+
};
|
|
443
|
+
el.addEventListener("loadedmetadata", onMeta);
|
|
444
|
+
el.addEventListener("timeupdate", onTime);
|
|
445
|
+
el.addEventListener("ended", onEnd);
|
|
446
|
+
return () => {
|
|
447
|
+
el.removeEventListener("loadedmetadata", onMeta);
|
|
448
|
+
el.removeEventListener("timeupdate", onTime);
|
|
449
|
+
el.removeEventListener("ended", onEnd);
|
|
450
|
+
};
|
|
451
|
+
}, [audio.src]);
|
|
452
|
+
|
|
453
|
+
const toggle = useCallback(() => {
|
|
454
|
+
const el = ref.current;
|
|
455
|
+
if (!el) return;
|
|
456
|
+
if (playing) {
|
|
457
|
+
el.pause();
|
|
458
|
+
setPlaying(false);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
void el.play();
|
|
462
|
+
setPlaying(true);
|
|
463
|
+
}, [playing]);
|
|
464
|
+
|
|
465
|
+
const displayMs = durationMs || audio.durationMs || 0;
|
|
466
|
+
|
|
467
|
+
return (
|
|
468
|
+
<div className="flex min-w-[min(100%,14rem)] items-center gap-2">
|
|
469
|
+
<audio ref={ref} src={audio.src} preload="metadata" className="hidden" />
|
|
470
|
+
<Button
|
|
471
|
+
type="button"
|
|
472
|
+
variant={mine ? "secondary" : "outline"}
|
|
473
|
+
size="icon"
|
|
474
|
+
className="size-9 shrink-0 rounded-full"
|
|
475
|
+
aria-label={playing ? "Pausar audio" : "Reproducir audio"}
|
|
476
|
+
onClick={toggle}
|
|
477
|
+
>
|
|
478
|
+
{playing ? (
|
|
479
|
+
<IconPlayerPause className="size-4" />
|
|
480
|
+
) : (
|
|
481
|
+
<IconPlayerPlay className="size-4" />
|
|
482
|
+
)}
|
|
483
|
+
</Button>
|
|
484
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
485
|
+
<div className="flex h-7 items-end gap-px" aria-hidden>
|
|
486
|
+
{bars.map((h, i) => {
|
|
487
|
+
const lit = progress > 0 && i / bars.length <= progress;
|
|
488
|
+
return (
|
|
489
|
+
<span
|
|
490
|
+
key={i}
|
|
491
|
+
className={cn(
|
|
492
|
+
"w-[3px] shrink-0 rounded-full transition-colors",
|
|
493
|
+
lit
|
|
494
|
+
? mine
|
|
495
|
+
? "bg-foreground/70"
|
|
496
|
+
: "bg-primary"
|
|
497
|
+
: "bg-muted-foreground/35",
|
|
498
|
+
)}
|
|
499
|
+
style={{ height: `${Math.round(h * 100)}%` }}
|
|
500
|
+
/>
|
|
501
|
+
);
|
|
502
|
+
})}
|
|
503
|
+
</div>
|
|
504
|
+
<span className="text-[0.65rem] tabular-nums text-muted-foreground">
|
|
505
|
+
{displayMs > 0 ? formatAudioDuration(displayMs) : "0:00"}
|
|
506
|
+
</span>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function InternalChatAttachmentStack({
|
|
513
|
+
items,
|
|
514
|
+
labels,
|
|
515
|
+
}: {
|
|
516
|
+
items: InternalChatMessageAttachment[];
|
|
517
|
+
labels: InternalChatPanelLabels;
|
|
518
|
+
}) {
|
|
519
|
+
if (items.length === 0) return null;
|
|
520
|
+
return (
|
|
521
|
+
<div className="flex flex-col gap-1.5">
|
|
522
|
+
{items.map((att, i) => (
|
|
523
|
+
<div
|
|
524
|
+
key={`${att.name}-${i}`}
|
|
525
|
+
className="overflow-hidden rounded-lg border border-border bg-muted/40"
|
|
526
|
+
>
|
|
527
|
+
{att.kind === "video" && att.url ? (
|
|
528
|
+
<video
|
|
529
|
+
src={att.url}
|
|
530
|
+
controls
|
|
531
|
+
playsInline
|
|
532
|
+
className="max-h-48 w-full bg-black object-contain"
|
|
533
|
+
/>
|
|
534
|
+
) : att.kind === "image" && att.url ? (
|
|
535
|
+
<img
|
|
536
|
+
src={att.url}
|
|
537
|
+
alt={att.name}
|
|
538
|
+
className="max-h-40 w-full object-cover"
|
|
539
|
+
/>
|
|
540
|
+
) : (
|
|
541
|
+
<div className="flex items-center gap-2 px-3 py-2 text-sm">
|
|
542
|
+
{att.kind === "image" ? (
|
|
543
|
+
<IconPhoto className="size-4 shrink-0 text-muted-foreground" />
|
|
544
|
+
) : att.kind === "video" ? (
|
|
545
|
+
<IconScreenShare className="size-4 shrink-0 text-muted-foreground" />
|
|
546
|
+
) : (
|
|
547
|
+
<IconFile className="size-4 shrink-0 text-muted-foreground" />
|
|
548
|
+
)}
|
|
549
|
+
<span className="min-w-0 truncate font-medium">{att.name}</span>
|
|
550
|
+
{att.sizeLabel ? (
|
|
551
|
+
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
|
552
|
+
{att.sizeLabel}
|
|
553
|
+
</span>
|
|
554
|
+
) : null}
|
|
555
|
+
</div>
|
|
556
|
+
)}
|
|
557
|
+
{!att.url ? (
|
|
558
|
+
<p className="border-t border-border px-3 py-1 text-[0.65rem] text-muted-foreground">
|
|
559
|
+
{labels.attachmentPending}
|
|
560
|
+
</p>
|
|
561
|
+
) : null}
|
|
562
|
+
</div>
|
|
563
|
+
))}
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function InternalChatVoiceRecorderBar({
|
|
569
|
+
labels,
|
|
570
|
+
onClose,
|
|
571
|
+
onSendBlob,
|
|
572
|
+
onSendLegacy,
|
|
573
|
+
}: {
|
|
574
|
+
labels: InternalChatPanelLabels;
|
|
575
|
+
onClose: () => void;
|
|
576
|
+
onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>;
|
|
577
|
+
onSendLegacy?: () => void | Promise<void>;
|
|
578
|
+
}) {
|
|
579
|
+
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
580
|
+
const chunksRef = useRef<Blob[]>([]);
|
|
581
|
+
const startedAtRef = useRef(0);
|
|
582
|
+
const [recording, setRecording] = useState(false);
|
|
583
|
+
const [blob, setBlob] = useState<Blob | null>(null);
|
|
584
|
+
const [durationMs, setDurationMs] = useState(0);
|
|
585
|
+
const [error, setError] = useState<string | null>(null);
|
|
586
|
+
const [sending, setSending] = useState(false);
|
|
587
|
+
|
|
588
|
+
const stopTracks = useCallback((recorder: MediaRecorder | null) => {
|
|
589
|
+
recorder?.stream.getTracks().forEach((t) => t.stop());
|
|
590
|
+
}, []);
|
|
591
|
+
|
|
592
|
+
const startRecording = useCallback(async () => {
|
|
593
|
+
setError(null);
|
|
594
|
+
setBlob(null);
|
|
595
|
+
chunksRef.current = [];
|
|
596
|
+
try {
|
|
597
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
598
|
+
const mime = MediaRecorder.isTypeSupported("audio/webm")
|
|
599
|
+
? "audio/webm"
|
|
600
|
+
: "audio/mp4";
|
|
601
|
+
const recorder = new MediaRecorder(stream, { mimeType: mime });
|
|
602
|
+
recorderRef.current = recorder;
|
|
603
|
+
recorder.ondataavailable = (e) => {
|
|
604
|
+
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
605
|
+
};
|
|
606
|
+
recorder.onstop = () => {
|
|
607
|
+
const next = new Blob(chunksRef.current, { type: mime });
|
|
608
|
+
setBlob(next);
|
|
609
|
+
const ms = Math.max(
|
|
610
|
+
0,
|
|
611
|
+
Date.now() - (startedAtRef.current || Date.now()),
|
|
612
|
+
);
|
|
613
|
+
setDurationMs(ms);
|
|
614
|
+
stopTracks(recorder);
|
|
615
|
+
recorderRef.current = null;
|
|
616
|
+
};
|
|
617
|
+
startedAtRef.current = Date.now();
|
|
618
|
+
recorder.start();
|
|
619
|
+
setRecording(true);
|
|
620
|
+
} catch {
|
|
621
|
+
setError(labels.voiceRecordPermissionError);
|
|
622
|
+
}
|
|
623
|
+
}, [labels.voiceRecordPermissionError, stopTracks]);
|
|
624
|
+
|
|
625
|
+
const stopRecording = useCallback(() => {
|
|
626
|
+
const rec = recorderRef.current;
|
|
627
|
+
if (!rec || rec.state === "inactive") return;
|
|
628
|
+
rec.stop();
|
|
629
|
+
setRecording(false);
|
|
630
|
+
}, []);
|
|
631
|
+
|
|
632
|
+
const handleClose = useCallback(() => {
|
|
633
|
+
if (recording) stopRecording();
|
|
634
|
+
stopTracks(recorderRef.current);
|
|
635
|
+
recorderRef.current = null;
|
|
636
|
+
onClose();
|
|
637
|
+
}, [onClose, recording, stopRecording, stopTracks]);
|
|
638
|
+
|
|
639
|
+
const handleSend = useCallback(async () => {
|
|
640
|
+
if (sending) return;
|
|
641
|
+
setSending(true);
|
|
642
|
+
try {
|
|
643
|
+
if (blob && onSendBlob) {
|
|
644
|
+
await onSendBlob(blob, durationMs);
|
|
645
|
+
} else if (onSendLegacy) {
|
|
646
|
+
await onSendLegacy();
|
|
647
|
+
}
|
|
648
|
+
onClose();
|
|
649
|
+
} finally {
|
|
650
|
+
setSending(false);
|
|
651
|
+
}
|
|
652
|
+
}, [blob, durationMs, onClose, onSendBlob, onSendLegacy, sending]);
|
|
653
|
+
|
|
654
|
+
const previewUrl = useMemo(
|
|
655
|
+
() => (blob ? URL.createObjectURL(blob) : null),
|
|
656
|
+
[blob],
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
return () => {
|
|
661
|
+
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
662
|
+
};
|
|
663
|
+
}, [previewUrl]);
|
|
664
|
+
|
|
665
|
+
return (
|
|
666
|
+
<div
|
|
667
|
+
className="mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2"
|
|
668
|
+
role="region"
|
|
669
|
+
aria-label={labels.voiceRecordTitle}
|
|
670
|
+
>
|
|
671
|
+
<div className="flex items-center justify-between gap-2">
|
|
672
|
+
<p className="text-xs font-medium text-foreground">
|
|
673
|
+
{labels.voiceRecordTitle}
|
|
674
|
+
</p>
|
|
675
|
+
<Button
|
|
676
|
+
type="button"
|
|
677
|
+
variant="ghost"
|
|
678
|
+
size="icon"
|
|
679
|
+
className="size-8 shrink-0"
|
|
680
|
+
aria-label={labels.voiceRecordCancel}
|
|
681
|
+
onClick={handleClose}
|
|
682
|
+
>
|
|
683
|
+
<IconX className="size-4" />
|
|
684
|
+
</Button>
|
|
685
|
+
</div>
|
|
686
|
+
{error ? (
|
|
687
|
+
<p className="mt-1 text-xs leading-snug text-destructive">{error}</p>
|
|
688
|
+
) : (
|
|
689
|
+
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">
|
|
690
|
+
{recording
|
|
691
|
+
? "Pulsa el cuadrado rojo para terminar."
|
|
692
|
+
: blob
|
|
693
|
+
? "Escucha la vista previa y envía cuando quieras."
|
|
694
|
+
: labels.voiceRecordHint}
|
|
695
|
+
</p>
|
|
696
|
+
)}
|
|
697
|
+
<div className="mt-2 flex items-center gap-2">
|
|
698
|
+
{previewUrl && blob ? (
|
|
699
|
+
<VoiceRecorderPreviewBar
|
|
700
|
+
src={previewUrl}
|
|
701
|
+
durationMs={durationMs}
|
|
702
|
+
/>
|
|
703
|
+
) : recording ? (
|
|
704
|
+
<>
|
|
705
|
+
<Button
|
|
706
|
+
type="button"
|
|
707
|
+
variant="destructive"
|
|
708
|
+
size="sm"
|
|
709
|
+
className="h-8 shrink-0 gap-1.5"
|
|
710
|
+
onClick={stopRecording}
|
|
711
|
+
>
|
|
712
|
+
<IconSquare className="size-3.5 fill-current" />
|
|
713
|
+
{labels.voiceRecordStop}
|
|
714
|
+
</Button>
|
|
715
|
+
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive">
|
|
716
|
+
<span className="size-2 shrink-0 animate-pulse rounded-full bg-destructive" />
|
|
717
|
+
Grabando…
|
|
718
|
+
</span>
|
|
719
|
+
</>
|
|
720
|
+
) : (
|
|
721
|
+
<Button
|
|
722
|
+
type="button"
|
|
723
|
+
variant="secondary"
|
|
724
|
+
size="sm"
|
|
725
|
+
className="h-8 gap-1.5"
|
|
726
|
+
onClick={() => void startRecording()}
|
|
727
|
+
>
|
|
728
|
+
<IconMicrophone className="size-3.5" />
|
|
729
|
+
{labels.voiceRecordStart}
|
|
730
|
+
</Button>
|
|
731
|
+
)}
|
|
732
|
+
<Button
|
|
733
|
+
type="button"
|
|
734
|
+
size="sm"
|
|
735
|
+
className="ml-auto h-8 shrink-0"
|
|
736
|
+
disabled={sending || (!blob && !onSendLegacy)}
|
|
737
|
+
onClick={() => void handleSend()}
|
|
738
|
+
>
|
|
739
|
+
{labels.voiceRecordSend}
|
|
740
|
+
</Button>
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function VoiceRecorderPreviewBar({
|
|
747
|
+
src,
|
|
748
|
+
durationMs,
|
|
749
|
+
}: {
|
|
750
|
+
src: string;
|
|
751
|
+
durationMs: number;
|
|
752
|
+
}) {
|
|
753
|
+
const ref = useRef<HTMLAudioElement>(null);
|
|
754
|
+
const [playing, setPlaying] = useState(false);
|
|
755
|
+
const [progress, setProgress] = useState(0);
|
|
756
|
+
const [resolvedMs, setResolvedMs] = useState(durationMs);
|
|
757
|
+
const bars = useMemo(() => pseudoWaveformBars("voice-recorder-preview"), []);
|
|
758
|
+
|
|
759
|
+
const toggle = useCallback(() => {
|
|
760
|
+
const el = ref.current;
|
|
761
|
+
if (!el) return;
|
|
762
|
+
if (playing) {
|
|
763
|
+
el.pause();
|
|
764
|
+
setPlaying(false);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
void el.play();
|
|
768
|
+
setPlaying(true);
|
|
769
|
+
}, [playing]);
|
|
770
|
+
|
|
771
|
+
useEffect(() => {
|
|
772
|
+
const el = ref.current;
|
|
773
|
+
if (!el) return;
|
|
774
|
+
const onMeta = () => {
|
|
775
|
+
if (Number.isFinite(el.duration) && el.duration > 0) {
|
|
776
|
+
setResolvedMs(Math.round(el.duration * 1000));
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
const onTime = () => {
|
|
780
|
+
if (!el.duration) return;
|
|
781
|
+
setProgress(el.currentTime / el.duration);
|
|
782
|
+
};
|
|
783
|
+
const onEnd = () => {
|
|
784
|
+
setPlaying(false);
|
|
785
|
+
setProgress(0);
|
|
786
|
+
};
|
|
787
|
+
el.addEventListener("loadedmetadata", onMeta);
|
|
788
|
+
el.addEventListener("timeupdate", onTime);
|
|
789
|
+
el.addEventListener("ended", onEnd);
|
|
790
|
+
return () => {
|
|
791
|
+
el.removeEventListener("loadedmetadata", onMeta);
|
|
792
|
+
el.removeEventListener("timeupdate", onTime);
|
|
793
|
+
el.removeEventListener("ended", onEnd);
|
|
794
|
+
};
|
|
795
|
+
}, [src]);
|
|
796
|
+
|
|
797
|
+
const displayMs = resolvedMs || durationMs;
|
|
798
|
+
|
|
799
|
+
return (
|
|
800
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
801
|
+
<audio ref={ref} src={src} preload="metadata" className="hidden" />
|
|
802
|
+
<Button
|
|
803
|
+
type="button"
|
|
804
|
+
variant="outline"
|
|
805
|
+
size="icon"
|
|
806
|
+
className="size-8 shrink-0 rounded-full"
|
|
807
|
+
aria-label={playing ? "Pausar vista previa" : "Reproducir vista previa"}
|
|
808
|
+
onClick={toggle}
|
|
809
|
+
>
|
|
810
|
+
{playing ? (
|
|
811
|
+
<IconPlayerPause className="size-4" />
|
|
812
|
+
) : (
|
|
813
|
+
<IconPlayerPlay className="size-4" />
|
|
814
|
+
)}
|
|
815
|
+
</Button>
|
|
816
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
817
|
+
<div className="flex h-6 items-end gap-px" aria-hidden>
|
|
818
|
+
{bars.map((h, i) => {
|
|
819
|
+
const lit = progress > 0 && i / bars.length <= progress;
|
|
820
|
+
return (
|
|
821
|
+
<span
|
|
822
|
+
key={i}
|
|
823
|
+
className={cn(
|
|
824
|
+
"w-[2px] shrink-0 rounded-full transition-colors",
|
|
825
|
+
lit ? "bg-primary" : "bg-muted-foreground/35",
|
|
826
|
+
)}
|
|
827
|
+
style={{ height: `${Math.round(h * 100)}%` }}
|
|
828
|
+
/>
|
|
829
|
+
);
|
|
830
|
+
})}
|
|
831
|
+
</div>
|
|
832
|
+
<span className="text-[0.65rem] tabular-nums text-muted-foreground">
|
|
833
|
+
{displayMs > 0 ? formatAudioDuration(displayMs) : "0:00"}
|
|
834
|
+
</span>
|
|
835
|
+
</div>
|
|
836
|
+
</div>
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function pickScreenRecorderMime(): string {
|
|
841
|
+
const candidates = [
|
|
842
|
+
"video/webm;codecs=vp9",
|
|
843
|
+
"video/webm;codecs=vp8",
|
|
844
|
+
"video/webm",
|
|
845
|
+
"video/mp4",
|
|
846
|
+
];
|
|
847
|
+
return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "video/webm";
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
type ScreenRecorderSession = {
|
|
851
|
+
recording: boolean;
|
|
852
|
+
elapsedMs: number;
|
|
853
|
+
blob: Blob | null;
|
|
854
|
+
error: string | null;
|
|
855
|
+
maxReached: boolean;
|
|
856
|
+
sending: boolean;
|
|
857
|
+
previewUrl: string | null;
|
|
858
|
+
canSend: boolean;
|
|
859
|
+
timerLabel: string;
|
|
860
|
+
active: boolean;
|
|
861
|
+
startCapture: () => Promise<void>;
|
|
862
|
+
stopRecording: () => void;
|
|
863
|
+
cancelSession: () => void;
|
|
864
|
+
sendRecording: () => Promise<void>;
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
function useInternalChatScreenRecorderSession(
|
|
868
|
+
labels: InternalChatPanelLabels,
|
|
869
|
+
onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>,
|
|
870
|
+
onRecordingChange?: (recording: boolean) => void,
|
|
871
|
+
): ScreenRecorderSession {
|
|
872
|
+
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
873
|
+
const streamRef = useRef<MediaStream | null>(null);
|
|
874
|
+
const chunksRef = useRef<Blob[]>([]);
|
|
875
|
+
const startedAtRef = useRef(0);
|
|
876
|
+
const durationMsRef = useRef(0);
|
|
877
|
+
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
878
|
+
const maxTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
879
|
+
const previewUrlRef = useRef<string | null>(null);
|
|
880
|
+
|
|
881
|
+
const [recording, setRecording] = useState(false);
|
|
882
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
883
|
+
const [blob, setBlob] = useState<Blob | null>(null);
|
|
884
|
+
const [error, setError] = useState<string | null>(null);
|
|
885
|
+
const [maxReached, setMaxReached] = useState(false);
|
|
886
|
+
const [sending, setSending] = useState(false);
|
|
887
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
888
|
+
|
|
889
|
+
const clearTimers = useCallback(() => {
|
|
890
|
+
if (tickRef.current) {
|
|
891
|
+
clearInterval(tickRef.current);
|
|
892
|
+
tickRef.current = null;
|
|
893
|
+
}
|
|
894
|
+
if (maxTimerRef.current) {
|
|
895
|
+
clearTimeout(maxTimerRef.current);
|
|
896
|
+
maxTimerRef.current = null;
|
|
897
|
+
}
|
|
898
|
+
}, []);
|
|
899
|
+
|
|
900
|
+
const stopStream = useCallback(() => {
|
|
901
|
+
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
902
|
+
streamRef.current = null;
|
|
903
|
+
}, []);
|
|
904
|
+
|
|
905
|
+
const revokePreview = useCallback(() => {
|
|
906
|
+
if (previewUrlRef.current) {
|
|
907
|
+
URL.revokeObjectURL(previewUrlRef.current);
|
|
908
|
+
previewUrlRef.current = null;
|
|
909
|
+
}
|
|
910
|
+
setPreviewUrl(null);
|
|
911
|
+
}, []);
|
|
912
|
+
|
|
913
|
+
const stopRecording = useCallback(
|
|
914
|
+
(reason?: "max") => {
|
|
915
|
+
const rec = recorderRef.current;
|
|
916
|
+
if (rec && rec.state !== "inactive") {
|
|
917
|
+
rec.stop();
|
|
918
|
+
} else {
|
|
919
|
+
clearTimers();
|
|
920
|
+
stopStream();
|
|
921
|
+
setRecording(false);
|
|
922
|
+
onRecordingChange?.(false);
|
|
923
|
+
}
|
|
924
|
+
if (reason === "max") setMaxReached(true);
|
|
925
|
+
},
|
|
926
|
+
[clearTimers, onRecordingChange, stopStream],
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
const startCapture = useCallback(async () => {
|
|
930
|
+
setError(null);
|
|
931
|
+
revokePreview();
|
|
932
|
+
setBlob(null);
|
|
933
|
+
setMaxReached(false);
|
|
934
|
+
setElapsedMs(0);
|
|
935
|
+
chunksRef.current = [];
|
|
936
|
+
try {
|
|
937
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
938
|
+
video: true,
|
|
939
|
+
audio: false,
|
|
940
|
+
preferCurrentTab: true,
|
|
941
|
+
} as DisplayMediaStreamOptions);
|
|
942
|
+
streamRef.current = stream;
|
|
943
|
+
const videoTrack = stream.getVideoTracks()[0];
|
|
944
|
+
videoTrack?.addEventListener("ended", () => stopRecording());
|
|
945
|
+
|
|
946
|
+
const mime = pickScreenRecorderMime();
|
|
947
|
+
const recorder = new MediaRecorder(stream, { mimeType: mime });
|
|
948
|
+
recorderRef.current = recorder;
|
|
949
|
+
recorder.ondataavailable = (e) => {
|
|
950
|
+
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
951
|
+
};
|
|
952
|
+
recorder.onstop = () => {
|
|
953
|
+
clearTimers();
|
|
954
|
+
const finalMs = Math.min(
|
|
955
|
+
SCREEN_RECORD_MAX_MS,
|
|
956
|
+
Math.max(0, Date.now() - (startedAtRef.current || Date.now())),
|
|
957
|
+
);
|
|
958
|
+
durationMsRef.current = finalMs;
|
|
959
|
+
setElapsedMs(finalMs);
|
|
960
|
+
const next = new Blob(chunksRef.current, {
|
|
961
|
+
type: recorder.mimeType || mime,
|
|
962
|
+
});
|
|
963
|
+
setBlob(next);
|
|
964
|
+
const url = URL.createObjectURL(next);
|
|
965
|
+
previewUrlRef.current = url;
|
|
966
|
+
setPreviewUrl(url);
|
|
967
|
+
stopStream();
|
|
968
|
+
recorderRef.current = null;
|
|
969
|
+
setRecording(false);
|
|
970
|
+
onRecordingChange?.(false);
|
|
971
|
+
};
|
|
972
|
+
startedAtRef.current = Date.now();
|
|
973
|
+
recorder.start(500);
|
|
974
|
+
setRecording(true);
|
|
975
|
+
onRecordingChange?.(true);
|
|
976
|
+
tickRef.current = setInterval(() => {
|
|
977
|
+
const ms = Date.now() - startedAtRef.current;
|
|
978
|
+
const clamped = Math.min(ms, SCREEN_RECORD_MAX_MS);
|
|
979
|
+
setElapsedMs(clamped);
|
|
980
|
+
if (ms >= SCREEN_RECORD_MAX_MS) stopRecording("max");
|
|
981
|
+
}, 200);
|
|
982
|
+
maxTimerRef.current = setTimeout(
|
|
983
|
+
() => stopRecording("max"),
|
|
984
|
+
SCREEN_RECORD_MAX_MS,
|
|
985
|
+
);
|
|
986
|
+
} catch {
|
|
987
|
+
stopStream();
|
|
988
|
+
setError(labels.screenRecordPermissionError);
|
|
989
|
+
setRecording(false);
|
|
990
|
+
onRecordingChange?.(false);
|
|
991
|
+
}
|
|
992
|
+
}, [
|
|
993
|
+
clearTimers,
|
|
994
|
+
labels.screenRecordPermissionError,
|
|
995
|
+
onRecordingChange,
|
|
996
|
+
revokePreview,
|
|
997
|
+
stopRecording,
|
|
998
|
+
stopStream,
|
|
999
|
+
]);
|
|
1000
|
+
|
|
1001
|
+
const cancelSession = useCallback(() => {
|
|
1002
|
+
if (recording) stopRecording();
|
|
1003
|
+
clearTimers();
|
|
1004
|
+
stopStream();
|
|
1005
|
+
recorderRef.current = null;
|
|
1006
|
+
revokePreview();
|
|
1007
|
+
setBlob(null);
|
|
1008
|
+
setError(null);
|
|
1009
|
+
setMaxReached(false);
|
|
1010
|
+
setElapsedMs(0);
|
|
1011
|
+
durationMsRef.current = 0;
|
|
1012
|
+
setRecording(false);
|
|
1013
|
+
onRecordingChange?.(false);
|
|
1014
|
+
}, [
|
|
1015
|
+
clearTimers,
|
|
1016
|
+
onRecordingChange,
|
|
1017
|
+
recording,
|
|
1018
|
+
revokePreview,
|
|
1019
|
+
stopRecording,
|
|
1020
|
+
stopStream,
|
|
1021
|
+
]);
|
|
1022
|
+
|
|
1023
|
+
const sendRecording = useCallback(async () => {
|
|
1024
|
+
if (sending || !blob || !onSendBlob) return;
|
|
1025
|
+
setSending(true);
|
|
1026
|
+
try {
|
|
1027
|
+
await onSendBlob(blob, durationMsRef.current || elapsedMs);
|
|
1028
|
+
cancelSession();
|
|
1029
|
+
} finally {
|
|
1030
|
+
setSending(false);
|
|
1031
|
+
}
|
|
1032
|
+
}, [blob, cancelSession, elapsedMs, onSendBlob, sending]);
|
|
1033
|
+
|
|
1034
|
+
useEffect(() => {
|
|
1035
|
+
return () => {
|
|
1036
|
+
clearTimers();
|
|
1037
|
+
if (recorderRef.current?.state === "recording") {
|
|
1038
|
+
recorderRef.current.stop();
|
|
1039
|
+
}
|
|
1040
|
+
stopStream();
|
|
1041
|
+
revokePreview();
|
|
1042
|
+
};
|
|
1043
|
+
}, [clearTimers, revokePreview, stopStream]);
|
|
1044
|
+
|
|
1045
|
+
const timerLabel = `${formatAudioDuration(elapsedMs)} / ${formatAudioDuration(SCREEN_RECORD_MAX_MS)}`;
|
|
1046
|
+
const active = recording || Boolean(blob) || Boolean(error);
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
recording,
|
|
1050
|
+
elapsedMs,
|
|
1051
|
+
blob,
|
|
1052
|
+
error,
|
|
1053
|
+
maxReached,
|
|
1054
|
+
sending,
|
|
1055
|
+
previewUrl,
|
|
1056
|
+
canSend: Boolean(blob) && Boolean(onSendBlob),
|
|
1057
|
+
timerLabel,
|
|
1058
|
+
active,
|
|
1059
|
+
startCapture,
|
|
1060
|
+
stopRecording: () => stopRecording(),
|
|
1061
|
+
cancelSession,
|
|
1062
|
+
sendRecording,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function InternalChatScreenRecorderBar({
|
|
1067
|
+
labels,
|
|
1068
|
+
session,
|
|
1069
|
+
onDismiss,
|
|
1070
|
+
}: {
|
|
1071
|
+
labels: InternalChatPanelLabels;
|
|
1072
|
+
session: ScreenRecorderSession;
|
|
1073
|
+
onDismiss: () => void;
|
|
1074
|
+
}) {
|
|
1075
|
+
const {
|
|
1076
|
+
recording,
|
|
1077
|
+
blob,
|
|
1078
|
+
error,
|
|
1079
|
+
maxReached,
|
|
1080
|
+
sending,
|
|
1081
|
+
previewUrl,
|
|
1082
|
+
canSend,
|
|
1083
|
+
timerLabel,
|
|
1084
|
+
startCapture,
|
|
1085
|
+
stopRecording,
|
|
1086
|
+
cancelSession,
|
|
1087
|
+
sendRecording,
|
|
1088
|
+
} = session;
|
|
1089
|
+
|
|
1090
|
+
const handleDismiss = () => {
|
|
1091
|
+
if (!recording && !blob) {
|
|
1092
|
+
cancelSession();
|
|
1093
|
+
}
|
|
1094
|
+
onDismiss();
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
return (
|
|
1098
|
+
<div
|
|
1099
|
+
className="mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2"
|
|
1100
|
+
role="region"
|
|
1101
|
+
aria-label={labels.screenRecordTitle}
|
|
1102
|
+
>
|
|
1103
|
+
<div className="flex items-center justify-between gap-2">
|
|
1104
|
+
<p className="text-xs font-medium text-foreground">
|
|
1105
|
+
{labels.screenRecordTitle}
|
|
1106
|
+
</p>
|
|
1107
|
+
<Button
|
|
1108
|
+
type="button"
|
|
1109
|
+
variant="ghost"
|
|
1110
|
+
size="icon"
|
|
1111
|
+
className="size-8 shrink-0"
|
|
1112
|
+
aria-label={
|
|
1113
|
+
recording
|
|
1114
|
+
? "Ocultar panel (la grabación continúa)"
|
|
1115
|
+
: labels.screenRecordCancel
|
|
1116
|
+
}
|
|
1117
|
+
onClick={handleDismiss}
|
|
1118
|
+
>
|
|
1119
|
+
<IconX className="size-4" />
|
|
1120
|
+
</Button>
|
|
1121
|
+
</div>
|
|
1122
|
+
{error ? (
|
|
1123
|
+
<p className="mt-1 text-xs leading-snug text-destructive">{error}</p>
|
|
1124
|
+
) : (
|
|
1125
|
+
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">
|
|
1126
|
+
{recording
|
|
1127
|
+
? labels.screenRecordBackgroundHint
|
|
1128
|
+
: maxReached
|
|
1129
|
+
? labels.screenRecordMaxReached
|
|
1130
|
+
: blob
|
|
1131
|
+
? "Revisa el video y envíalo al chat."
|
|
1132
|
+
: labels.screenRecordHint}
|
|
1133
|
+
</p>
|
|
1134
|
+
)}
|
|
1135
|
+
{recording ? (
|
|
1136
|
+
<p className="mt-1 text-xs font-medium tabular-nums text-destructive">
|
|
1137
|
+
{timerLabel}
|
|
1138
|
+
</p>
|
|
1139
|
+
) : null}
|
|
1140
|
+
<div className="mt-2 flex flex-col gap-2">
|
|
1141
|
+
{previewUrl && blob ? (
|
|
1142
|
+
<video
|
|
1143
|
+
src={previewUrl}
|
|
1144
|
+
controls
|
|
1145
|
+
playsInline
|
|
1146
|
+
className="max-h-32 w-full rounded-md border border-border bg-black object-contain"
|
|
1147
|
+
/>
|
|
1148
|
+
) : null}
|
|
1149
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1150
|
+
{recording ? (
|
|
1151
|
+
<Button
|
|
1152
|
+
type="button"
|
|
1153
|
+
variant="destructive"
|
|
1154
|
+
size="sm"
|
|
1155
|
+
className="h-8 gap-1.5"
|
|
1156
|
+
onClick={stopRecording}
|
|
1157
|
+
>
|
|
1158
|
+
<IconSquare className="size-3.5 fill-current" />
|
|
1159
|
+
{labels.screenRecordStop}
|
|
1160
|
+
</Button>
|
|
1161
|
+
) : (
|
|
1162
|
+
<Button
|
|
1163
|
+
type="button"
|
|
1164
|
+
variant="secondary"
|
|
1165
|
+
size="sm"
|
|
1166
|
+
className="h-8 gap-1.5"
|
|
1167
|
+
disabled={Boolean(blob)}
|
|
1168
|
+
onClick={() => void startCapture()}
|
|
1169
|
+
>
|
|
1170
|
+
<IconScreenShare className="size-3.5" />
|
|
1171
|
+
{labels.screenRecordStart}
|
|
1172
|
+
</Button>
|
|
1173
|
+
)}
|
|
1174
|
+
{recording ? (
|
|
1175
|
+
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive">
|
|
1176
|
+
<span className="size-2 shrink-0 animate-pulse rounded-full bg-destructive" />
|
|
1177
|
+
Grabando pantalla
|
|
1178
|
+
</span>
|
|
1179
|
+
) : null}
|
|
1180
|
+
{(recording || blob) && (
|
|
1181
|
+
<Button
|
|
1182
|
+
type="button"
|
|
1183
|
+
variant="ghost"
|
|
1184
|
+
size="sm"
|
|
1185
|
+
className="h-8 text-xs text-muted-foreground"
|
|
1186
|
+
onClick={cancelSession}
|
|
1187
|
+
>
|
|
1188
|
+
{labels.screenRecordCancel}
|
|
1189
|
+
</Button>
|
|
1190
|
+
)}
|
|
1191
|
+
<Button
|
|
1192
|
+
type="button"
|
|
1193
|
+
size="sm"
|
|
1194
|
+
className="ml-auto h-8 shrink-0"
|
|
1195
|
+
disabled={sending || !canSend}
|
|
1196
|
+
onClick={() => void sendRecording()}
|
|
1197
|
+
>
|
|
1198
|
+
{labels.screenRecordSend}
|
|
1199
|
+
</Button>
|
|
1200
|
+
</div>
|
|
1201
|
+
</div>
|
|
1202
|
+
</div>
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
252
1206
|
function renderMessageTextWithMentions(text: string): ReactNode {
|
|
253
1207
|
const parts = text.split(/(@\[[^\]]+\])/g);
|
|
254
1208
|
return parts.map((part, index) => {
|
|
@@ -298,6 +1252,32 @@ export type InternalChatPanelProps = {
|
|
|
298
1252
|
onDeleteRoomBlocked?: (reason: string) => void;
|
|
299
1253
|
labels?: Partial<InternalChatPanelLabels>;
|
|
300
1254
|
className?: string;
|
|
1255
|
+
/** Mensajes no vistos (icono del header: badge rojo, hasta «9+»). */
|
|
1256
|
+
unreadTotal?: number;
|
|
1257
|
+
/** No leídos por sala (badge en el rail de salas). */
|
|
1258
|
+
unreadByRoomId?: Record<string, number>;
|
|
1259
|
+
/** Se invoca al abrir el panel o al cambiar de sala con el chat visible. */
|
|
1260
|
+
onMarkRoomRead?: (roomId: string) => void;
|
|
1261
|
+
/** Conexión BFF: el contenedor sube el archivo. */
|
|
1262
|
+
onAttachPhotos?: (file: File) => void | Promise<void>;
|
|
1263
|
+
onAttachFiles?: (file: File) => void | Promise<void>;
|
|
1264
|
+
/** Tras grabar en el panel (conexión BFF / subida). */
|
|
1265
|
+
onSendAudioBlob?: (
|
|
1266
|
+
blob: Blob,
|
|
1267
|
+
durationMs: number,
|
|
1268
|
+
) => void | Promise<void>;
|
|
1269
|
+
/** Atajo legacy: el host abre su propio flujo sin el grabador integrado. */
|
|
1270
|
+
onSendAudio?: () => void | Promise<void>;
|
|
1271
|
+
onAttachAudioFile?: (file: File) => void | Promise<void>;
|
|
1272
|
+
/** Video de captura de pantalla (máx. 1 min en el grabador integrado). */
|
|
1273
|
+
onSendScreenRecordingBlob?: (
|
|
1274
|
+
blob: Blob,
|
|
1275
|
+
durationMs: number,
|
|
1276
|
+
) => void | Promise<void>;
|
|
1277
|
+
/** Abre el drawer al montar (p. ej. `/admin/.../chat`). */
|
|
1278
|
+
defaultOpen?: boolean;
|
|
1279
|
+
/** Oculta el botón flotante; usar con `defaultOpen` en página dedicada. */
|
|
1280
|
+
hideTrigger?: boolean;
|
|
301
1281
|
};
|
|
302
1282
|
|
|
303
1283
|
function InternalChatPanel({
|
|
@@ -330,14 +1310,33 @@ function InternalChatPanel({
|
|
|
330
1310
|
onDeleteRoomBlocked,
|
|
331
1311
|
labels: labelsPartial,
|
|
332
1312
|
className,
|
|
1313
|
+
unreadTotal = 0,
|
|
1314
|
+
unreadByRoomId,
|
|
1315
|
+
onMarkRoomRead,
|
|
1316
|
+
onAttachPhotos,
|
|
1317
|
+
onAttachFiles,
|
|
1318
|
+
onSendAudioBlob,
|
|
1319
|
+
onSendAudio,
|
|
1320
|
+
onSendScreenRecordingBlob,
|
|
1321
|
+
defaultOpen = false,
|
|
1322
|
+
hideTrigger = false,
|
|
333
1323
|
}: InternalChatPanelProps) {
|
|
334
1324
|
const labels = { ...DEFAULT_LABELS, ...labelsPartial };
|
|
335
1325
|
|
|
336
|
-
const [open, setOpen] = useState(
|
|
1326
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
337
1327
|
const [draft, setDraft] = useState("");
|
|
338
1328
|
const [createOpen, setCreateOpen] = useState(false);
|
|
339
1329
|
const [newRoomName, setNewRoomName] = useState("");
|
|
340
1330
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
1331
|
+
const [voiceRecorderOpen, setVoiceRecorderOpen] = useState(false);
|
|
1332
|
+
const [screenRecorderOpen, setScreenRecorderOpen] = useState(false);
|
|
1333
|
+
const screenRecorderSession = useInternalChatScreenRecorderSession(
|
|
1334
|
+
labels,
|
|
1335
|
+
onSendScreenRecordingBlob,
|
|
1336
|
+
useCallback((isRecording: boolean) => {
|
|
1337
|
+
if (isRecording) setScreenRecorderOpen(true);
|
|
1338
|
+
}, []),
|
|
1339
|
+
);
|
|
341
1340
|
const [mentionStart, setMentionStart] = useState<number | null>(null);
|
|
342
1341
|
const [mentionQuery, setMentionQuery] = useState("");
|
|
343
1342
|
const [mentionHighlight, setMentionHighlight] = useState(0);
|
|
@@ -346,7 +1345,20 @@ function InternalChatPanel({
|
|
|
346
1345
|
|
|
347
1346
|
const formRef = useRef<HTMLFormElement>(null);
|
|
348
1347
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
1348
|
+
const messagesListRef = useRef<HTMLDivElement>(null);
|
|
349
1349
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
1350
|
+
const photoInputRef = useRef<HTMLInputElement>(null);
|
|
1351
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
1352
|
+
|
|
1353
|
+
const supportRoom = useMemo(
|
|
1354
|
+
() => rooms.find((r) => r.isSupport) ?? null,
|
|
1355
|
+
[rooms],
|
|
1356
|
+
);
|
|
1357
|
+
|
|
1358
|
+
const teamRooms = useMemo(
|
|
1359
|
+
() => rooms.filter((r) => !r.isSupport),
|
|
1360
|
+
[rooms],
|
|
1361
|
+
);
|
|
350
1362
|
|
|
351
1363
|
const activeRoom = useMemo(
|
|
352
1364
|
() => rooms.find((r) => r.id === activeRoomId) ?? null,
|
|
@@ -395,6 +1407,47 @@ function InternalChatPanel({
|
|
|
395
1407
|
!isSending &&
|
|
396
1408
|
Boolean(activeRoomId);
|
|
397
1409
|
|
|
1410
|
+
const messagesScrollTail = useMemo(() => {
|
|
1411
|
+
if (messages.length === 0) {
|
|
1412
|
+
return `empty:${messagesStatus}`;
|
|
1413
|
+
}
|
|
1414
|
+
const last = messages[messages.length - 1]!;
|
|
1415
|
+
const attachCount = last.attachments?.length ?? 0;
|
|
1416
|
+
const hasAudio = last.audio ? 1 : 0;
|
|
1417
|
+
return [
|
|
1418
|
+
messages.length,
|
|
1419
|
+
last.id,
|
|
1420
|
+
last.text.length,
|
|
1421
|
+
attachCount,
|
|
1422
|
+
hasAudio,
|
|
1423
|
+
last.receiptStatus ?? "",
|
|
1424
|
+
messagesStatus,
|
|
1425
|
+
].join("|");
|
|
1426
|
+
}, [messages, messagesStatus]);
|
|
1427
|
+
|
|
1428
|
+
const scrollMessagesToBottom = useCallback(() => {
|
|
1429
|
+
const node = scrollerRef.current;
|
|
1430
|
+
if (!node) return;
|
|
1431
|
+
node.scrollTop = node.scrollHeight;
|
|
1432
|
+
}, []);
|
|
1433
|
+
|
|
1434
|
+
const scheduleScrollMessagesToBottom = useCallback(() => {
|
|
1435
|
+
scrollMessagesToBottom();
|
|
1436
|
+
let rafNested = 0;
|
|
1437
|
+
const rafOuter = requestAnimationFrame(() => {
|
|
1438
|
+
scrollMessagesToBottom();
|
|
1439
|
+
rafNested = requestAnimationFrame(scrollMessagesToBottom);
|
|
1440
|
+
});
|
|
1441
|
+
const timeouts = [50, 150, 320].map((ms) =>
|
|
1442
|
+
window.setTimeout(scrollMessagesToBottom, ms),
|
|
1443
|
+
);
|
|
1444
|
+
return () => {
|
|
1445
|
+
cancelAnimationFrame(rafOuter);
|
|
1446
|
+
if (rafNested) cancelAnimationFrame(rafNested);
|
|
1447
|
+
timeouts.forEach(clearTimeout);
|
|
1448
|
+
};
|
|
1449
|
+
}, [scrollMessagesToBottom]);
|
|
1450
|
+
|
|
398
1451
|
const setOpenWithNotify = useCallback(
|
|
399
1452
|
(next: boolean) => {
|
|
400
1453
|
if (next) onUserGesture?.();
|
|
@@ -404,6 +1457,18 @@ function InternalChatPanel({
|
|
|
404
1457
|
[onOpenChange, onUserGesture],
|
|
405
1458
|
);
|
|
406
1459
|
|
|
1460
|
+
useEffect(() => {
|
|
1461
|
+
if (!open || !activeRoomId) return;
|
|
1462
|
+
onMarkRoomRead?.(activeRoomId);
|
|
1463
|
+
}, [open, activeRoomId, onMarkRoomRead]);
|
|
1464
|
+
|
|
1465
|
+
useEffect(() => {
|
|
1466
|
+
if (!open) return;
|
|
1467
|
+
if (screenRecorderSession.active) {
|
|
1468
|
+
setScreenRecorderOpen(true);
|
|
1469
|
+
}
|
|
1470
|
+
}, [open, screenRecorderSession.active]);
|
|
1471
|
+
|
|
407
1472
|
const handleMessagesScroll = useCallback(() => {
|
|
408
1473
|
const node = scrollerRef.current;
|
|
409
1474
|
if (!node || !activeRoomId || !onLoadOlderMessages) return;
|
|
@@ -414,12 +1479,12 @@ function InternalChatPanel({
|
|
|
414
1479
|
const prevHeight = node.scrollHeight;
|
|
415
1480
|
const prevTop = node.scrollTop;
|
|
416
1481
|
void Promise.resolve(onLoadOlderMessages()).finally(() => {
|
|
417
|
-
loadingOlderMessagesRef.current = false;
|
|
418
1482
|
requestAnimationFrame(() => {
|
|
419
1483
|
const n = scrollerRef.current;
|
|
420
1484
|
if (!n) return;
|
|
421
1485
|
const delta = n.scrollHeight - prevHeight;
|
|
422
1486
|
n.scrollTop = prevTop + delta;
|
|
1487
|
+
loadingOlderMessagesRef.current = false;
|
|
423
1488
|
});
|
|
424
1489
|
});
|
|
425
1490
|
}, [
|
|
@@ -430,27 +1495,46 @@ function InternalChatPanel({
|
|
|
430
1495
|
]);
|
|
431
1496
|
|
|
432
1497
|
useLayoutEffect(() => {
|
|
433
|
-
if (!open) return;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
1498
|
+
if (!open || !activeRoomId) return;
|
|
1499
|
+
if (loadingOlderMessagesRef.current) return;
|
|
1500
|
+
return scheduleScrollMessagesToBottom();
|
|
1501
|
+
}, [
|
|
1502
|
+
open,
|
|
1503
|
+
activeRoomId,
|
|
1504
|
+
messagesScrollTail,
|
|
1505
|
+
typingLine,
|
|
1506
|
+
draft,
|
|
1507
|
+
voiceRecorderOpen,
|
|
1508
|
+
screenRecorderOpen,
|
|
1509
|
+
scheduleScrollMessagesToBottom,
|
|
1510
|
+
]);
|
|
1511
|
+
|
|
1512
|
+
useEffect(() => {
|
|
1513
|
+
if (!open || !activeRoomId) return;
|
|
1514
|
+
const scroller = scrollerRef.current;
|
|
1515
|
+
const list = messagesListRef.current;
|
|
1516
|
+
const textarea = textareaRef.current;
|
|
1517
|
+
if (!scroller) return;
|
|
1518
|
+
|
|
1519
|
+
const pinBottom = () => {
|
|
1520
|
+
if (loadingOlderMessagesRef.current) return;
|
|
1521
|
+
scrollMessagesToBottom();
|
|
452
1522
|
};
|
|
453
|
-
|
|
1523
|
+
|
|
1524
|
+
const ro = new ResizeObserver(pinBottom);
|
|
1525
|
+
ro.observe(scroller);
|
|
1526
|
+
if (list) ro.observe(list);
|
|
1527
|
+
if (textarea) ro.observe(textarea);
|
|
1528
|
+
const form = formRef.current;
|
|
1529
|
+
if (form) ro.observe(form);
|
|
1530
|
+
|
|
1531
|
+
return () => ro.disconnect();
|
|
1532
|
+
}, [
|
|
1533
|
+
open,
|
|
1534
|
+
activeRoomId,
|
|
1535
|
+
messagesScrollTail,
|
|
1536
|
+
scrollMessagesToBottom,
|
|
1537
|
+
]);
|
|
454
1538
|
|
|
455
1539
|
useEffect(() => {
|
|
456
1540
|
if (!mentionOpen || mentionEntries.length === 0) return;
|
|
@@ -582,6 +1666,7 @@ function InternalChatPanel({
|
|
|
582
1666
|
await onSendMessage(text);
|
|
583
1667
|
setDraft("");
|
|
584
1668
|
onDraftChange?.("");
|
|
1669
|
+
scheduleScrollMessagesToBottom();
|
|
585
1670
|
} catch {
|
|
586
1671
|
/* errores vía contenedor */
|
|
587
1672
|
}
|
|
@@ -615,7 +1700,11 @@ function InternalChatPanel({
|
|
|
615
1700
|
onDeleteRoomBlocked?.("La sala Organización no se puede eliminar.");
|
|
616
1701
|
return;
|
|
617
1702
|
}
|
|
618
|
-
if (
|
|
1703
|
+
if (activeRoom.isSupport) {
|
|
1704
|
+
onDeleteRoomBlocked?.("La sala de soporte no se puede eliminar.");
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if (teamRooms.length <= 1) {
|
|
619
1708
|
onDeleteRoomBlocked?.("Debe existir al menos una sala.");
|
|
620
1709
|
return;
|
|
621
1710
|
}
|
|
@@ -624,15 +1713,65 @@ function InternalChatPanel({
|
|
|
624
1713
|
|
|
625
1714
|
return (
|
|
626
1715
|
<>
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1716
|
+
{!hideTrigger ? (
|
|
1717
|
+
<Button
|
|
1718
|
+
type="button"
|
|
1719
|
+
size="icon"
|
|
1720
|
+
className={cn("relative size-9 text-primary-foreground", className)}
|
|
1721
|
+
aria-label={
|
|
1722
|
+
unreadTotal > 0
|
|
1723
|
+
? `${labels.openChat} · ${unreadTotal} sin leer`
|
|
1724
|
+
: labels.openChat
|
|
1725
|
+
}
|
|
1726
|
+
onClick={() => setOpenWithNotify(true)}
|
|
1727
|
+
>
|
|
1728
|
+
<IconMessages className="size-4" />
|
|
1729
|
+
{unreadTotal > 0 ? (
|
|
1730
|
+
<span
|
|
1731
|
+
className={unreadCountBadgeClasses(formatUnreadBadge(unreadTotal))}
|
|
1732
|
+
aria-hidden
|
|
1733
|
+
>
|
|
1734
|
+
{formatUnreadBadge(unreadTotal)}
|
|
1735
|
+
</span>
|
|
1736
|
+
) : null}
|
|
1737
|
+
{screenRecorderSession.recording ? (
|
|
1738
|
+
<span
|
|
1739
|
+
className="absolute -top-0.5 -left-0.5 size-2.5 animate-pulse rounded-full bg-destructive ring-2 ring-background"
|
|
1740
|
+
aria-hidden
|
|
1741
|
+
/>
|
|
1742
|
+
) : null}
|
|
1743
|
+
</Button>
|
|
1744
|
+
) : !open ? (
|
|
1745
|
+
<Button
|
|
1746
|
+
type="button"
|
|
1747
|
+
variant="outline"
|
|
1748
|
+
size="sm"
|
|
1749
|
+
className="gap-2"
|
|
1750
|
+
onClick={() => setOpenWithNotify(true)}
|
|
1751
|
+
>
|
|
1752
|
+
<IconMessages className="size-4" />
|
|
1753
|
+
{labels.openChat}
|
|
1754
|
+
</Button>
|
|
1755
|
+
) : null}
|
|
1756
|
+
|
|
1757
|
+
{!open && screenRecorderSession.active ? (
|
|
1758
|
+
<Button
|
|
1759
|
+
type="button"
|
|
1760
|
+
variant="destructive"
|
|
1761
|
+
size="sm"
|
|
1762
|
+
className="fixed bottom-6 right-6 z-[200] h-9 gap-2 shadow-lg"
|
|
1763
|
+
onClick={() => setOpenWithNotify(true)}
|
|
1764
|
+
>
|
|
1765
|
+
<span className="size-2 animate-pulse rounded-full bg-destructive-foreground" />
|
|
1766
|
+
<span className="text-xs font-medium tabular-nums">
|
|
1767
|
+
{screenRecorderSession.recording
|
|
1768
|
+
? `REC ${screenRecorderSession.timerLabel}`
|
|
1769
|
+
: screenRecorderSession.blob
|
|
1770
|
+
? labels.screenRecordOpenChatToFinish
|
|
1771
|
+
: labels.screenRecordTitle}
|
|
1772
|
+
</span>
|
|
1773
|
+
</Button>
|
|
1774
|
+
) : null}
|
|
636
1775
|
|
|
637
1776
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
638
1777
|
<DialogContent className="sm:max-w-md">
|
|
@@ -735,7 +1874,7 @@ function InternalChatPanel({
|
|
|
735
1874
|
</Tooltip>
|
|
736
1875
|
|
|
737
1876
|
<div className="flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1">
|
|
738
|
-
{
|
|
1877
|
+
{teamRooms.map((room) => {
|
|
739
1878
|
const selected = room.id === activeRoomId;
|
|
740
1879
|
return (
|
|
741
1880
|
<Tooltip key={room.id} delayDuration={300}>
|
|
@@ -755,6 +1894,19 @@ function InternalChatPanel({
|
|
|
755
1894
|
<span className="text-xs font-semibold leading-none">
|
|
756
1895
|
{roomInitialLetter(room.name)}
|
|
757
1896
|
</span>
|
|
1897
|
+
{(unreadByRoomId?.[room.id] ?? 0) > 0 ? (
|
|
1898
|
+
<span
|
|
1899
|
+
className={cn(
|
|
1900
|
+
unreadCountBadgeClasses(
|
|
1901
|
+
formatUnreadBadge(unreadByRoomId![room.id]!),
|
|
1902
|
+
),
|
|
1903
|
+
"ring-2 ring-background",
|
|
1904
|
+
)}
|
|
1905
|
+
aria-hidden
|
|
1906
|
+
>
|
|
1907
|
+
{formatUnreadBadge(unreadByRoomId![room.id]!)}
|
|
1908
|
+
</span>
|
|
1909
|
+
) : null}
|
|
758
1910
|
</button>
|
|
759
1911
|
</TooltipTrigger>
|
|
760
1912
|
<TooltipContent side="left">
|
|
@@ -767,6 +1919,49 @@ function InternalChatPanel({
|
|
|
767
1919
|
})}
|
|
768
1920
|
</div>
|
|
769
1921
|
|
|
1922
|
+
{supportRoom ? (
|
|
1923
|
+
<Tooltip delayDuration={300}>
|
|
1924
|
+
<TooltipTrigger asChild>
|
|
1925
|
+
<Button
|
|
1926
|
+
type="button"
|
|
1927
|
+
variant="ghost"
|
|
1928
|
+
size="icon"
|
|
1929
|
+
className={cn(
|
|
1930
|
+
"relative size-9 shrink-0 rounded-full border shadow-sm",
|
|
1931
|
+
activeRoomId === supportRoom.id
|
|
1932
|
+
? "border-sky-600 bg-sky-600 text-white hover:bg-sky-600/90 hover:text-white dark:border-sky-500 dark:bg-sky-500"
|
|
1933
|
+
: "border-sky-500/70 bg-sky-500/20 text-sky-800 hover:bg-sky-500/30 dark:border-sky-400/60 dark:bg-sky-500/25 dark:text-sky-100",
|
|
1934
|
+
)}
|
|
1935
|
+
aria-label={labels.supportChatTooltip}
|
|
1936
|
+
aria-current={
|
|
1937
|
+
activeRoomId === supportRoom.id ? "true" : undefined
|
|
1938
|
+
}
|
|
1939
|
+
onClick={() => onActiveRoomChange(supportRoom.id)}
|
|
1940
|
+
>
|
|
1941
|
+
<IconHeadset className="size-4 stroke-[2.25]" />
|
|
1942
|
+
{(unreadByRoomId?.[supportRoom.id] ?? 0) > 0 ? (
|
|
1943
|
+
<span
|
|
1944
|
+
className={cn(
|
|
1945
|
+
unreadCountBadgeClasses(
|
|
1946
|
+
formatUnreadBadge(
|
|
1947
|
+
unreadByRoomId![supportRoom.id]!,
|
|
1948
|
+
),
|
|
1949
|
+
),
|
|
1950
|
+
"ring-2 ring-background",
|
|
1951
|
+
)}
|
|
1952
|
+
aria-hidden
|
|
1953
|
+
>
|
|
1954
|
+
{formatUnreadBadge(unreadByRoomId![supportRoom.id]!)}
|
|
1955
|
+
</span>
|
|
1956
|
+
) : null}
|
|
1957
|
+
</Button>
|
|
1958
|
+
</TooltipTrigger>
|
|
1959
|
+
<TooltipContent side="left">
|
|
1960
|
+
{labels.supportChatTooltip}
|
|
1961
|
+
</TooltipContent>
|
|
1962
|
+
</Tooltip>
|
|
1963
|
+
) : null}
|
|
1964
|
+
|
|
770
1965
|
<DropdownMenu>
|
|
771
1966
|
<DropdownMenuTrigger asChild>
|
|
772
1967
|
<Button
|
|
@@ -787,7 +1982,9 @@ function InternalChatPanel({
|
|
|
787
1982
|
<DropdownMenuSeparator />
|
|
788
1983
|
<DropdownMenuItem
|
|
789
1984
|
disabled={
|
|
790
|
-
|
|
1985
|
+
teamRooms.length <= 1 ||
|
|
1986
|
+
activeRoom?.isOrganization ||
|
|
1987
|
+
activeRoom?.isSupport
|
|
791
1988
|
}
|
|
792
1989
|
className="text-destructive focus:text-destructive"
|
|
793
1990
|
onClick={requestDeleteRoom}
|
|
@@ -808,19 +2005,23 @@ function InternalChatPanel({
|
|
|
808
2005
|
</DrawerTitle>
|
|
809
2006
|
<DrawerDescription className="line-clamp-2">
|
|
810
2007
|
{activeRoom ? (
|
|
811
|
-
|
|
812
|
-
<span
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
2008
|
+
activeRoom.isSupport ? (
|
|
2009
|
+
<span>{labels.supportRoomDescription}</span>
|
|
2010
|
+
) : (
|
|
2011
|
+
<>
|
|
2012
|
+
<span className="font-medium text-foreground">
|
|
2013
|
+
{activeRoom.name}
|
|
2014
|
+
</span>
|
|
2015
|
+
{activeRoom.isOrganization ? (
|
|
2016
|
+
<>{labels.orgRoomSuffix}</>
|
|
2017
|
+
) : null}
|
|
2018
|
+
{" · "}
|
|
2019
|
+
<kbd className="pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]">
|
|
2020
|
+
/
|
|
2021
|
+
</kbd>{" "}
|
|
2022
|
+
{labels.slashMentionHint}
|
|
2023
|
+
</>
|
|
2024
|
+
)
|
|
824
2025
|
) : (
|
|
825
2026
|
labels.selectRoomHint
|
|
826
2027
|
)}
|
|
@@ -841,7 +2042,7 @@ function InternalChatPanel({
|
|
|
841
2042
|
|
|
842
2043
|
<div
|
|
843
2044
|
ref={scrollerRef}
|
|
844
|
-
className="min-h-0 flex-1 overflow-y-auto"
|
|
2045
|
+
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
|
845
2046
|
onScroll={handleMessagesScroll}
|
|
846
2047
|
>
|
|
847
2048
|
{!activeRoom ? (
|
|
@@ -886,7 +2087,10 @@ function InternalChatPanel({
|
|
|
886
2087
|
</EmptyHeader>
|
|
887
2088
|
</Empty>
|
|
888
2089
|
) : (
|
|
889
|
-
<div
|
|
2090
|
+
<div
|
|
2091
|
+
ref={messagesListRef}
|
|
2092
|
+
className="flex flex-col gap-4 px-5 pt-2 pb-6"
|
|
2093
|
+
>
|
|
890
2094
|
{isLoadingOlder ? (
|
|
891
2095
|
<div className="flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground">
|
|
892
2096
|
<IconLoader2
|
|
@@ -908,20 +2112,64 @@ function InternalChatPanel({
|
|
|
908
2112
|
{message.initials}
|
|
909
2113
|
</MessageAvatar>
|
|
910
2114
|
)}
|
|
911
|
-
<MessageContent>
|
|
2115
|
+
<MessageContent className="gap-0.5">
|
|
912
2116
|
{mine ? null : (
|
|
913
|
-
<MessageHeader>
|
|
2117
|
+
<MessageHeader className="px-3 pb-0 leading-snug">
|
|
2118
|
+
{message.author}
|
|
2119
|
+
</MessageHeader>
|
|
914
2120
|
)}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
2121
|
+
{message.attachments?.length ? (
|
|
2122
|
+
<InternalChatAttachmentStack
|
|
2123
|
+
items={message.attachments}
|
|
2124
|
+
labels={labels}
|
|
2125
|
+
/>
|
|
2126
|
+
) : null}
|
|
2127
|
+
{message.audio ? (
|
|
2128
|
+
<Bubble
|
|
2129
|
+
align={mine ? "end" : "start"}
|
|
2130
|
+
variant={mine ? "muted" : "outline"}
|
|
2131
|
+
>
|
|
2132
|
+
<BubbleContent className="py-2">
|
|
2133
|
+
<InternalChatAudioBubble
|
|
2134
|
+
audio={message.audio}
|
|
2135
|
+
seed={message.id}
|
|
2136
|
+
mine={mine}
|
|
2137
|
+
/>
|
|
2138
|
+
</BubbleContent>
|
|
2139
|
+
</Bubble>
|
|
2140
|
+
) : null}
|
|
2141
|
+
{message.text.trim() ? (
|
|
2142
|
+
<Bubble
|
|
2143
|
+
align={mine ? "end" : "start"}
|
|
2144
|
+
variant={mine ? "muted" : "outline"}
|
|
2145
|
+
>
|
|
2146
|
+
<BubbleContent className="break-words whitespace-pre-wrap">
|
|
2147
|
+
{renderMessageTextWithMentions(message.text)}
|
|
2148
|
+
</BubbleContent>
|
|
2149
|
+
</Bubble>
|
|
2150
|
+
) : null}
|
|
2151
|
+
<MessageFooter className="flex items-center justify-end gap-1 px-3 pt-0 leading-none">
|
|
2152
|
+
<span className="text-[0.65rem] tabular-nums text-muted-foreground">
|
|
2153
|
+
{formatMessageTime(message.at)}
|
|
2154
|
+
</span>
|
|
2155
|
+
{mine && message.receiptStatus ? (
|
|
2156
|
+
<span
|
|
2157
|
+
className="inline-flex shrink-0"
|
|
2158
|
+
title={
|
|
2159
|
+
message.receiptStatus === "pending"
|
|
2160
|
+
? "Enviando"
|
|
2161
|
+
: message.receiptStatus === "sent"
|
|
2162
|
+
? "Enviado"
|
|
2163
|
+
: message.receiptStatus === "delivered"
|
|
2164
|
+
? "Entregado"
|
|
2165
|
+
: "Leído"
|
|
2166
|
+
}
|
|
2167
|
+
>
|
|
2168
|
+
<MessageReceiptTicks
|
|
2169
|
+
status={message.receiptStatus}
|
|
2170
|
+
/>
|
|
2171
|
+
</span>
|
|
2172
|
+
) : null}
|
|
925
2173
|
</MessageFooter>
|
|
926
2174
|
</MessageContent>
|
|
927
2175
|
</Message>
|
|
@@ -939,6 +2187,23 @@ function InternalChatPanel({
|
|
|
939
2187
|
) : null}
|
|
940
2188
|
</div>
|
|
941
2189
|
|
|
2190
|
+
{voiceRecorderOpen && activeRoom ? (
|
|
2191
|
+
<InternalChatVoiceRecorderBar
|
|
2192
|
+
labels={labels}
|
|
2193
|
+
onClose={() => setVoiceRecorderOpen(false)}
|
|
2194
|
+
onSendBlob={onSendAudioBlob}
|
|
2195
|
+
onSendLegacy={onSendAudio}
|
|
2196
|
+
/>
|
|
2197
|
+
) : null}
|
|
2198
|
+
|
|
2199
|
+
{activeRoom && screenRecorderOpen ? (
|
|
2200
|
+
<InternalChatScreenRecorderBar
|
|
2201
|
+
labels={labels}
|
|
2202
|
+
session={screenRecorderSession}
|
|
2203
|
+
onDismiss={() => setScreenRecorderOpen(false)}
|
|
2204
|
+
/>
|
|
2205
|
+
) : null}
|
|
2206
|
+
|
|
942
2207
|
<form
|
|
943
2208
|
ref={formRef}
|
|
944
2209
|
onSubmit={onSubmit}
|
|
@@ -1204,24 +2469,105 @@ function InternalChatPanel({
|
|
|
1204
2469
|
<DropdownMenuContent
|
|
1205
2470
|
align="start"
|
|
1206
2471
|
side="top"
|
|
1207
|
-
className="w-
|
|
2472
|
+
className="w-48"
|
|
1208
2473
|
>
|
|
1209
|
-
<DropdownMenuItem
|
|
1210
|
-
|
|
2474
|
+
<DropdownMenuItem
|
|
2475
|
+
onClick={() => {
|
|
2476
|
+
onUserGesture?.();
|
|
2477
|
+
photoInputRef.current?.click();
|
|
2478
|
+
}}
|
|
2479
|
+
>
|
|
2480
|
+
<IconPhoto className="size-4" />
|
|
1211
2481
|
{labels.attachPhotos}
|
|
1212
2482
|
</DropdownMenuItem>
|
|
2483
|
+
<DropdownMenuItem
|
|
2484
|
+
onClick={() => {
|
|
2485
|
+
onUserGesture?.();
|
|
2486
|
+
fileInputRef.current?.click();
|
|
2487
|
+
}}
|
|
2488
|
+
>
|
|
2489
|
+
<IconPaperclip className="size-4" />
|
|
2490
|
+
{labels.attachFiles}
|
|
2491
|
+
</DropdownMenuItem>
|
|
1213
2492
|
</DropdownMenuContent>
|
|
1214
2493
|
</DropdownMenu>
|
|
1215
2494
|
<InputGroupButton
|
|
1216
|
-
type="
|
|
1217
|
-
variant=
|
|
2495
|
+
type="button"
|
|
2496
|
+
variant={
|
|
2497
|
+
screenRecorderOpen || screenRecorderSession.recording
|
|
2498
|
+
? "default"
|
|
2499
|
+
: "outline"
|
|
2500
|
+
}
|
|
1218
2501
|
size="icon-sm"
|
|
1219
|
-
disabled={!
|
|
1220
|
-
|
|
2502
|
+
disabled={!activeRoom}
|
|
2503
|
+
aria-label={labels.screenCaptureAriaLabel}
|
|
2504
|
+
aria-pressed={
|
|
2505
|
+
screenRecorderOpen || screenRecorderSession.recording
|
|
2506
|
+
}
|
|
2507
|
+
className={cn(
|
|
2508
|
+
!screenRecorderOpen &&
|
|
2509
|
+
!screenRecorderSession.recording &&
|
|
2510
|
+
"text-violet-600 hover:text-violet-700 dark:text-violet-400 dark:hover:text-violet-300",
|
|
2511
|
+
)}
|
|
2512
|
+
onClick={() => {
|
|
2513
|
+
onUserGesture?.();
|
|
2514
|
+
setVoiceRecorderOpen(false);
|
|
2515
|
+
setScreenRecorderOpen((prev) => !prev);
|
|
2516
|
+
}}
|
|
1221
2517
|
>
|
|
1222
|
-
<
|
|
1223
|
-
<span className="sr-only">{labels.sendSrOnly}</span>
|
|
2518
|
+
<IconScreenShare className="size-4" />
|
|
1224
2519
|
</InputGroupButton>
|
|
2520
|
+
<input
|
|
2521
|
+
ref={photoInputRef}
|
|
2522
|
+
type="file"
|
|
2523
|
+
accept="image/*"
|
|
2524
|
+
className="hidden"
|
|
2525
|
+
onChange={(e) => {
|
|
2526
|
+
const file = e.target.files?.[0];
|
|
2527
|
+
if (file && onAttachPhotos) void onAttachPhotos(file);
|
|
2528
|
+
e.target.value = "";
|
|
2529
|
+
}}
|
|
2530
|
+
/>
|
|
2531
|
+
<input
|
|
2532
|
+
ref={fileInputRef}
|
|
2533
|
+
type="file"
|
|
2534
|
+
className="hidden"
|
|
2535
|
+
onChange={(e) => {
|
|
2536
|
+
const file = e.target.files?.[0];
|
|
2537
|
+
if (file && onAttachFiles) void onAttachFiles(file);
|
|
2538
|
+
e.target.value = "";
|
|
2539
|
+
}}
|
|
2540
|
+
/>
|
|
2541
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
2542
|
+
<InputGroupButton
|
|
2543
|
+
type="button"
|
|
2544
|
+
variant={voiceRecorderOpen ? "default" : "outline"}
|
|
2545
|
+
size="icon-sm"
|
|
2546
|
+
disabled={!activeRoom}
|
|
2547
|
+
aria-label={labels.attachAudio}
|
|
2548
|
+
aria-pressed={voiceRecorderOpen}
|
|
2549
|
+
className={cn(
|
|
2550
|
+
!voiceRecorderOpen &&
|
|
2551
|
+
"text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300",
|
|
2552
|
+
)}
|
|
2553
|
+
onClick={() => {
|
|
2554
|
+
onUserGesture?.();
|
|
2555
|
+
setScreenRecorderOpen(false);
|
|
2556
|
+
setVoiceRecorderOpen((open) => !open);
|
|
2557
|
+
}}
|
|
2558
|
+
>
|
|
2559
|
+
<IconMicrophone className="size-4" />
|
|
2560
|
+
</InputGroupButton>
|
|
2561
|
+
<InputGroupButton
|
|
2562
|
+
type="submit"
|
|
2563
|
+
variant="default"
|
|
2564
|
+
size="icon-sm"
|
|
2565
|
+
disabled={!canSend || !activeRoom}
|
|
2566
|
+
>
|
|
2567
|
+
<IconArrowUp className="size-4" />
|
|
2568
|
+
<span className="sr-only">{labels.sendSrOnly}</span>
|
|
2569
|
+
</InputGroupButton>
|
|
2570
|
+
</div>
|
|
1225
2571
|
</InputGroupAddon>
|
|
1226
2572
|
</InputGroup>
|
|
1227
2573
|
</form>
|