@traky-app/ui 1.6.4 → 1.6.6
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 +1 -1
- package/registry/traky/json/internal-chat-panel.json +1 -1
- package/registry/traky/json/office-address-form.json +5 -1
- package/registry/traky/ui/internal-chat-panel.tsx +1423 -71
- package/registry/traky/ui/office-address-form.tsx +122 -76
- package/src/ui/internal-chat-panel.tsx +1423 -71
- package/src/ui/office-address-form.tsx +80 -43
|
@@ -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,37 @@ 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;
|
|
1281
|
+
/**
|
|
1282
|
+
* Página dedicada (`defaultOpen` + `hideTrigger`): el drawer no es modal
|
|
1283
|
+
* y no bloquea clics en el resto del admin (sin overlay a pantalla completa).
|
|
1284
|
+
*/
|
|
1285
|
+
embedded?: boolean;
|
|
301
1286
|
};
|
|
302
1287
|
|
|
303
1288
|
function InternalChatPanel({
|
|
@@ -330,14 +1315,34 @@ function InternalChatPanel({
|
|
|
330
1315
|
onDeleteRoomBlocked,
|
|
331
1316
|
labels: labelsPartial,
|
|
332
1317
|
className,
|
|
1318
|
+
unreadTotal = 0,
|
|
1319
|
+
unreadByRoomId,
|
|
1320
|
+
onMarkRoomRead,
|
|
1321
|
+
onAttachPhotos,
|
|
1322
|
+
onAttachFiles,
|
|
1323
|
+
onSendAudioBlob,
|
|
1324
|
+
onSendAudio,
|
|
1325
|
+
onSendScreenRecordingBlob,
|
|
1326
|
+
defaultOpen = false,
|
|
1327
|
+
hideTrigger = false,
|
|
1328
|
+
embedded = false,
|
|
333
1329
|
}: InternalChatPanelProps) {
|
|
334
1330
|
const labels = { ...DEFAULT_LABELS, ...labelsPartial };
|
|
335
1331
|
|
|
336
|
-
const [open, setOpen] = useState(
|
|
1332
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
337
1333
|
const [draft, setDraft] = useState("");
|
|
338
1334
|
const [createOpen, setCreateOpen] = useState(false);
|
|
339
1335
|
const [newRoomName, setNewRoomName] = useState("");
|
|
340
1336
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
1337
|
+
const [voiceRecorderOpen, setVoiceRecorderOpen] = useState(false);
|
|
1338
|
+
const [screenRecorderOpen, setScreenRecorderOpen] = useState(false);
|
|
1339
|
+
const screenRecorderSession = useInternalChatScreenRecorderSession(
|
|
1340
|
+
labels,
|
|
1341
|
+
onSendScreenRecordingBlob,
|
|
1342
|
+
useCallback((isRecording: boolean) => {
|
|
1343
|
+
if (isRecording) setScreenRecorderOpen(true);
|
|
1344
|
+
}, []),
|
|
1345
|
+
);
|
|
341
1346
|
const [mentionStart, setMentionStart] = useState<number | null>(null);
|
|
342
1347
|
const [mentionQuery, setMentionQuery] = useState("");
|
|
343
1348
|
const [mentionHighlight, setMentionHighlight] = useState(0);
|
|
@@ -346,7 +1351,20 @@ function InternalChatPanel({
|
|
|
346
1351
|
|
|
347
1352
|
const formRef = useRef<HTMLFormElement>(null);
|
|
348
1353
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
1354
|
+
const messagesListRef = useRef<HTMLDivElement>(null);
|
|
349
1355
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
1356
|
+
const photoInputRef = useRef<HTMLInputElement>(null);
|
|
1357
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
1358
|
+
|
|
1359
|
+
const supportRoom = useMemo(
|
|
1360
|
+
() => rooms.find((r) => r.isSupport) ?? null,
|
|
1361
|
+
[rooms],
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
const teamRooms = useMemo(
|
|
1365
|
+
() => rooms.filter((r) => !r.isSupport),
|
|
1366
|
+
[rooms],
|
|
1367
|
+
);
|
|
350
1368
|
|
|
351
1369
|
const activeRoom = useMemo(
|
|
352
1370
|
() => rooms.find((r) => r.id === activeRoomId) ?? null,
|
|
@@ -395,6 +1413,47 @@ function InternalChatPanel({
|
|
|
395
1413
|
!isSending &&
|
|
396
1414
|
Boolean(activeRoomId);
|
|
397
1415
|
|
|
1416
|
+
const messagesScrollTail = useMemo(() => {
|
|
1417
|
+
if (messages.length === 0) {
|
|
1418
|
+
return `empty:${messagesStatus}`;
|
|
1419
|
+
}
|
|
1420
|
+
const last = messages[messages.length - 1]!;
|
|
1421
|
+
const attachCount = last.attachments?.length ?? 0;
|
|
1422
|
+
const hasAudio = last.audio ? 1 : 0;
|
|
1423
|
+
return [
|
|
1424
|
+
messages.length,
|
|
1425
|
+
last.id,
|
|
1426
|
+
last.text.length,
|
|
1427
|
+
attachCount,
|
|
1428
|
+
hasAudio,
|
|
1429
|
+
last.receiptStatus ?? "",
|
|
1430
|
+
messagesStatus,
|
|
1431
|
+
].join("|");
|
|
1432
|
+
}, [messages, messagesStatus]);
|
|
1433
|
+
|
|
1434
|
+
const scrollMessagesToBottom = useCallback(() => {
|
|
1435
|
+
const node = scrollerRef.current;
|
|
1436
|
+
if (!node) return;
|
|
1437
|
+
node.scrollTop = node.scrollHeight;
|
|
1438
|
+
}, []);
|
|
1439
|
+
|
|
1440
|
+
const scheduleScrollMessagesToBottom = useCallback(() => {
|
|
1441
|
+
scrollMessagesToBottom();
|
|
1442
|
+
let rafNested = 0;
|
|
1443
|
+
const rafOuter = requestAnimationFrame(() => {
|
|
1444
|
+
scrollMessagesToBottom();
|
|
1445
|
+
rafNested = requestAnimationFrame(scrollMessagesToBottom);
|
|
1446
|
+
});
|
|
1447
|
+
const timeouts = [50, 150, 320].map((ms) =>
|
|
1448
|
+
window.setTimeout(scrollMessagesToBottom, ms),
|
|
1449
|
+
);
|
|
1450
|
+
return () => {
|
|
1451
|
+
cancelAnimationFrame(rafOuter);
|
|
1452
|
+
if (rafNested) cancelAnimationFrame(rafNested);
|
|
1453
|
+
timeouts.forEach(clearTimeout);
|
|
1454
|
+
};
|
|
1455
|
+
}, [scrollMessagesToBottom]);
|
|
1456
|
+
|
|
398
1457
|
const setOpenWithNotify = useCallback(
|
|
399
1458
|
(next: boolean) => {
|
|
400
1459
|
if (next) onUserGesture?.();
|
|
@@ -404,6 +1463,18 @@ function InternalChatPanel({
|
|
|
404
1463
|
[onOpenChange, onUserGesture],
|
|
405
1464
|
);
|
|
406
1465
|
|
|
1466
|
+
useEffect(() => {
|
|
1467
|
+
if (!open || !activeRoomId) return;
|
|
1468
|
+
onMarkRoomRead?.(activeRoomId);
|
|
1469
|
+
}, [open, activeRoomId, onMarkRoomRead]);
|
|
1470
|
+
|
|
1471
|
+
useEffect(() => {
|
|
1472
|
+
if (!open) return;
|
|
1473
|
+
if (screenRecorderSession.active) {
|
|
1474
|
+
setScreenRecorderOpen(true);
|
|
1475
|
+
}
|
|
1476
|
+
}, [open, screenRecorderSession.active]);
|
|
1477
|
+
|
|
407
1478
|
const handleMessagesScroll = useCallback(() => {
|
|
408
1479
|
const node = scrollerRef.current;
|
|
409
1480
|
if (!node || !activeRoomId || !onLoadOlderMessages) return;
|
|
@@ -414,12 +1485,12 @@ function InternalChatPanel({
|
|
|
414
1485
|
const prevHeight = node.scrollHeight;
|
|
415
1486
|
const prevTop = node.scrollTop;
|
|
416
1487
|
void Promise.resolve(onLoadOlderMessages()).finally(() => {
|
|
417
|
-
loadingOlderMessagesRef.current = false;
|
|
418
1488
|
requestAnimationFrame(() => {
|
|
419
1489
|
const n = scrollerRef.current;
|
|
420
1490
|
if (!n) return;
|
|
421
1491
|
const delta = n.scrollHeight - prevHeight;
|
|
422
1492
|
n.scrollTop = prevTop + delta;
|
|
1493
|
+
loadingOlderMessagesRef.current = false;
|
|
423
1494
|
});
|
|
424
1495
|
});
|
|
425
1496
|
}, [
|
|
@@ -430,27 +1501,45 @@ function InternalChatPanel({
|
|
|
430
1501
|
]);
|
|
431
1502
|
|
|
432
1503
|
useLayoutEffect(() => {
|
|
433
|
-
if (!open) return;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
);
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
1504
|
+
if (!open || !activeRoomId) return;
|
|
1505
|
+
if (loadingOlderMessagesRef.current) return;
|
|
1506
|
+
return scheduleScrollMessagesToBottom();
|
|
1507
|
+
}, [
|
|
1508
|
+
open,
|
|
1509
|
+
activeRoomId,
|
|
1510
|
+
messagesScrollTail,
|
|
1511
|
+
typingLine,
|
|
1512
|
+
voiceRecorderOpen,
|
|
1513
|
+
screenRecorderOpen,
|
|
1514
|
+
scheduleScrollMessagesToBottom,
|
|
1515
|
+
]);
|
|
1516
|
+
|
|
1517
|
+
useEffect(() => {
|
|
1518
|
+
if (!open || !activeRoomId) return;
|
|
1519
|
+
const scroller = scrollerRef.current;
|
|
1520
|
+
const list = messagesListRef.current;
|
|
1521
|
+
const textarea = textareaRef.current;
|
|
1522
|
+
if (!scroller) return;
|
|
1523
|
+
|
|
1524
|
+
const pinBottom = () => {
|
|
1525
|
+
if (loadingOlderMessagesRef.current) return;
|
|
1526
|
+
scrollMessagesToBottom();
|
|
452
1527
|
};
|
|
453
|
-
|
|
1528
|
+
|
|
1529
|
+
const ro = new ResizeObserver(pinBottom);
|
|
1530
|
+
ro.observe(scroller);
|
|
1531
|
+
if (list) ro.observe(list);
|
|
1532
|
+
if (textarea) ro.observe(textarea);
|
|
1533
|
+
const form = formRef.current;
|
|
1534
|
+
if (form) ro.observe(form);
|
|
1535
|
+
|
|
1536
|
+
return () => ro.disconnect();
|
|
1537
|
+
}, [
|
|
1538
|
+
open,
|
|
1539
|
+
activeRoomId,
|
|
1540
|
+
messagesScrollTail,
|
|
1541
|
+
scrollMessagesToBottom,
|
|
1542
|
+
]);
|
|
454
1543
|
|
|
455
1544
|
useEffect(() => {
|
|
456
1545
|
if (!mentionOpen || mentionEntries.length === 0) return;
|
|
@@ -582,6 +1671,7 @@ function InternalChatPanel({
|
|
|
582
1671
|
await onSendMessage(text);
|
|
583
1672
|
setDraft("");
|
|
584
1673
|
onDraftChange?.("");
|
|
1674
|
+
scheduleScrollMessagesToBottom();
|
|
585
1675
|
} catch {
|
|
586
1676
|
/* errores vía contenedor */
|
|
587
1677
|
}
|
|
@@ -615,7 +1705,11 @@ function InternalChatPanel({
|
|
|
615
1705
|
onDeleteRoomBlocked?.("La sala Organización no se puede eliminar.");
|
|
616
1706
|
return;
|
|
617
1707
|
}
|
|
618
|
-
if (
|
|
1708
|
+
if (activeRoom.isSupport) {
|
|
1709
|
+
onDeleteRoomBlocked?.("La sala de soporte no se puede eliminar.");
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (teamRooms.length <= 1) {
|
|
619
1713
|
onDeleteRoomBlocked?.("Debe existir al menos una sala.");
|
|
620
1714
|
return;
|
|
621
1715
|
}
|
|
@@ -624,15 +1718,65 @@ function InternalChatPanel({
|
|
|
624
1718
|
|
|
625
1719
|
return (
|
|
626
1720
|
<>
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1721
|
+
{!hideTrigger ? (
|
|
1722
|
+
<Button
|
|
1723
|
+
type="button"
|
|
1724
|
+
size="icon"
|
|
1725
|
+
className={cn("relative size-9 text-primary-foreground", className)}
|
|
1726
|
+
aria-label={
|
|
1727
|
+
unreadTotal > 0
|
|
1728
|
+
? `${labels.openChat} · ${unreadTotal} sin leer`
|
|
1729
|
+
: labels.openChat
|
|
1730
|
+
}
|
|
1731
|
+
onClick={() => setOpenWithNotify(true)}
|
|
1732
|
+
>
|
|
1733
|
+
<IconMessages className="size-4" />
|
|
1734
|
+
{unreadTotal > 0 ? (
|
|
1735
|
+
<span
|
|
1736
|
+
className={unreadCountBadgeClasses(formatUnreadBadge(unreadTotal))}
|
|
1737
|
+
aria-hidden
|
|
1738
|
+
>
|
|
1739
|
+
{formatUnreadBadge(unreadTotal)}
|
|
1740
|
+
</span>
|
|
1741
|
+
) : null}
|
|
1742
|
+
{screenRecorderSession.recording ? (
|
|
1743
|
+
<span
|
|
1744
|
+
className="absolute -top-0.5 -left-0.5 size-2.5 animate-pulse rounded-full bg-destructive ring-2 ring-background"
|
|
1745
|
+
aria-hidden
|
|
1746
|
+
/>
|
|
1747
|
+
) : null}
|
|
1748
|
+
</Button>
|
|
1749
|
+
) : !open ? (
|
|
1750
|
+
<Button
|
|
1751
|
+
type="button"
|
|
1752
|
+
variant="outline"
|
|
1753
|
+
size="sm"
|
|
1754
|
+
className="gap-2"
|
|
1755
|
+
onClick={() => setOpenWithNotify(true)}
|
|
1756
|
+
>
|
|
1757
|
+
<IconMessages className="size-4" />
|
|
1758
|
+
{labels.openChat}
|
|
1759
|
+
</Button>
|
|
1760
|
+
) : null}
|
|
1761
|
+
|
|
1762
|
+
{!open && screenRecorderSession.active ? (
|
|
1763
|
+
<Button
|
|
1764
|
+
type="button"
|
|
1765
|
+
variant="destructive"
|
|
1766
|
+
size="sm"
|
|
1767
|
+
className="fixed bottom-6 right-6 z-[200] h-9 gap-2 shadow-lg"
|
|
1768
|
+
onClick={() => setOpenWithNotify(true)}
|
|
1769
|
+
>
|
|
1770
|
+
<span className="size-2 animate-pulse rounded-full bg-destructive-foreground" />
|
|
1771
|
+
<span className="text-xs font-medium tabular-nums">
|
|
1772
|
+
{screenRecorderSession.recording
|
|
1773
|
+
? `REC ${screenRecorderSession.timerLabel}`
|
|
1774
|
+
: screenRecorderSession.blob
|
|
1775
|
+
? labels.screenRecordOpenChatToFinish
|
|
1776
|
+
: labels.screenRecordTitle}
|
|
1777
|
+
</span>
|
|
1778
|
+
</Button>
|
|
1779
|
+
) : null}
|
|
636
1780
|
|
|
637
1781
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
638
1782
|
<DialogContent className="sm:max-w-md">
|
|
@@ -700,6 +1844,7 @@ function InternalChatPanel({
|
|
|
700
1844
|
onOpenChange={setOpenWithNotify}
|
|
701
1845
|
direction="right"
|
|
702
1846
|
shouldScaleBackground={false}
|
|
1847
|
+
modal={!embedded}
|
|
703
1848
|
>
|
|
704
1849
|
<DrawerContent
|
|
705
1850
|
className={cn(
|
|
@@ -735,7 +1880,7 @@ function InternalChatPanel({
|
|
|
735
1880
|
</Tooltip>
|
|
736
1881
|
|
|
737
1882
|
<div className="flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1">
|
|
738
|
-
{
|
|
1883
|
+
{teamRooms.map((room) => {
|
|
739
1884
|
const selected = room.id === activeRoomId;
|
|
740
1885
|
return (
|
|
741
1886
|
<Tooltip key={room.id} delayDuration={300}>
|
|
@@ -755,6 +1900,19 @@ function InternalChatPanel({
|
|
|
755
1900
|
<span className="text-xs font-semibold leading-none">
|
|
756
1901
|
{roomInitialLetter(room.name)}
|
|
757
1902
|
</span>
|
|
1903
|
+
{(unreadByRoomId?.[room.id] ?? 0) > 0 ? (
|
|
1904
|
+
<span
|
|
1905
|
+
className={cn(
|
|
1906
|
+
unreadCountBadgeClasses(
|
|
1907
|
+
formatUnreadBadge(unreadByRoomId![room.id]!),
|
|
1908
|
+
),
|
|
1909
|
+
"ring-2 ring-background",
|
|
1910
|
+
)}
|
|
1911
|
+
aria-hidden
|
|
1912
|
+
>
|
|
1913
|
+
{formatUnreadBadge(unreadByRoomId![room.id]!)}
|
|
1914
|
+
</span>
|
|
1915
|
+
) : null}
|
|
758
1916
|
</button>
|
|
759
1917
|
</TooltipTrigger>
|
|
760
1918
|
<TooltipContent side="left">
|
|
@@ -767,6 +1925,49 @@ function InternalChatPanel({
|
|
|
767
1925
|
})}
|
|
768
1926
|
</div>
|
|
769
1927
|
|
|
1928
|
+
{supportRoom ? (
|
|
1929
|
+
<Tooltip delayDuration={300}>
|
|
1930
|
+
<TooltipTrigger asChild>
|
|
1931
|
+
<Button
|
|
1932
|
+
type="button"
|
|
1933
|
+
variant="ghost"
|
|
1934
|
+
size="icon"
|
|
1935
|
+
className={cn(
|
|
1936
|
+
"relative size-9 shrink-0 rounded-full border shadow-sm",
|
|
1937
|
+
activeRoomId === supportRoom.id
|
|
1938
|
+
? "border-sky-600 bg-sky-600 text-white hover:bg-sky-600/90 hover:text-white dark:border-sky-500 dark:bg-sky-500"
|
|
1939
|
+
: "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",
|
|
1940
|
+
)}
|
|
1941
|
+
aria-label={labels.supportChatTooltip}
|
|
1942
|
+
aria-current={
|
|
1943
|
+
activeRoomId === supportRoom.id ? "true" : undefined
|
|
1944
|
+
}
|
|
1945
|
+
onClick={() => onActiveRoomChange(supportRoom.id)}
|
|
1946
|
+
>
|
|
1947
|
+
<IconHeadset className="size-4 stroke-[2.25]" />
|
|
1948
|
+
{(unreadByRoomId?.[supportRoom.id] ?? 0) > 0 ? (
|
|
1949
|
+
<span
|
|
1950
|
+
className={cn(
|
|
1951
|
+
unreadCountBadgeClasses(
|
|
1952
|
+
formatUnreadBadge(
|
|
1953
|
+
unreadByRoomId![supportRoom.id]!,
|
|
1954
|
+
),
|
|
1955
|
+
),
|
|
1956
|
+
"ring-2 ring-background",
|
|
1957
|
+
)}
|
|
1958
|
+
aria-hidden
|
|
1959
|
+
>
|
|
1960
|
+
{formatUnreadBadge(unreadByRoomId![supportRoom.id]!)}
|
|
1961
|
+
</span>
|
|
1962
|
+
) : null}
|
|
1963
|
+
</Button>
|
|
1964
|
+
</TooltipTrigger>
|
|
1965
|
+
<TooltipContent side="left">
|
|
1966
|
+
{labels.supportChatTooltip}
|
|
1967
|
+
</TooltipContent>
|
|
1968
|
+
</Tooltip>
|
|
1969
|
+
) : null}
|
|
1970
|
+
|
|
770
1971
|
<DropdownMenu>
|
|
771
1972
|
<DropdownMenuTrigger asChild>
|
|
772
1973
|
<Button
|
|
@@ -787,7 +1988,9 @@ function InternalChatPanel({
|
|
|
787
1988
|
<DropdownMenuSeparator />
|
|
788
1989
|
<DropdownMenuItem
|
|
789
1990
|
disabled={
|
|
790
|
-
|
|
1991
|
+
teamRooms.length <= 1 ||
|
|
1992
|
+
activeRoom?.isOrganization ||
|
|
1993
|
+
activeRoom?.isSupport
|
|
791
1994
|
}
|
|
792
1995
|
className="text-destructive focus:text-destructive"
|
|
793
1996
|
onClick={requestDeleteRoom}
|
|
@@ -808,19 +2011,23 @@ function InternalChatPanel({
|
|
|
808
2011
|
</DrawerTitle>
|
|
809
2012
|
<DrawerDescription className="line-clamp-2">
|
|
810
2013
|
{activeRoom ? (
|
|
811
|
-
|
|
812
|
-
<span
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
2014
|
+
activeRoom.isSupport ? (
|
|
2015
|
+
<span>{labels.supportRoomDescription}</span>
|
|
2016
|
+
) : (
|
|
2017
|
+
<>
|
|
2018
|
+
<span className="font-medium text-foreground">
|
|
2019
|
+
{activeRoom.name}
|
|
2020
|
+
</span>
|
|
2021
|
+
{activeRoom.isOrganization ? (
|
|
2022
|
+
<>{labels.orgRoomSuffix}</>
|
|
2023
|
+
) : null}
|
|
2024
|
+
{" · "}
|
|
2025
|
+
<kbd className="pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]">
|
|
2026
|
+
/
|
|
2027
|
+
</kbd>{" "}
|
|
2028
|
+
{labels.slashMentionHint}
|
|
2029
|
+
</>
|
|
2030
|
+
)
|
|
824
2031
|
) : (
|
|
825
2032
|
labels.selectRoomHint
|
|
826
2033
|
)}
|
|
@@ -841,7 +2048,7 @@ function InternalChatPanel({
|
|
|
841
2048
|
|
|
842
2049
|
<div
|
|
843
2050
|
ref={scrollerRef}
|
|
844
|
-
className="min-h-0 flex-1 overflow-y-auto"
|
|
2051
|
+
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
|
845
2052
|
onScroll={handleMessagesScroll}
|
|
846
2053
|
>
|
|
847
2054
|
{!activeRoom ? (
|
|
@@ -886,7 +2093,10 @@ function InternalChatPanel({
|
|
|
886
2093
|
</EmptyHeader>
|
|
887
2094
|
</Empty>
|
|
888
2095
|
) : (
|
|
889
|
-
<div
|
|
2096
|
+
<div
|
|
2097
|
+
ref={messagesListRef}
|
|
2098
|
+
className="flex flex-col gap-4 px-5 pt-2 pb-6"
|
|
2099
|
+
>
|
|
890
2100
|
{isLoadingOlder ? (
|
|
891
2101
|
<div className="flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground">
|
|
892
2102
|
<IconLoader2
|
|
@@ -908,20 +2118,64 @@ function InternalChatPanel({
|
|
|
908
2118
|
{message.initials}
|
|
909
2119
|
</MessageAvatar>
|
|
910
2120
|
)}
|
|
911
|
-
<MessageContent>
|
|
2121
|
+
<MessageContent className="gap-0.5">
|
|
912
2122
|
{mine ? null : (
|
|
913
|
-
<MessageHeader>
|
|
2123
|
+
<MessageHeader className="px-3 pb-0 leading-snug">
|
|
2124
|
+
{message.author}
|
|
2125
|
+
</MessageHeader>
|
|
914
2126
|
)}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
2127
|
+
{message.attachments?.length ? (
|
|
2128
|
+
<InternalChatAttachmentStack
|
|
2129
|
+
items={message.attachments}
|
|
2130
|
+
labels={labels}
|
|
2131
|
+
/>
|
|
2132
|
+
) : null}
|
|
2133
|
+
{message.audio ? (
|
|
2134
|
+
<Bubble
|
|
2135
|
+
align={mine ? "end" : "start"}
|
|
2136
|
+
variant={mine ? "muted" : "outline"}
|
|
2137
|
+
>
|
|
2138
|
+
<BubbleContent className="py-2">
|
|
2139
|
+
<InternalChatAudioBubble
|
|
2140
|
+
audio={message.audio}
|
|
2141
|
+
seed={message.id}
|
|
2142
|
+
mine={mine}
|
|
2143
|
+
/>
|
|
2144
|
+
</BubbleContent>
|
|
2145
|
+
</Bubble>
|
|
2146
|
+
) : null}
|
|
2147
|
+
{message.text.trim() ? (
|
|
2148
|
+
<Bubble
|
|
2149
|
+
align={mine ? "end" : "start"}
|
|
2150
|
+
variant={mine ? "muted" : "outline"}
|
|
2151
|
+
>
|
|
2152
|
+
<BubbleContent className="break-words whitespace-pre-wrap">
|
|
2153
|
+
{renderMessageTextWithMentions(message.text)}
|
|
2154
|
+
</BubbleContent>
|
|
2155
|
+
</Bubble>
|
|
2156
|
+
) : null}
|
|
2157
|
+
<MessageFooter className="flex items-center justify-end gap-1 px-3 pt-0 leading-none">
|
|
2158
|
+
<span className="text-[0.65rem] tabular-nums text-muted-foreground">
|
|
2159
|
+
{formatMessageTime(message.at)}
|
|
2160
|
+
</span>
|
|
2161
|
+
{mine && message.receiptStatus ? (
|
|
2162
|
+
<span
|
|
2163
|
+
className="inline-flex shrink-0"
|
|
2164
|
+
title={
|
|
2165
|
+
message.receiptStatus === "pending"
|
|
2166
|
+
? "Enviando"
|
|
2167
|
+
: message.receiptStatus === "sent"
|
|
2168
|
+
? "Enviado"
|
|
2169
|
+
: message.receiptStatus === "delivered"
|
|
2170
|
+
? "Entregado"
|
|
2171
|
+
: "Leído"
|
|
2172
|
+
}
|
|
2173
|
+
>
|
|
2174
|
+
<MessageReceiptTicks
|
|
2175
|
+
status={message.receiptStatus}
|
|
2176
|
+
/>
|
|
2177
|
+
</span>
|
|
2178
|
+
) : null}
|
|
925
2179
|
</MessageFooter>
|
|
926
2180
|
</MessageContent>
|
|
927
2181
|
</Message>
|
|
@@ -939,6 +2193,23 @@ function InternalChatPanel({
|
|
|
939
2193
|
) : null}
|
|
940
2194
|
</div>
|
|
941
2195
|
|
|
2196
|
+
{voiceRecorderOpen && activeRoom ? (
|
|
2197
|
+
<InternalChatVoiceRecorderBar
|
|
2198
|
+
labels={labels}
|
|
2199
|
+
onClose={() => setVoiceRecorderOpen(false)}
|
|
2200
|
+
onSendBlob={onSendAudioBlob}
|
|
2201
|
+
onSendLegacy={onSendAudio}
|
|
2202
|
+
/>
|
|
2203
|
+
) : null}
|
|
2204
|
+
|
|
2205
|
+
{activeRoom && screenRecorderOpen ? (
|
|
2206
|
+
<InternalChatScreenRecorderBar
|
|
2207
|
+
labels={labels}
|
|
2208
|
+
session={screenRecorderSession}
|
|
2209
|
+
onDismiss={() => setScreenRecorderOpen(false)}
|
|
2210
|
+
/>
|
|
2211
|
+
) : null}
|
|
2212
|
+
|
|
942
2213
|
<form
|
|
943
2214
|
ref={formRef}
|
|
944
2215
|
onSubmit={onSubmit}
|
|
@@ -1204,24 +2475,105 @@ function InternalChatPanel({
|
|
|
1204
2475
|
<DropdownMenuContent
|
|
1205
2476
|
align="start"
|
|
1206
2477
|
side="top"
|
|
1207
|
-
className="w-
|
|
2478
|
+
className="w-48"
|
|
1208
2479
|
>
|
|
1209
|
-
<DropdownMenuItem
|
|
1210
|
-
|
|
2480
|
+
<DropdownMenuItem
|
|
2481
|
+
onClick={() => {
|
|
2482
|
+
onUserGesture?.();
|
|
2483
|
+
photoInputRef.current?.click();
|
|
2484
|
+
}}
|
|
2485
|
+
>
|
|
2486
|
+
<IconPhoto className="size-4" />
|
|
1211
2487
|
{labels.attachPhotos}
|
|
1212
2488
|
</DropdownMenuItem>
|
|
2489
|
+
<DropdownMenuItem
|
|
2490
|
+
onClick={() => {
|
|
2491
|
+
onUserGesture?.();
|
|
2492
|
+
fileInputRef.current?.click();
|
|
2493
|
+
}}
|
|
2494
|
+
>
|
|
2495
|
+
<IconPaperclip className="size-4" />
|
|
2496
|
+
{labels.attachFiles}
|
|
2497
|
+
</DropdownMenuItem>
|
|
1213
2498
|
</DropdownMenuContent>
|
|
1214
2499
|
</DropdownMenu>
|
|
1215
2500
|
<InputGroupButton
|
|
1216
|
-
type="
|
|
1217
|
-
variant=
|
|
2501
|
+
type="button"
|
|
2502
|
+
variant={
|
|
2503
|
+
screenRecorderOpen || screenRecorderSession.recording
|
|
2504
|
+
? "default"
|
|
2505
|
+
: "outline"
|
|
2506
|
+
}
|
|
1218
2507
|
size="icon-sm"
|
|
1219
|
-
disabled={!
|
|
1220
|
-
|
|
2508
|
+
disabled={!activeRoom}
|
|
2509
|
+
aria-label={labels.screenCaptureAriaLabel}
|
|
2510
|
+
aria-pressed={
|
|
2511
|
+
screenRecorderOpen || screenRecorderSession.recording
|
|
2512
|
+
}
|
|
2513
|
+
className={cn(
|
|
2514
|
+
!screenRecorderOpen &&
|
|
2515
|
+
!screenRecorderSession.recording &&
|
|
2516
|
+
"text-violet-600 hover:text-violet-700 dark:text-violet-400 dark:hover:text-violet-300",
|
|
2517
|
+
)}
|
|
2518
|
+
onClick={() => {
|
|
2519
|
+
onUserGesture?.();
|
|
2520
|
+
setVoiceRecorderOpen(false);
|
|
2521
|
+
setScreenRecorderOpen((prev) => !prev);
|
|
2522
|
+
}}
|
|
1221
2523
|
>
|
|
1222
|
-
<
|
|
1223
|
-
<span className="sr-only">{labels.sendSrOnly}</span>
|
|
2524
|
+
<IconScreenShare className="size-4" />
|
|
1224
2525
|
</InputGroupButton>
|
|
2526
|
+
<input
|
|
2527
|
+
ref={photoInputRef}
|
|
2528
|
+
type="file"
|
|
2529
|
+
accept="image/*"
|
|
2530
|
+
className="hidden"
|
|
2531
|
+
onChange={(e) => {
|
|
2532
|
+
const file = e.target.files?.[0];
|
|
2533
|
+
if (file && onAttachPhotos) void onAttachPhotos(file);
|
|
2534
|
+
e.target.value = "";
|
|
2535
|
+
}}
|
|
2536
|
+
/>
|
|
2537
|
+
<input
|
|
2538
|
+
ref={fileInputRef}
|
|
2539
|
+
type="file"
|
|
2540
|
+
className="hidden"
|
|
2541
|
+
onChange={(e) => {
|
|
2542
|
+
const file = e.target.files?.[0];
|
|
2543
|
+
if (file && onAttachFiles) void onAttachFiles(file);
|
|
2544
|
+
e.target.value = "";
|
|
2545
|
+
}}
|
|
2546
|
+
/>
|
|
2547
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
2548
|
+
<InputGroupButton
|
|
2549
|
+
type="button"
|
|
2550
|
+
variant={voiceRecorderOpen ? "default" : "outline"}
|
|
2551
|
+
size="icon-sm"
|
|
2552
|
+
disabled={!activeRoom}
|
|
2553
|
+
aria-label={labels.attachAudio}
|
|
2554
|
+
aria-pressed={voiceRecorderOpen}
|
|
2555
|
+
className={cn(
|
|
2556
|
+
!voiceRecorderOpen &&
|
|
2557
|
+
"text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300",
|
|
2558
|
+
)}
|
|
2559
|
+
onClick={() => {
|
|
2560
|
+
onUserGesture?.();
|
|
2561
|
+
setScreenRecorderOpen(false);
|
|
2562
|
+
setVoiceRecorderOpen((open) => !open);
|
|
2563
|
+
}}
|
|
2564
|
+
>
|
|
2565
|
+
<IconMicrophone className="size-4" />
|
|
2566
|
+
</InputGroupButton>
|
|
2567
|
+
<InputGroupButton
|
|
2568
|
+
type="submit"
|
|
2569
|
+
variant="default"
|
|
2570
|
+
size="icon-sm"
|
|
2571
|
+
disabled={!canSend || !activeRoom}
|
|
2572
|
+
>
|
|
2573
|
+
<IconArrowUp className="size-4" />
|
|
2574
|
+
<span className="sr-only">{labels.sendSrOnly}</span>
|
|
2575
|
+
</InputGroupButton>
|
|
2576
|
+
</div>
|
|
1225
2577
|
</InputGroupAddon>
|
|
1226
2578
|
</InputGroup>
|
|
1227
2579
|
</form>
|