import type { ChatMessage } from '../types'; export type TitleMode = 'rotate' | 'prefix'; export interface TitleRotatorOptions { /** Snapshot taken on first `start()` if omitted. */ base?: string; /** * Build the alert string for the current unread state. * Only used in `rotate` mode. Default: `"(N) Base"` — same shape as * the prefix mode, so the two modes differ only in *whether* the * title rotates, not in what it says. */ template?: (count: number, latest?: ChatMessage | null, base?: string) => string; /** Only used in `rotate` mode. Default 2000ms — gentle, not nervous. */ intervalMs?: number; /** * - `prefix` (default): render `"(N) Base"` once, no interval. The * restrained Facebook-style cue: rely on the favicon dot for * attention, keep the title readable. * - `rotate`: swap between base and `template(...)` every * `intervalMs`. Use when the host has no favicon (or the user * pinned the tab and favicons are hidden). */ mode?: TitleMode; } interface RotatorHandle { start(count: number, latest?: ChatMessage | null): void; update(count: number, latest?: ChatMessage | null): void; stop(): void; } const DEFAULT_TEMPLATE = (count: number, _latest?: ChatMessage | null, base?: string) => base ? `(${count}) ${base}` : `(${count})`; /** * Mutates `document.title`. SSR-safe (returns no-op handle). * * Invariants: * - `stop()` always restores the base title. * - Multiple `start()` calls without an intervening `stop()` are * equivalent to `update()`; we never stack timers. * - In `rotate` mode the timer only ticks when the page is hidden * (the caller is expected to gate this; see `useChatUnreadNotifier`). */ export function createTitleRotator(opts: TitleRotatorOptions = {}): RotatorHandle { if (typeof document === 'undefined') { return { start() {}, update() {}, stop() {} }; } const template = opts.template ?? DEFAULT_TEMPLATE; const intervalMs = opts.intervalMs ?? 2000; const mode: TitleMode = opts.mode ?? 'prefix'; let baseTitle: string | null = null; let alertTitle = ''; let phase: 'base' | 'alert' = 'base'; let timer: ReturnType | null = null; const captureBaseOnce = () => { if (baseTitle !== null) return; baseTitle = opts.base ?? document.title; }; const render = () => { if (baseTitle === null) return; document.title = phase === 'alert' ? alertTitle : baseTitle; }; const tick = () => { phase = phase === 'alert' ? 'base' : 'alert'; render(); }; const prefixTitle = (count: number) => baseTitle ? `(${count}) ${baseTitle}` : `(${count})`; return { start(count, latest) { captureBaseOnce(); alertTitle = template(count, latest, baseTitle ?? undefined); if (mode === 'prefix') { document.title = prefixTitle(count); return; } phase = 'alert'; render(); if (timer === null) { timer = setInterval(tick, intervalMs); } }, update(count, latest) { // Same path as start — base title is already captured, just refresh // the alert string and re-render in case we're in alert phase. captureBaseOnce(); alertTitle = template(count, latest, baseTitle ?? undefined); if (mode === 'prefix') { document.title = prefixTitle(count); } else if (phase === 'alert') { render(); } }, stop() { if (timer !== null) { clearInterval(timer); timer = null; } if (baseTitle !== null) { document.title = baseTitle; } phase = 'base'; }, }; }