UX: notificaciones de chat con toast y sonido (WebAudio) + skeletons de carga en Agenda y Chat
This commit is contained in:
141
frontend/src/lib/chat-notifications.tsx
Normal file
141
frontend/src/lib/chat-notifications.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import { toast } from './toast';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import { useAuth } from './auth';
|
||||
|
||||
// Notificaciones del chat interno sin recargar:
|
||||
// polling ligero sobre getChatUsers (mismo mecanismo que useChatUnread);
|
||||
// al detectar mensajes nuevos de un remitente que NO está en pantalla,
|
||||
// muestra un toast discreto (clic → abre /chat con esa conversación) y un
|
||||
// beep sutil generado con WebAudio (sin archivos de audio).
|
||||
|
||||
// ---- Conversación activa (la registra Chat.tsx) ----
|
||||
let activeChatUserId: number | null = null;
|
||||
export const setActiveChatUser = (id: number | null) => {
|
||||
activeChatUserId = id;
|
||||
};
|
||||
|
||||
// ---- Beep sutil con WebAudio ----
|
||||
// El AudioContext solo se crea/reanuda tras la primera interacción del
|
||||
// usuario (política de autoplay de los navegadores).
|
||||
let audioCtx: AudioContext | null = null;
|
||||
let audioArmed = false;
|
||||
|
||||
const armAudio = () => {
|
||||
if (audioArmed) return;
|
||||
audioArmed = true;
|
||||
const init = () => {
|
||||
try {
|
||||
if (!audioCtx) {
|
||||
const Ctor = window.AudioContext
|
||||
|| (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
audioCtx = new Ctor();
|
||||
}
|
||||
if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {});
|
||||
} catch {
|
||||
// audio no disponible: se omite el sonido
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointerdown', init);
|
||||
window.addEventListener('keydown', init);
|
||||
};
|
||||
|
||||
const playChatBeep = () => {
|
||||
try {
|
||||
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||
const t0 = audioCtx.currentTime;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(880, t0);
|
||||
// Volumen bajo con caída rápida: beep corto y discreto
|
||||
gain.gain.setValueAtTime(0.0001, t0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.06, t0 + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
|
||||
osc.connect(gain).connect(audioCtx.destination);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + 0.2);
|
||||
} catch {
|
||||
// silencioso
|
||||
}
|
||||
};
|
||||
|
||||
const POLL_MS = 15000;
|
||||
|
||||
/** Polling global de mensajes nuevos: toast + beep por remitente. Montar una sola vez. */
|
||||
export const useChatNotifications = (intervalMs = POLL_MS) => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const prevUnread = useRef<Record<number, number>>({});
|
||||
const baselineDone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
armAudio();
|
||||
let alive = true;
|
||||
|
||||
const fetchUsers = () => {
|
||||
odooApi.getChatUsers()
|
||||
.then((res) => {
|
||||
if (!alive || res.status !== 'success') return;
|
||||
const prev = prevUnread.current;
|
||||
const next: Record<number, number> = {};
|
||||
for (const u of res.users) next[u.id] = u.unread;
|
||||
// Primera lectura: solo establece la línea base (no notificar lo viejo)
|
||||
if (!baselineDone.current) {
|
||||
baselineDone.current = true;
|
||||
prevUnread.current = next;
|
||||
return;
|
||||
}
|
||||
for (const u of res.users) {
|
||||
const before = prev[u.id] ?? 0;
|
||||
if (u.unread <= before) continue;
|
||||
// No notificar si esa conversación está abierta en pantalla
|
||||
if (activeChatUserId === u.id) continue;
|
||||
// Un solo toast por remitente por ráfaga (el polling ya agrupa;
|
||||
// se notifica solo cuando el conteo sube)
|
||||
const preview = (u.last_message || '').slice(0, 90);
|
||||
playChatBeep();
|
||||
toast.custom((t) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toast.dismiss(t);
|
||||
navigate(`/chat?with=${u.id}`);
|
||||
}}
|
||||
className="flex items-center gap-3 w-full max-w-sm rounded-xl border border-theme-border bg-theme-surface px-4 py-3 text-left shadow-soft"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-theme-heading">
|
||||
<MessageCircle size={16} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-theme-heading">{u.name}</span>
|
||||
<span className="block truncate text-xs text-theme-muted">
|
||||
{preview || 'Nuevo mensaje'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
), { duration: 6000 });
|
||||
}
|
||||
prevUnread.current = next;
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
const id = setInterval(fetchUsers, intervalMs);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
}, [user, intervalMs, navigate]);
|
||||
};
|
||||
|
||||
/** Componente mudo para montar el polling global dentro del Router. */
|
||||
export const ChatNotifications: FC = () => {
|
||||
useChatNotifications();
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ChatNotifications;
|
||||
Reference in New Issue
Block a user