@traky-app/ui 1.6.3 → 1.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +15 -1
  2. package/registry/traky/json/city-select.json +19 -0
  3. package/registry/traky/json/country-select.json +24 -0
  4. package/registry/traky/json/geo-search-select.json +24 -0
  5. package/registry/traky/json/geo-selectors.json +21 -0
  6. package/registry/traky/json/google-maps-address-picker.json +22 -0
  7. package/registry/traky/json/internal-chat-panel.json +1 -1
  8. package/registry/traky/json/lib-geo-city-codes.json +14 -0
  9. package/registry/traky/json/lib-geo-countries.json +14 -0
  10. package/registry/traky/json/lib-geo-csc.json +17 -0
  11. package/registry/traky/json/lib-geo-locations.json +14 -0
  12. package/registry/traky/json/lib-geo-overrides.json +14 -0
  13. package/registry/traky/json/lib-geo-types.json +14 -0
  14. package/registry/traky/json/lib-google-maps-address.json +14 -0
  15. package/registry/traky/json/lib-phone-countries.json +14 -0
  16. package/registry/traky/json/lib-phone-e164.json +14 -0
  17. package/registry/traky/json/lib-utils.json +1 -1
  18. package/registry/traky/json/office-address-form-dialog.json +17 -0
  19. package/registry/traky/json/office-address-form.json +35 -0
  20. package/registry/traky/json/phone-number-input.json +25 -0
  21. package/registry/traky/json/state-select.json +19 -0
  22. package/registry/traky/lib/geo-city-codes.ts +234 -0
  23. package/registry/traky/lib/geo-countries.ts +75 -0
  24. package/registry/traky/lib/geo-csc.ts +82 -0
  25. package/registry/traky/lib/geo-locations.ts +171 -0
  26. package/registry/traky/lib/geo-overrides.ts +375 -0
  27. package/registry/traky/lib/geo-types.ts +34 -0
  28. package/registry/traky/lib/google-maps-address.ts +100 -0
  29. package/registry/traky/lib/phone-countries.ts +380 -0
  30. package/registry/traky/lib/phone-e164.ts +66 -0
  31. package/registry/traky/lib/utils.ts +24 -0
  32. package/registry/traky/registry.json +180 -0
  33. package/registry/traky/ui/city-select.tsx +113 -0
  34. package/registry/traky/ui/country-select.tsx +233 -0
  35. package/registry/traky/ui/geo-search-select.tsx +136 -0
  36. package/registry/traky/ui/geo-selectors.tsx +162 -0
  37. package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
  38. package/registry/traky/ui/internal-chat-panel.tsx +1417 -71
  39. package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
  40. package/registry/traky/ui/office-address-form.tsx +914 -0
  41. package/registry/traky/ui/phone-number-input.tsx +253 -0
  42. package/registry/traky/ui/state-select.tsx +107 -0
  43. package/src/lib/geo-city-codes.ts +234 -0
  44. package/src/lib/geo-countries.ts +75 -0
  45. package/src/lib/geo-csc.ts +82 -0
  46. package/src/lib/geo-locations.ts +171 -0
  47. package/src/lib/geo-overrides.ts +375 -0
  48. package/src/lib/geo-types.ts +34 -0
  49. package/src/lib/google-maps-address.ts +100 -0
  50. package/src/lib/phone-countries.ts +380 -0
  51. package/src/lib/phone-e164.ts +66 -0
  52. package/src/test/setup.ts +31 -0
  53. package/src/ui/__tests__/geo-selects.test.tsx +504 -0
  54. package/src/ui/city-select.tsx +113 -0
  55. package/src/ui/country-select.tsx +233 -0
  56. package/src/ui/geo-search-select.tsx +136 -0
  57. package/src/ui/geo-selectors.tsx +162 -0
  58. package/src/ui/google-maps-address-picker.tsx +422 -0
  59. package/src/ui/internal-chat-panel.tsx +1417 -71
  60. package/src/ui/office-address-form-dialog.tsx +16 -0
  61. package/src/ui/office-address-form.tsx +914 -0
  62. package/src/ui/phone-number-input.tsx +253 -0
  63. package/src/ui/state-select.tsx +107 -0
@@ -2,12 +2,22 @@
2
2
 
3
3
  import {
4
4
  IconArrowUp,
5
+ IconCheck,
6
+ IconChecks,
5
7
  IconDotsVertical,
8
+ IconFile,
9
+ IconHeadset,
6
10
  IconLoader2,
11
+ IconMicrophone,
7
12
  IconMessages,
8
13
  IconPackage,
9
14
  IconPaperclip,
15
+ IconPhoto,
16
+ IconPlayerPause,
17
+ IconPlayerPlay,
18
+ IconSquare,
10
19
  IconPlus,
20
+ IconScreenShare,
11
21
  IconSparkles,
12
22
  IconTrash,
13
23
  IconTruckLoading,
@@ -105,6 +115,29 @@ export type InternalChatRoom = {
105
115
  id: string;
106
116
  name: string;
107
117
  isOrganization?: boolean;
118
+ /** Canal reservado con el administrador / soporte Traky. */
119
+ isSupport?: boolean;
120
+ };
121
+
122
+ /** Acuse de recibo estilo mensajería (1 palomita = enviado, 2 grises = llegó, 2 azules = leído). */
123
+ export type InternalChatMessageReceiptStatus =
124
+ | "pending"
125
+ | "sent"
126
+ | "delivered"
127
+ | "read";
128
+
129
+ export type InternalChatMessageAttachment = {
130
+ kind: "image" | "file" | "video";
131
+ name: string;
132
+ url?: string;
133
+ sizeLabel?: string;
134
+ };
135
+
136
+ const SCREEN_RECORD_MAX_MS = 60_000;
137
+
138
+ export type InternalChatMessageAudio = {
139
+ src: string;
140
+ durationMs?: number;
108
141
  };
109
142
 
110
143
  export type InternalChatMessageLine = {
@@ -114,6 +147,11 @@ export type InternalChatMessageLine = {
114
147
  initials: string;
115
148
  text: string;
116
149
  at: string;
150
+ kind?: "text" | "audio";
151
+ audio?: InternalChatMessageAudio;
152
+ attachments?: InternalChatMessageAttachment[];
153
+ /** Solo mensajes propios (`role: "me"`). */
154
+ receiptStatus?: InternalChatMessageReceiptStatus;
117
155
  };
118
156
 
119
157
  export type InternalChatMessagesStatus =
@@ -151,11 +189,36 @@ export type InternalChatPanelLabels = {
151
189
  messageAriaLabel: string;
152
190
  attachAriaLabel: string;
153
191
  attachPhotos: string;
192
+ attachFiles: string;
193
+ attachAudio: string;
194
+ attachAudioFile: string;
195
+ attachmentPending: string;
196
+ voiceRecordTitle: string;
197
+ voiceRecordHint: string;
198
+ voiceRecordStart: string;
199
+ voiceRecordStop: string;
200
+ voiceRecordSend: string;
201
+ voiceRecordCancel: string;
202
+ voiceRecordPermissionError: string;
203
+ screenCaptureAriaLabel: string;
204
+ screenRecordTitle: string;
205
+ screenRecordHint: string;
206
+ screenRecordStart: string;
207
+ screenRecordStop: string;
208
+ screenRecordSend: string;
209
+ screenRecordCancel: string;
210
+ screenRecordPermissionError: string;
211
+ screenRecordMaxReached: string;
212
+ screenRecordBackgroundHint: string;
213
+ screenRecordOpenChatToFinish: string;
154
214
  sendSrOnly: string;
155
215
  orgRoomSuffix: string;
156
216
  orgRoomTooltip: string;
157
217
  slashMentionHint: string;
158
218
  selectRoomHint: string;
219
+ supportChat: string;
220
+ supportChatTooltip: string;
221
+ supportRoomDescription: string;
159
222
  };
160
223
 
161
224
  const DEFAULT_LABELS: InternalChatPanelLabels = {
@@ -189,12 +252,42 @@ const DEFAULT_LABELS: InternalChatPanelLabels = {
189
252
  messagePlaceholderNoRoom: "Selecciona una sala…",
190
253
  messageAriaLabel: "Mensaje",
191
254
  attachAriaLabel: "Adjuntar",
192
- attachPhotos: "Fotos 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,32 @@ export type InternalChatPanelProps = {
298
1252
  onDeleteRoomBlocked?: (reason: string) => void;
299
1253
  labels?: Partial<InternalChatPanelLabels>;
300
1254
  className?: string;
1255
+ /** Mensajes no vistos (icono del header: badge rojo, hasta «9+»). */
1256
+ unreadTotal?: number;
1257
+ /** No leídos por sala (badge en el rail de salas). */
1258
+ unreadByRoomId?: Record<string, number>;
1259
+ /** Se invoca al abrir el panel o al cambiar de sala con el chat visible. */
1260
+ onMarkRoomRead?: (roomId: string) => void;
1261
+ /** Conexión BFF: el contenedor sube el archivo. */
1262
+ onAttachPhotos?: (file: File) => void | Promise<void>;
1263
+ onAttachFiles?: (file: File) => void | Promise<void>;
1264
+ /** Tras grabar en el panel (conexión BFF / subida). */
1265
+ onSendAudioBlob?: (
1266
+ blob: Blob,
1267
+ durationMs: number,
1268
+ ) => void | Promise<void>;
1269
+ /** Atajo legacy: el host abre su propio flujo sin el grabador integrado. */
1270
+ onSendAudio?: () => void | Promise<void>;
1271
+ onAttachAudioFile?: (file: File) => void | Promise<void>;
1272
+ /** Video de captura de pantalla (máx. 1 min en el grabador integrado). */
1273
+ onSendScreenRecordingBlob?: (
1274
+ blob: Blob,
1275
+ durationMs: number,
1276
+ ) => void | Promise<void>;
1277
+ /** Abre el drawer al montar (p. ej. `/admin/.../chat`). */
1278
+ defaultOpen?: boolean;
1279
+ /** Oculta el botón flotante; usar con `defaultOpen` en página dedicada. */
1280
+ hideTrigger?: boolean;
301
1281
  };
302
1282
 
303
1283
  function InternalChatPanel({
@@ -330,14 +1310,33 @@ function InternalChatPanel({
330
1310
  onDeleteRoomBlocked,
331
1311
  labels: labelsPartial,
332
1312
  className,
1313
+ unreadTotal = 0,
1314
+ unreadByRoomId,
1315
+ onMarkRoomRead,
1316
+ onAttachPhotos,
1317
+ onAttachFiles,
1318
+ onSendAudioBlob,
1319
+ onSendAudio,
1320
+ onSendScreenRecordingBlob,
1321
+ defaultOpen = false,
1322
+ hideTrigger = false,
333
1323
  }: InternalChatPanelProps) {
334
1324
  const labels = { ...DEFAULT_LABELS, ...labelsPartial };
335
1325
 
336
- const [open, setOpen] = useState(false);
1326
+ const [open, setOpen] = useState(defaultOpen);
337
1327
  const [draft, setDraft] = useState("");
338
1328
  const [createOpen, setCreateOpen] = useState(false);
339
1329
  const [newRoomName, setNewRoomName] = useState("");
340
1330
  const [deleteOpen, setDeleteOpen] = useState(false);
1331
+ const [voiceRecorderOpen, setVoiceRecorderOpen] = useState(false);
1332
+ const [screenRecorderOpen, setScreenRecorderOpen] = useState(false);
1333
+ const screenRecorderSession = useInternalChatScreenRecorderSession(
1334
+ labels,
1335
+ onSendScreenRecordingBlob,
1336
+ useCallback((isRecording: boolean) => {
1337
+ if (isRecording) setScreenRecorderOpen(true);
1338
+ }, []),
1339
+ );
341
1340
  const [mentionStart, setMentionStart] = useState<number | null>(null);
342
1341
  const [mentionQuery, setMentionQuery] = useState("");
343
1342
  const [mentionHighlight, setMentionHighlight] = useState(0);
@@ -346,7 +1345,20 @@ function InternalChatPanel({
346
1345
 
347
1346
  const formRef = useRef<HTMLFormElement>(null);
348
1347
  const scrollerRef = useRef<HTMLDivElement>(null);
1348
+ const messagesListRef = useRef<HTMLDivElement>(null);
349
1349
  const textareaRef = useRef<HTMLTextAreaElement>(null);
1350
+ const photoInputRef = useRef<HTMLInputElement>(null);
1351
+ const fileInputRef = useRef<HTMLInputElement>(null);
1352
+
1353
+ const supportRoom = useMemo(
1354
+ () => rooms.find((r) => r.isSupport) ?? null,
1355
+ [rooms],
1356
+ );
1357
+
1358
+ const teamRooms = useMemo(
1359
+ () => rooms.filter((r) => !r.isSupport),
1360
+ [rooms],
1361
+ );
350
1362
 
351
1363
  const activeRoom = useMemo(
352
1364
  () => rooms.find((r) => r.id === activeRoomId) ?? null,
@@ -395,6 +1407,47 @@ function InternalChatPanel({
395
1407
  !isSending &&
396
1408
  Boolean(activeRoomId);
397
1409
 
1410
+ const messagesScrollTail = useMemo(() => {
1411
+ if (messages.length === 0) {
1412
+ return `empty:${messagesStatus}`;
1413
+ }
1414
+ const last = messages[messages.length - 1]!;
1415
+ const attachCount = last.attachments?.length ?? 0;
1416
+ const hasAudio = last.audio ? 1 : 0;
1417
+ return [
1418
+ messages.length,
1419
+ last.id,
1420
+ last.text.length,
1421
+ attachCount,
1422
+ hasAudio,
1423
+ last.receiptStatus ?? "",
1424
+ messagesStatus,
1425
+ ].join("|");
1426
+ }, [messages, messagesStatus]);
1427
+
1428
+ const scrollMessagesToBottom = useCallback(() => {
1429
+ const node = scrollerRef.current;
1430
+ if (!node) return;
1431
+ node.scrollTop = node.scrollHeight;
1432
+ }, []);
1433
+
1434
+ const scheduleScrollMessagesToBottom = useCallback(() => {
1435
+ scrollMessagesToBottom();
1436
+ let rafNested = 0;
1437
+ const rafOuter = requestAnimationFrame(() => {
1438
+ scrollMessagesToBottom();
1439
+ rafNested = requestAnimationFrame(scrollMessagesToBottom);
1440
+ });
1441
+ const timeouts = [50, 150, 320].map((ms) =>
1442
+ window.setTimeout(scrollMessagesToBottom, ms),
1443
+ );
1444
+ return () => {
1445
+ cancelAnimationFrame(rafOuter);
1446
+ if (rafNested) cancelAnimationFrame(rafNested);
1447
+ timeouts.forEach(clearTimeout);
1448
+ };
1449
+ }, [scrollMessagesToBottom]);
1450
+
398
1451
  const setOpenWithNotify = useCallback(
399
1452
  (next: boolean) => {
400
1453
  if (next) onUserGesture?.();
@@ -404,6 +1457,18 @@ function InternalChatPanel({
404
1457
  [onOpenChange, onUserGesture],
405
1458
  );
406
1459
 
1460
+ useEffect(() => {
1461
+ if (!open || !activeRoomId) return;
1462
+ onMarkRoomRead?.(activeRoomId);
1463
+ }, [open, activeRoomId, onMarkRoomRead]);
1464
+
1465
+ useEffect(() => {
1466
+ if (!open) return;
1467
+ if (screenRecorderSession.active) {
1468
+ setScreenRecorderOpen(true);
1469
+ }
1470
+ }, [open, screenRecorderSession.active]);
1471
+
407
1472
  const handleMessagesScroll = useCallback(() => {
408
1473
  const node = scrollerRef.current;
409
1474
  if (!node || !activeRoomId || !onLoadOlderMessages) return;
@@ -414,12 +1479,12 @@ function InternalChatPanel({
414
1479
  const prevHeight = node.scrollHeight;
415
1480
  const prevTop = node.scrollTop;
416
1481
  void Promise.resolve(onLoadOlderMessages()).finally(() => {
417
- loadingOlderMessagesRef.current = false;
418
1482
  requestAnimationFrame(() => {
419
1483
  const n = scrollerRef.current;
420
1484
  if (!n) return;
421
1485
  const delta = n.scrollHeight - prevHeight;
422
1486
  n.scrollTop = prevTop + delta;
1487
+ loadingOlderMessagesRef.current = false;
423
1488
  });
424
1489
  });
425
1490
  }, [
@@ -430,27 +1495,46 @@ function InternalChatPanel({
430
1495
  ]);
431
1496
 
432
1497
  useLayoutEffect(() => {
433
- if (!open) return;
434
- 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);
1498
+ if (!open || !activeRoomId) return;
1499
+ if (loadingOlderMessagesRef.current) return;
1500
+ return scheduleScrollMessagesToBottom();
1501
+ }, [
1502
+ open,
1503
+ activeRoomId,
1504
+ messagesScrollTail,
1505
+ typingLine,
1506
+ draft,
1507
+ voiceRecorderOpen,
1508
+ screenRecorderOpen,
1509
+ scheduleScrollMessagesToBottom,
1510
+ ]);
1511
+
1512
+ useEffect(() => {
1513
+ if (!open || !activeRoomId) return;
1514
+ const scroller = scrollerRef.current;
1515
+ const list = messagesListRef.current;
1516
+ const textarea = textareaRef.current;
1517
+ if (!scroller) return;
1518
+
1519
+ const pinBottom = () => {
1520
+ if (loadingOlderMessagesRef.current) return;
1521
+ scrollMessagesToBottom();
452
1522
  };
453
- }, [open, activeRoomId, messages.length]);
1523
+
1524
+ const ro = new ResizeObserver(pinBottom);
1525
+ ro.observe(scroller);
1526
+ if (list) ro.observe(list);
1527
+ if (textarea) ro.observe(textarea);
1528
+ const form = formRef.current;
1529
+ if (form) ro.observe(form);
1530
+
1531
+ return () => ro.disconnect();
1532
+ }, [
1533
+ open,
1534
+ activeRoomId,
1535
+ messagesScrollTail,
1536
+ scrollMessagesToBottom,
1537
+ ]);
454
1538
 
455
1539
  useEffect(() => {
456
1540
  if (!mentionOpen || mentionEntries.length === 0) return;
@@ -582,6 +1666,7 @@ function InternalChatPanel({
582
1666
  await onSendMessage(text);
583
1667
  setDraft("");
584
1668
  onDraftChange?.("");
1669
+ scheduleScrollMessagesToBottom();
585
1670
  } catch {
586
1671
  /* errores vía contenedor */
587
1672
  }
@@ -615,7 +1700,11 @@ function InternalChatPanel({
615
1700
  onDeleteRoomBlocked?.("La sala Organización no se puede eliminar.");
616
1701
  return;
617
1702
  }
618
- if (rooms.length <= 1) {
1703
+ if (activeRoom.isSupport) {
1704
+ onDeleteRoomBlocked?.("La sala de soporte no se puede eliminar.");
1705
+ return;
1706
+ }
1707
+ if (teamRooms.length <= 1) {
619
1708
  onDeleteRoomBlocked?.("Debe existir al menos una sala.");
620
1709
  return;
621
1710
  }
@@ -624,15 +1713,65 @@ function InternalChatPanel({
624
1713
 
625
1714
  return (
626
1715
  <>
627
- <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>
1716
+ {!hideTrigger ? (
1717
+ <Button
1718
+ type="button"
1719
+ size="icon"
1720
+ className={cn("relative size-9 text-primary-foreground", className)}
1721
+ aria-label={
1722
+ unreadTotal > 0
1723
+ ? `${labels.openChat} · ${unreadTotal} sin leer`
1724
+ : labels.openChat
1725
+ }
1726
+ onClick={() => setOpenWithNotify(true)}
1727
+ >
1728
+ <IconMessages className="size-4" />
1729
+ {unreadTotal > 0 ? (
1730
+ <span
1731
+ className={unreadCountBadgeClasses(formatUnreadBadge(unreadTotal))}
1732
+ aria-hidden
1733
+ >
1734
+ {formatUnreadBadge(unreadTotal)}
1735
+ </span>
1736
+ ) : null}
1737
+ {screenRecorderSession.recording ? (
1738
+ <span
1739
+ className="absolute -top-0.5 -left-0.5 size-2.5 animate-pulse rounded-full bg-destructive ring-2 ring-background"
1740
+ aria-hidden
1741
+ />
1742
+ ) : null}
1743
+ </Button>
1744
+ ) : !open ? (
1745
+ <Button
1746
+ type="button"
1747
+ variant="outline"
1748
+ size="sm"
1749
+ className="gap-2"
1750
+ onClick={() => setOpenWithNotify(true)}
1751
+ >
1752
+ <IconMessages className="size-4" />
1753
+ {labels.openChat}
1754
+ </Button>
1755
+ ) : null}
1756
+
1757
+ {!open && screenRecorderSession.active ? (
1758
+ <Button
1759
+ type="button"
1760
+ variant="destructive"
1761
+ size="sm"
1762
+ className="fixed bottom-6 right-6 z-[200] h-9 gap-2 shadow-lg"
1763
+ onClick={() => setOpenWithNotify(true)}
1764
+ >
1765
+ <span className="size-2 animate-pulse rounded-full bg-destructive-foreground" />
1766
+ <span className="text-xs font-medium tabular-nums">
1767
+ {screenRecorderSession.recording
1768
+ ? `REC ${screenRecorderSession.timerLabel}`
1769
+ : screenRecorderSession.blob
1770
+ ? labels.screenRecordOpenChatToFinish
1771
+ : labels.screenRecordTitle}
1772
+ </span>
1773
+ </Button>
1774
+ ) : null}
636
1775
 
637
1776
  <Dialog open={createOpen} onOpenChange={setCreateOpen}>
638
1777
  <DialogContent className="sm:max-w-md">
@@ -735,7 +1874,7 @@ function InternalChatPanel({
735
1874
  </Tooltip>
736
1875
 
737
1876
  <div className="flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1">
738
- {rooms.map((room) => {
1877
+ {teamRooms.map((room) => {
739
1878
  const selected = room.id === activeRoomId;
740
1879
  return (
741
1880
  <Tooltip key={room.id} delayDuration={300}>
@@ -755,6 +1894,19 @@ function InternalChatPanel({
755
1894
  <span className="text-xs font-semibold leading-none">
756
1895
  {roomInitialLetter(room.name)}
757
1896
  </span>
1897
+ {(unreadByRoomId?.[room.id] ?? 0) > 0 ? (
1898
+ <span
1899
+ className={cn(
1900
+ unreadCountBadgeClasses(
1901
+ formatUnreadBadge(unreadByRoomId![room.id]!),
1902
+ ),
1903
+ "ring-2 ring-background",
1904
+ )}
1905
+ aria-hidden
1906
+ >
1907
+ {formatUnreadBadge(unreadByRoomId![room.id]!)}
1908
+ </span>
1909
+ ) : null}
758
1910
  </button>
759
1911
  </TooltipTrigger>
760
1912
  <TooltipContent side="left">
@@ -767,6 +1919,49 @@ function InternalChatPanel({
767
1919
  })}
768
1920
  </div>
769
1921
 
1922
+ {supportRoom ? (
1923
+ <Tooltip delayDuration={300}>
1924
+ <TooltipTrigger asChild>
1925
+ <Button
1926
+ type="button"
1927
+ variant="ghost"
1928
+ size="icon"
1929
+ className={cn(
1930
+ "relative size-9 shrink-0 rounded-full border shadow-sm",
1931
+ activeRoomId === supportRoom.id
1932
+ ? "border-sky-600 bg-sky-600 text-white hover:bg-sky-600/90 hover:text-white dark:border-sky-500 dark:bg-sky-500"
1933
+ : "border-sky-500/70 bg-sky-500/20 text-sky-800 hover:bg-sky-500/30 dark:border-sky-400/60 dark:bg-sky-500/25 dark:text-sky-100",
1934
+ )}
1935
+ aria-label={labels.supportChatTooltip}
1936
+ aria-current={
1937
+ activeRoomId === supportRoom.id ? "true" : undefined
1938
+ }
1939
+ onClick={() => onActiveRoomChange(supportRoom.id)}
1940
+ >
1941
+ <IconHeadset className="size-4 stroke-[2.25]" />
1942
+ {(unreadByRoomId?.[supportRoom.id] ?? 0) > 0 ? (
1943
+ <span
1944
+ className={cn(
1945
+ unreadCountBadgeClasses(
1946
+ formatUnreadBadge(
1947
+ unreadByRoomId![supportRoom.id]!,
1948
+ ),
1949
+ ),
1950
+ "ring-2 ring-background",
1951
+ )}
1952
+ aria-hidden
1953
+ >
1954
+ {formatUnreadBadge(unreadByRoomId![supportRoom.id]!)}
1955
+ </span>
1956
+ ) : null}
1957
+ </Button>
1958
+ </TooltipTrigger>
1959
+ <TooltipContent side="left">
1960
+ {labels.supportChatTooltip}
1961
+ </TooltipContent>
1962
+ </Tooltip>
1963
+ ) : null}
1964
+
770
1965
  <DropdownMenu>
771
1966
  <DropdownMenuTrigger asChild>
772
1967
  <Button
@@ -787,7 +1982,9 @@ function InternalChatPanel({
787
1982
  <DropdownMenuSeparator />
788
1983
  <DropdownMenuItem
789
1984
  disabled={
790
- rooms.length <= 1 || activeRoom?.isOrganization
1985
+ teamRooms.length <= 1 ||
1986
+ activeRoom?.isOrganization ||
1987
+ activeRoom?.isSupport
791
1988
  }
792
1989
  className="text-destructive focus:text-destructive"
793
1990
  onClick={requestDeleteRoom}
@@ -808,19 +2005,23 @@ function InternalChatPanel({
808
2005
  </DrawerTitle>
809
2006
  <DrawerDescription className="line-clamp-2">
810
2007
  {activeRoom ? (
811
- <>
812
- <span 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
- </>
2008
+ activeRoom.isSupport ? (
2009
+ <span>{labels.supportRoomDescription}</span>
2010
+ ) : (
2011
+ <>
2012
+ <span className="font-medium text-foreground">
2013
+ {activeRoom.name}
2014
+ </span>
2015
+ {activeRoom.isOrganization ? (
2016
+ <>{labels.orgRoomSuffix}</>
2017
+ ) : null}
2018
+ {" · "}
2019
+ <kbd className="pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]">
2020
+ /
2021
+ </kbd>{" "}
2022
+ {labels.slashMentionHint}
2023
+ </>
2024
+ )
824
2025
  ) : (
825
2026
  labels.selectRoomHint
826
2027
  )}
@@ -841,7 +2042,7 @@ function InternalChatPanel({
841
2042
 
842
2043
  <div
843
2044
  ref={scrollerRef}
844
- className="min-h-0 flex-1 overflow-y-auto"
2045
+ className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
845
2046
  onScroll={handleMessagesScroll}
846
2047
  >
847
2048
  {!activeRoom ? (
@@ -886,7 +2087,10 @@ function InternalChatPanel({
886
2087
  </EmptyHeader>
887
2088
  </Empty>
888
2089
  ) : (
889
- <div className="flex flex-col gap-6 px-5 py-2">
2090
+ <div
2091
+ ref={messagesListRef}
2092
+ className="flex flex-col gap-4 px-5 pt-2 pb-6"
2093
+ >
890
2094
  {isLoadingOlder ? (
891
2095
  <div className="flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground">
892
2096
  <IconLoader2
@@ -908,20 +2112,64 @@ function InternalChatPanel({
908
2112
  {message.initials}
909
2113
  </MessageAvatar>
910
2114
  )}
911
- <MessageContent>
2115
+ <MessageContent className="gap-0.5">
912
2116
  {mine ? null : (
913
- <MessageHeader>{message.author}</MessageHeader>
2117
+ <MessageHeader className="px-3 pb-0 leading-snug">
2118
+ {message.author}
2119
+ </MessageHeader>
914
2120
  )}
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)}
2121
+ {message.attachments?.length ? (
2122
+ <InternalChatAttachmentStack
2123
+ items={message.attachments}
2124
+ labels={labels}
2125
+ />
2126
+ ) : null}
2127
+ {message.audio ? (
2128
+ <Bubble
2129
+ align={mine ? "end" : "start"}
2130
+ variant={mine ? "muted" : "outline"}
2131
+ >
2132
+ <BubbleContent className="py-2">
2133
+ <InternalChatAudioBubble
2134
+ audio={message.audio}
2135
+ seed={message.id}
2136
+ mine={mine}
2137
+ />
2138
+ </BubbleContent>
2139
+ </Bubble>
2140
+ ) : null}
2141
+ {message.text.trim() ? (
2142
+ <Bubble
2143
+ align={mine ? "end" : "start"}
2144
+ variant={mine ? "muted" : "outline"}
2145
+ >
2146
+ <BubbleContent className="break-words whitespace-pre-wrap">
2147
+ {renderMessageTextWithMentions(message.text)}
2148
+ </BubbleContent>
2149
+ </Bubble>
2150
+ ) : null}
2151
+ <MessageFooter className="flex items-center justify-end gap-1 px-3 pt-0 leading-none">
2152
+ <span className="text-[0.65rem] tabular-nums text-muted-foreground">
2153
+ {formatMessageTime(message.at)}
2154
+ </span>
2155
+ {mine && message.receiptStatus ? (
2156
+ <span
2157
+ className="inline-flex shrink-0"
2158
+ title={
2159
+ message.receiptStatus === "pending"
2160
+ ? "Enviando"
2161
+ : message.receiptStatus === "sent"
2162
+ ? "Enviado"
2163
+ : message.receiptStatus === "delivered"
2164
+ ? "Entregado"
2165
+ : "Leído"
2166
+ }
2167
+ >
2168
+ <MessageReceiptTicks
2169
+ status={message.receiptStatus}
2170
+ />
2171
+ </span>
2172
+ ) : null}
925
2173
  </MessageFooter>
926
2174
  </MessageContent>
927
2175
  </Message>
@@ -939,6 +2187,23 @@ function InternalChatPanel({
939
2187
  ) : null}
940
2188
  </div>
941
2189
 
2190
+ {voiceRecorderOpen && activeRoom ? (
2191
+ <InternalChatVoiceRecorderBar
2192
+ labels={labels}
2193
+ onClose={() => setVoiceRecorderOpen(false)}
2194
+ onSendBlob={onSendAudioBlob}
2195
+ onSendLegacy={onSendAudio}
2196
+ />
2197
+ ) : null}
2198
+
2199
+ {activeRoom && screenRecorderOpen ? (
2200
+ <InternalChatScreenRecorderBar
2201
+ labels={labels}
2202
+ session={screenRecorderSession}
2203
+ onDismiss={() => setScreenRecorderOpen(false)}
2204
+ />
2205
+ ) : null}
2206
+
942
2207
  <form
943
2208
  ref={formRef}
944
2209
  onSubmit={onSubmit}
@@ -1204,24 +2469,105 @@ function InternalChatPanel({
1204
2469
  <DropdownMenuContent
1205
2470
  align="start"
1206
2471
  side="top"
1207
- className="w-44"
2472
+ className="w-48"
1208
2473
  >
1209
- <DropdownMenuItem>
1210
- <IconPaperclip className="size-4" />
2474
+ <DropdownMenuItem
2475
+ onClick={() => {
2476
+ onUserGesture?.();
2477
+ photoInputRef.current?.click();
2478
+ }}
2479
+ >
2480
+ <IconPhoto className="size-4" />
1211
2481
  {labels.attachPhotos}
1212
2482
  </DropdownMenuItem>
2483
+ <DropdownMenuItem
2484
+ onClick={() => {
2485
+ onUserGesture?.();
2486
+ fileInputRef.current?.click();
2487
+ }}
2488
+ >
2489
+ <IconPaperclip className="size-4" />
2490
+ {labels.attachFiles}
2491
+ </DropdownMenuItem>
1213
2492
  </DropdownMenuContent>
1214
2493
  </DropdownMenu>
1215
2494
  <InputGroupButton
1216
- type="submit"
1217
- variant="default"
2495
+ type="button"
2496
+ variant={
2497
+ screenRecorderOpen || screenRecorderSession.recording
2498
+ ? "default"
2499
+ : "outline"
2500
+ }
1218
2501
  size="icon-sm"
1219
- disabled={!canSend || !activeRoom}
1220
- className="ml-auto"
2502
+ disabled={!activeRoom}
2503
+ aria-label={labels.screenCaptureAriaLabel}
2504
+ aria-pressed={
2505
+ screenRecorderOpen || screenRecorderSession.recording
2506
+ }
2507
+ className={cn(
2508
+ !screenRecorderOpen &&
2509
+ !screenRecorderSession.recording &&
2510
+ "text-violet-600 hover:text-violet-700 dark:text-violet-400 dark:hover:text-violet-300",
2511
+ )}
2512
+ onClick={() => {
2513
+ onUserGesture?.();
2514
+ setVoiceRecorderOpen(false);
2515
+ setScreenRecorderOpen((prev) => !prev);
2516
+ }}
1221
2517
  >
1222
- <IconArrowUp className="size-4" />
1223
- <span className="sr-only">{labels.sendSrOnly}</span>
2518
+ <IconScreenShare className="size-4" />
1224
2519
  </InputGroupButton>
2520
+ <input
2521
+ ref={photoInputRef}
2522
+ type="file"
2523
+ accept="image/*"
2524
+ className="hidden"
2525
+ onChange={(e) => {
2526
+ const file = e.target.files?.[0];
2527
+ if (file && onAttachPhotos) void onAttachPhotos(file);
2528
+ e.target.value = "";
2529
+ }}
2530
+ />
2531
+ <input
2532
+ ref={fileInputRef}
2533
+ type="file"
2534
+ className="hidden"
2535
+ onChange={(e) => {
2536
+ const file = e.target.files?.[0];
2537
+ if (file && onAttachFiles) void onAttachFiles(file);
2538
+ e.target.value = "";
2539
+ }}
2540
+ />
2541
+ <div className="ml-auto flex shrink-0 items-center gap-1">
2542
+ <InputGroupButton
2543
+ type="button"
2544
+ variant={voiceRecorderOpen ? "default" : "outline"}
2545
+ size="icon-sm"
2546
+ disabled={!activeRoom}
2547
+ aria-label={labels.attachAudio}
2548
+ aria-pressed={voiceRecorderOpen}
2549
+ className={cn(
2550
+ !voiceRecorderOpen &&
2551
+ "text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300",
2552
+ )}
2553
+ onClick={() => {
2554
+ onUserGesture?.();
2555
+ setScreenRecorderOpen(false);
2556
+ setVoiceRecorderOpen((open) => !open);
2557
+ }}
2558
+ >
2559
+ <IconMicrophone className="size-4" />
2560
+ </InputGroupButton>
2561
+ <InputGroupButton
2562
+ type="submit"
2563
+ variant="default"
2564
+ size="icon-sm"
2565
+ disabled={!canSend || !activeRoom}
2566
+ >
2567
+ <IconArrowUp className="size-4" />
2568
+ <span className="sr-only">{labels.sendSrOnly}</span>
2569
+ </InputGroupButton>
2570
+ </div>
1225
2571
  </InputGroupAddon>
1226
2572
  </InputGroup>
1227
2573
  </form>