import type { BreakingAlert } from '@/services/breaking-news-alerts'; import { getAlertSettings } from '@/services/breaking-news-alerts'; import { getSourcePanelId } from '@/config/feeds'; import { t } from '@/services/i18n'; import { isMobileDevice } from '@/utils'; const MAX_ALERTS = 3; const CRITICAL_DISMISS_MS = 60_000; const HIGH_DISMISS_MS = 30_000; const SOUND_COOLDOWN_MS = 5 * 60 * 1000; interface ActiveAlert { alert: BreakingAlert; element: HTMLElement; timer: ReturnType | null; remainingMs: number; timerStartedAt: number; } export class BreakingNewsBanner { private container: HTMLElement; private activeAlerts: ActiveAlert[] = []; private audio: HTMLAudioElement | null = null; private lastSoundMs = 0; private mutationObserver: MutationObserver | null = null; private resizeObserver: ResizeObserver | null = null; private observedPostureBanner: Element | null = null; private boundOnAlert: (e: Event) => void; private boundOnVisibility: () => void; private boundOnResize: () => void; private dismissed = new Map(); private highlightTimers = new WeakMap>(); private readonly inFlow: boolean; constructor() { this.container = document.createElement('div'); this.container.className = 'breaking-news-container'; // Desktop: fixed body-level overlay. Mobile: join the app flex column // below the header (same slot as the critical posture banner, which // stays above when both are present) so alerts push content down // instead of covering the sticky chip nav / map header band. const anchor = isMobileDevice() ? document.querySelector('.critical-posture-banner') ?? document.querySelector('.header') : null; this.inFlow = !!anchor; if (anchor) anchor.insertAdjacentElement('afterend', this.container); else document.body.appendChild(this.container); this.initAudio(); this.updatePosition(); this.setupObservers(); this.boundOnAlert = (e: Event) => this.handleAlert((e as CustomEvent).detail); this.boundOnVisibility = () => this.handleVisibility(); this.boundOnResize = () => this.updatePosition(); document.addEventListener('wm:breaking-news', this.boundOnAlert); document.addEventListener('visibilitychange', this.boundOnVisibility); window.addEventListener('resize', this.boundOnResize); this.container.addEventListener('click', (e) => { const target = e.target as HTMLElement; const alertEl = target.closest('.breaking-alert') as HTMLElement | null; if (!alertEl) return; if (target.closest('.breaking-alert-dismiss')) { const id = alertEl.getAttribute('data-alert-id'); if (id) this.dismissAlert(id); return; } const panelId = alertEl.getAttribute('data-target-panel'); if (panelId) this.scrollToPanel(panelId); }); } private initAudio(): void { this.audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleQYjfKapmWswEjCJvuPQfSoXZZ+3qqBJESSP0unGaxMJVYiytrFeLhR6p8znrFUXRW+bs7V3Qx1hn8Xjp1cYPnegprhkMCFmoLi1k0sZTYGlqqlUIA=='); this.audio.volume = 0.3; } private playSound(): void { const settings = getAlertSettings(); if (!settings.soundEnabled || !this.audio) return; if (Date.now() - this.lastSoundMs < SOUND_COOLDOWN_MS) return; this.audio.currentTime = 0; this.audio.play()?.catch(() => {}); this.lastSoundMs = Date.now(); } private setupObservers(): void { this.mutationObserver = new MutationObserver(() => this.updatePosition()); this.mutationObserver.observe(document.body, { attributes: true, attributeFilter: ['class'], }); } private attachResizeObserverIfNeeded(): void { const postureBanner = document.querySelector('.critical-posture-banner'); if (!postureBanner) return; if (postureBanner === this.observedPostureBanner) return; if (this.resizeObserver) this.resizeObserver.disconnect(); this.resizeObserver = new ResizeObserver(() => this.updatePosition()); this.resizeObserver.observe(postureBanner); this.observedPostureBanner = postureBanner; } private updatePosition(): void { // In-flow (mobile) container: document flow handles stacking under the // header/posture banner; inline `top` is meaningless on static position. if (this.inFlow) { this.updateOffset(); return; } let top = 50; if (document.body?.classList.contains('has-critical-banner')) { this.attachResizeObserverIfNeeded(); const postureBanner = document.querySelector('.critical-posture-banner'); if (postureBanner) { top += postureBanner.getBoundingClientRect().height; } } this.container.style.top = `${top}px`; this.updateOffset(); } private updateOffset(): void { const height = this.container.offsetHeight; document.documentElement.style.setProperty( '--breaking-alert-offset', height > 0 ? `${height}px` : '0px' ); document.body?.classList.toggle('has-breaking-alert', this.activeAlerts.length > 0); } private isDismissedRecently(id: string): boolean { const ts = this.dismissed.get(id); if (ts === undefined) return false; if (Date.now() - ts >= 30 * 60 * 1000) { this.dismissed.delete(id); return false; } return true; } private handleAlert(alert: BreakingAlert): void { if (this.isDismissedRecently(alert.id)) return; const existing = this.activeAlerts.find(a => a.alert.id === alert.id); if (existing) return; if (alert.threatLevel === 'critical') { const highAlerts = this.activeAlerts.filter(a => a.alert.threatLevel === 'high'); for (const h of highAlerts) { this.removeAlert(h); const idx = this.activeAlerts.indexOf(h); if (idx !== -1) this.activeAlerts.splice(idx, 1); } } while (this.activeAlerts.length >= MAX_ALERTS) { const oldest = this.activeAlerts.shift(); if (oldest) this.removeAlert(oldest); } const el = this.createAlertElement(alert); this.container.appendChild(el); const dismissMs = alert.threatLevel === 'critical' ? CRITICAL_DISMISS_MS : HIGH_DISMISS_MS; const now = Date.now(); const active: ActiveAlert = { alert, element: el, timer: null, remainingMs: dismissMs, timerStartedAt: now, }; if (!document.hidden) { active.timer = setTimeout(() => this.dismissAlert(alert.id), dismissMs); } this.activeAlerts.push(active); this.playSound(); this.updateOffset(); } private resolveTargetPanel(alert: BreakingAlert): string { if (alert.origin === 'oref_siren') return 'oref-sirens'; if (alert.origin === 'rss_alert') return getSourcePanelId(alert.source); return 'politics'; } private scrollToPanel(panelId: string): void { // Synchronous: lets the mobile category nav clear a filter that would // leave the target display:none (scrollIntoView would silently no-op). window.dispatchEvent(new CustomEvent('wm:reveal-panel', { detail: { panelId } })); const panel = document.querySelector(`[data-panel="${panelId}"]`); if (!panel) return; panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); const prev = this.highlightTimers.get(panel); if (prev) clearTimeout(prev); panel.classList.remove('search-highlight'); void (panel as HTMLElement).offsetWidth; panel.classList.add('search-highlight'); this.highlightTimers.set(panel, setTimeout(() => { panel.classList.remove('search-highlight'); this.highlightTimers.delete(panel); }, 3100)); } private createAlertElement(alert: BreakingAlert): HTMLElement { const el = document.createElement('div'); el.className = `breaking-alert severity-${alert.threatLevel}`; el.setAttribute('data-alert-id', alert.id); el.setAttribute('data-target-panel', this.resolveTargetPanel(alert)); el.style.cursor = 'pointer'; const icon = alert.threatLevel === 'critical' ? '🚨' : '⚠️'; const levelText = alert.threatLevel === 'critical' ? t('components.breakingNews.critical') : t('components.breakingNews.high'); const timeAgo = this.formatTimeAgo(alert.timestamp); const iconSpan = document.createElement('span'); iconSpan.className = 'breaking-alert-icon'; iconSpan.textContent = icon; const content = document.createElement('div'); content.className = 'breaking-alert-content'; const levelSpan = document.createElement('span'); levelSpan.className = 'breaking-alert-level'; levelSpan.textContent = levelText; const headlineSpan = document.createElement('span'); headlineSpan.className = 'breaking-alert-headline'; headlineSpan.textContent = alert.headline; const metaSpan = document.createElement('span'); metaSpan.className = 'breaking-alert-meta'; metaSpan.textContent = `${alert.source} · ${timeAgo}`; content.appendChild(levelSpan); content.appendChild(headlineSpan); content.appendChild(metaSpan); const dismissBtn = document.createElement('button'); dismissBtn.className = 'breaking-alert-dismiss'; dismissBtn.textContent = '×'; dismissBtn.title = t('components.breakingNews.dismiss'); el.appendChild(iconSpan); el.appendChild(content); el.appendChild(dismissBtn); return el; } private formatTimeAgo(date: Date): string { const ms = Date.now() - date.getTime(); if (ms < 60_000) return t('components.intelligenceFindings.time.justNow'); if (ms < 3_600_000) return t('components.intelligenceFindings.time.minutesAgo', { count: String(Math.floor(ms / 60_000)) }); return t('components.intelligenceFindings.time.hoursAgo', { count: String(Math.floor(ms / 3_600_000)) }); } private dismissAlert(id: string): void { this.dismissed.set(id, Date.now()); const idx = this.activeAlerts.findIndex(a => a.alert.id === id); if (idx === -1) return; const active = this.activeAlerts[idx]!; this.removeAlert(active); this.activeAlerts.splice(idx, 1); this.updateOffset(); } private removeAlert(active: ActiveAlert): void { if (active.timer) clearTimeout(active.timer); active.element.remove(); } private handleVisibility(): void { const now = Date.now(); if (document.hidden) { for (const active of this.activeAlerts) { if (active.timer) { clearTimeout(active.timer); active.timer = null; const elapsed = now - active.timerStartedAt; active.remainingMs = Math.max(0, active.remainingMs - elapsed); } } } else { const expired: string[] = []; for (const active of this.activeAlerts) { if (!active.timer && active.remainingMs > 0) { active.timerStartedAt = now; active.timer = setTimeout(() => this.dismissAlert(active.alert.id), active.remainingMs); } else if (active.remainingMs <= 0) { expired.push(active.alert.id); } } for (const id of expired) this.dismissAlert(id); } } public destroy(): void { document.removeEventListener('wm:breaking-news', this.boundOnAlert); document.removeEventListener('visibilitychange', this.boundOnVisibility); window.removeEventListener('resize', this.boundOnResize); this.mutationObserver?.disconnect(); this.resizeObserver?.disconnect(); for (const active of this.activeAlerts) { if (active.timer) clearTimeout(active.timer); } this.activeAlerts = []; this.container.remove(); document.body.classList.remove('has-breaking-alert'); document.documentElement.style.removeProperty('--breaking-alert-offset'); } }