142 lines
5.0 KiB
TypeScript
142 lines
5.0 KiB
TypeScript
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;
|