@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.
@@ -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 y archivos",
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(false);
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
- const scrollToEnd = () => {
435
- const node = scrollerRef.current;
436
- if (!node) return;
437
- node.scrollTop = node.scrollHeight;
438
- };
439
- scrollToEnd();
440
- let rafNested = 0;
441
- const rafOuter = requestAnimationFrame(() => {
442
- scrollToEnd();
443
- rafNested = requestAnimationFrame(scrollToEnd);
444
- });
445
- const timeouts = [0, 50, 150].map((ms) =>
446
- window.setTimeout(scrollToEnd, ms),
447
- );
448
- return () => {
449
- cancelAnimationFrame(rafOuter);
450
- if (rafNested) cancelAnimationFrame(rafNested);
451
- timeouts.forEach(clearTimeout);
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
- }, [open, activeRoomId, messages.length]);
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 (rooms.length <= 1) {
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
- <Button
628
- type="button"
629
- size="icon"
630
- className={cn("relative size-9 text-primary-foreground", className)}
631
- aria-label={labels.openChat}
632
- onClick={() => setOpenWithNotify(true)}
633
- >
634
- <IconMessages className="size-4" />
635
- </Button>
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
- {rooms.map((room) => {
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
- rooms.length <= 1 || activeRoom?.isOrganization
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 className="font-medium text-foreground">
813
- {activeRoom.name}
814
- </span>
815
- {activeRoom.isOrganization ? (
816
- <>{labels.orgRoomSuffix}</>
817
- ) : null}
818
- {" · "}
819
- <kbd className="pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]">
820
- /
821
- </kbd>{" "}
822
- {labels.slashMentionHint}
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 className="flex flex-col gap-6 px-5 py-2">
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>{message.author}</MessageHeader>
2123
+ <MessageHeader className="px-3 pb-0 leading-snug">
2124
+ {message.author}
2125
+ </MessageHeader>
914
2126
  )}
915
- <Bubble
916
- align={mine ? "end" : "start"}
917
- variant={mine ? "muted" : "outline"}
918
- >
919
- <BubbleContent className="break-words whitespace-pre-wrap">
920
- {renderMessageTextWithMentions(message.text)}
921
- </BubbleContent>
922
- </Bubble>
923
- <MessageFooter>
924
- {formatMessageTime(message.at)}
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-44"
2478
+ className="w-48"
1208
2479
  >
1209
- <DropdownMenuItem>
1210
- <IconPaperclip className="size-4" />
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="submit"
1217
- variant="default"
2501
+ type="button"
2502
+ variant={
2503
+ screenRecorderOpen || screenRecorderSession.recording
2504
+ ? "default"
2505
+ : "outline"
2506
+ }
1218
2507
  size="icon-sm"
1219
- disabled={!canSend || !activeRoom}
1220
- className="ml-auto"
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
- <IconArrowUp className="size-4" />
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>