'use client'; import { useCallback, useEffect, useRef, useState, useSyncExternalStore, } from 'react'; import { Bell, BellOff } from 'lucide-react'; import { FACTIONS, type FactionId } from '@/game/catalog'; import { Button } from './ui/button'; export type AutomaticActionEvent = { id: string; seq: number; faction: FactionId; name: string; }; const PREFERENCE_KEY = 'dune.automatic-action-notices'; const PREFERENCE_EVENT = 'dune-automatic-action-notices-preference'; const MAX_QUEUED_NOTICES = 250; let memoryPreference: boolean | undefined; function readPreference() { if (memoryPreference !== undefined) return memoryPreference; try { return localStorage.getItem(PREFERENCE_KEY) !== 'false'; } catch { return true; } } function subscribePreference(listener: () => void) { window.addEventListener(PREFERENCE_EVENT, listener); return () => window.removeEventListener(PREFERENCE_EVENT, listener); } const serverPreference = () => true; /** Cosmetic feedback only. Supply public events with increasing, room-local sequences. */ export function AutomaticActionNotice({ events, }: { events: readonly AutomaticActionEvent[]; }) { const enabled = useSyncExternalStore( subscribePreference, readPreference, serverPreference, ); // The first snapshot establishes a baseline. Refreshing never replays history. const seenSeq = useRef( events.reduce((last, event) => Math.max(last, event.seq), -1), ); const queue = useRef([]); const active = useRef(null); const [notice, setNotice] = useState< (AutomaticActionEvent & { fading: boolean }) | null >(null); const [completed, setCompleted] = useState(0); const timers = useRef[]>([]); const clearTimers = useCallback(() => { for (const timer of timers.current) clearTimeout(timer); timers.current = []; }, []); const showNext = useCallback(() => { if (active.current) return; if (!readPreference()) { queue.current = []; return; } const next = queue.current.shift(); if (!next) return; active.current = next; clearTimers(); timers.current = [ setTimeout(() => { setNotice({ ...next, fading: false }); }, 0), setTimeout(() => { setNotice((current) => current?.id === next.id ? { ...current, fading: true } : current, ); }, 1250), setTimeout(() => { active.current = null; setNotice(null); setCompleted((count) => count + 1); }, 1500), ]; }, [clearTimers]); useEffect(() => { const fresh = events .filter((event) => event.seq > seenSeq.current) .toSorted((a, b) => a.seq - b.seq); const additions: AutomaticActionEvent[] = []; for (const event of fresh) { if (event.seq <= seenSeq.current) continue; seenSeq.current = event.seq; additions.push(event); } // Disabled notices are consumed, so enabling cannot replay missed events. if (!readPreference()) return; const capacity = MAX_QUEUED_NOTICES - (active.current ? 1 : 0); // Preserve the active notice and retain the newest pending events on overflow. queue.current = [...queue.current, ...additions].slice(-capacity); showNext(); }, [events, showNext, completed]); useEffect( () => () => { clearTimers(); queue.current = []; active.current = null; }, [clearTimers], ); const toggle = () => { const next = !enabled; memoryPreference = next; clearTimers(); queue.current = []; active.current = null; setNotice(null); try { localStorage.setItem(PREFERENCE_KEY, String(next)); memoryPreference = undefined; } catch { // Do not turn a cosmetic preference into an error or blocking dialog. } window.dispatchEvent(new Event(PREFERENCE_EVENT)); }; const house = notice ? FACTIONS.find((candidate) => candidate.id === notice.faction) : undefined; return ( <> {enabled && notice && ( {house?.name ?? 'Automatic action'} {notice.name} )} ); }