appendMessage() publishes ticket.created and message.created for the same first message of a new ticket. TicketToasts and DesktopNotifications listened to both independently, so one new customer ticket produced two toasts/two chimes/two OS notifications. message.created now carries ticketIsNew so both subscribers can skip it when ticket.created already covered the same event.
139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Bell, X } from "lucide-react";
|
|
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
|
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
|
|
|
interface Toast {
|
|
id: string;
|
|
title: string;
|
|
body: string;
|
|
href: string;
|
|
}
|
|
|
|
// A single, reused AudioContext. Two separate browser behaviors fight it:
|
|
// (1) autoplay policy — a context created/resumed outside a real user
|
|
// gesture starts "suspended" and stays silent; (2) power-saving
|
|
// auto-suspend — Chrome can suspend an already-unlocked context again
|
|
// after a stretch of no audio activity, independent of (1). Neither fails
|
|
// loudly: start()/stop() never throw, the chime just doesn't sound.
|
|
// Fix: keep the unlock listeners attached for the whole session (not just
|
|
// the first click) so any later click also re-resumes a context the
|
|
// browser suspended for power-saving, and actually await resume() before
|
|
// scheduling a chime instead of assuming it completes synchronously.
|
|
let sharedAudioContext: AudioContext | null = null;
|
|
|
|
function getAudioContext(): AudioContext | null {
|
|
if (typeof window === "undefined") return null;
|
|
const AudioCtor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!AudioCtor) return null;
|
|
if (!sharedAudioContext) sharedAudioContext = new AudioCtor();
|
|
return sharedAudioContext;
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
const unlockAudio = () => {
|
|
const ctx = getAudioContext();
|
|
if (ctx?.state === "suspended") ctx.resume();
|
|
};
|
|
window.addEventListener("pointerdown", unlockAudio);
|
|
window.addEventListener("keydown", unlockAudio);
|
|
}
|
|
|
|
/** A short two-tone chime via the Web Audio API — no audio asset needed. */
|
|
async function playChime() {
|
|
const ctx = getAudioContext();
|
|
if (!ctx) return;
|
|
try {
|
|
if (ctx.state === "suspended") await ctx.resume();
|
|
if (ctx.state !== "running") return; // still not unlocked by a user gesture yet — nothing to do
|
|
|
|
const now = ctx.currentTime;
|
|
for (const [freq, start] of [[880, 0], [1175, 0.12]] as const) {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "sine";
|
|
osc.frequency.value = freq;
|
|
gain.gain.setValueAtTime(0.0001, now + start);
|
|
gain.gain.exponentialRampToValueAtTime(0.25, now + start + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + start + 0.25);
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start(now + start);
|
|
osc.stop(now + start + 0.25);
|
|
}
|
|
} catch {
|
|
// audio isn't critical to the notification — a silent toast still works
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-page toast + sound for new tickets and customer messages, shown
|
|
* regardless of tab focus. Complements DesktopNotifications, which only
|
|
* fires the native OS notification while the tab is in the background —
|
|
* this is the "still on the page" case.
|
|
*/
|
|
export function TicketToasts({ currentUser }: { currentUser: { id: string; role: "admin" | "agent" } }) {
|
|
const router = useRouter();
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
const idCounter = useRef(0);
|
|
|
|
const pushToast = useCallback((title: string, body: string, href: string) => {
|
|
const id = `${Date.now()}-${idCounter.current++}`;
|
|
setToasts((prev) => [...prev, { id, title, body, href }]);
|
|
void playChime();
|
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 6_000);
|
|
}, []);
|
|
|
|
useTicketEvents("/api/events", (event) => {
|
|
if (event.type === "ticket.created") {
|
|
if (!isTicketVisibleTo(event.ticket, currentUser)) return;
|
|
pushToast("Новая заявка", event.ticket.subject, `/tickets/${event.ticket.id}`);
|
|
} else if (event.type === "message.created" && event.message.authorType === "customer" && !event.ticketIsNew) {
|
|
if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return;
|
|
pushToast(`Сообщение от ${event.message.authorName}`, event.message.body.slice(0, 120), `/tickets/${event.ticketId}`);
|
|
}
|
|
});
|
|
|
|
function dismiss(id: string) {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
}
|
|
|
|
return (
|
|
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
|
|
<AnimatePresence initial={false}>
|
|
{toasts.map((toast) => (
|
|
<motion.div
|
|
key={toast.id}
|
|
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.96 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="card pointer-events-auto flex items-start gap-2 p-3 shadow-md"
|
|
>
|
|
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-accent-soft-text">
|
|
<Bell size={13} />
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
dismiss(toast.id);
|
|
router.push(toast.href);
|
|
}}
|
|
className="min-w-0 flex-1 text-left"
|
|
>
|
|
<p className="truncate text-sm font-medium">{toast.title}</p>
|
|
<p className="line-clamp-2 text-xs text-text-muted">{toast.body}</p>
|
|
</button>
|
|
<button onClick={() => dismiss(toast.id)} className="text-text-faint hover:text-text" aria-label="Закрыть">
|
|
<X size={13} />
|
|
</button>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|