/** * Notification bell runtime component. * * Works with the native
dropdown. Handles polling, * notification list rendering, mark-read and dismiss actions. */ import { telemetry } from '../../../../js/core/app-telemetry.js'; import { getAppBase } from '../../../../js/pages/app-list-utils.js'; const POLL_INTERVAL_MS = 60_000; const TYPE_ICONS = { 'user.created': 'bi-person-plus', 'user.deleted': 'bi-person-dash', 'user.activated': 'bi-person-check', 'user.deactivated': 'bi-person-x', 'user.assignment_changed': 'bi-diagram-3', 'system': 'bi-gear', }; function getCsrf() { const root = document.documentElement; return { key: root.dataset.csrfKey || '', token: root.dataset.csrfToken || '', }; } function timeAgo(dateStr) { const now = Date.now(); const then = new Date(dateStr).getTime(); const diffSec = Math.floor((now - then) / 1000); if (diffSec < 60) return '< 1m'; if (diffSec < 3600) return Math.floor(diffSec / 60) + 'm'; if (diffSec < 86400) return Math.floor(diffSec / 3600) + 'h'; return Math.floor(diffSec / 86400) + 'd'; } function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } async function fetchJson(url, options = {}) { const resp = await fetch(url, options); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return resp.json(); } export function initNotificationBell(root = document, config = {}) { const details = root.querySelector('[data-notification-bell]'); const dropdown = root.querySelector('[data-notification-dropdown]'); if (!details || !dropdown) return { destroy() {} }; const appBase = getAppBase(); const endpoint = (path) => new URL(path, appBase).toString(); const badge = details.querySelector('[data-notification-badge]'); const list = dropdown.querySelector('[data-notification-list]'); const emptyEl = list?.querySelector('.app-empty-state'); const loadingEl = list?.querySelector('[data-notification-loading]'); const errorEl = list?.querySelector('[data-notification-error]'); const markAllBtn = dropdown.querySelector('[data-notification-mark-all]'); const ac = new AbortController(); const signal = ac.signal; let pollTimer = null; // --- Badge --- function updateBadge(count) { if (!badge) return; if (count > 0) { badge.textContent = count > 99 ? '99+' : String(count); badge.style.display = ''; } else { badge.textContent = ''; badge.style.display = 'none'; } } // --- State management --- function showState(state) { if (loadingEl) loadingEl.style.display = state === 'loading' ? '' : 'none'; if (errorEl) errorEl.style.display = state === 'error' ? '' : 'none'; if (emptyEl) emptyEl.style.display = state === 'empty' ? '' : 'none'; } // --- Render --- function renderNotifications(notifications) { if (!list) return; // Remove rendered items, keep state elements list.querySelectorAll('.app-notification-item').forEach(el => el.remove()); if (notifications.length === 0) { showState('empty'); return; } showState('success'); const frag = document.createDocumentFragment(); for (const n of notifications) { frag.appendChild(createNotificationItem(n)); } list.insertBefore(frag, emptyEl); } function createNotificationItem(n) { const item = document.createElement('div'); item.className = 'app-notification-item' + (n.is_read === 0 ? ' app-notification-item--unread' : ''); item.dataset.notificationId = n.id; const iconClass = TYPE_ICONS[n.type] || 'bi-bell'; const bodyHtml = n.body ? `

${escapeHtml(n.body)}

` : ''; item.innerHTML = `` + `
` + `

${escapeHtml(n.title)}

` + bodyHtml + `${escapeHtml(timeAgo(n.created))}` + `
` + `
` + (n.is_read === 0 ? `` : '') + `` + `
`; // Keyboard + click navigation for items with a link if (n.link) { item.classList.add('app-notification-item--link'); item.setAttribute('role', 'link'); item.setAttribute('tabindex', '0'); const navigate = () => { if (n.is_read === 0) markRead(n.id); window.location.href = endpoint(String(n.link)); }; item.addEventListener('click', (e) => { if (e.target.closest('[data-action]')) return; navigate(); }, { signal }); item.addEventListener('keydown', (e) => { if (e.target.closest('[data-action]')) return; if (e.key === 'Enter') { e.preventDefault(); navigate(); } }, { signal }); } else { item.addEventListener('click', (e) => { if (e.target.closest('[data-action]')) return; }, { signal }); } // Action buttons item.addEventListener('click', (e) => { const actionBtn = e.target.closest('[data-action]'); if (!actionBtn) return; e.stopPropagation(); const action = actionBtn.dataset.action; if (action === 'mark-read') markRead(n.id); if (action === 'dismiss') dismiss(n.id); }, { signal }); return item; } // --- API --- async function loadNotifications() { showState('loading'); list?.querySelectorAll('.app-notification-item').forEach(el => el.remove()); try { const data = await fetchJson(endpoint('notifications/list-data'), { signal }); if (data.ok) { renderNotifications(data.notifications || []); updateBadge(data.unread_count ?? 0); } else { showState('error'); } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; showState('error'); telemetry.capture('warn_once', { message: 'Notification bell: load failed', meta: { module: 'notifications', component: 'notification-bell', action: 'load' }, }); } } async function pollUnreadCount() { try { const data = await fetchJson(endpoint('notifications/unread-count-data'), { signal }); if (data.ok) { updateBadge(data.unread_count ?? 0); } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; telemetry.capture('warn_once', { message: 'Notification bell: poll failed', meta: { module: 'notifications', component: 'notification-bell', action: 'poll' }, }); } } async function markRead(id) { try { const csrf = getCsrf(); const body = new URLSearchParams(); body.set('id', String(id)); if (csrf.key) body.set(csrf.key, csrf.token); const data = await fetchJson(endpoint('notifications/mark-read-data'), { method: 'POST', body, signal, }); if (data.ok) { updateBadge(data.unread_count ?? 0); if (details.open) loadNotifications(); } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; telemetry.capture('warn_once', { message: 'Notification bell: mark-read failed', meta: { module: 'notifications', component: 'notification-bell', action: 'mark-read' }, }); } } async function markAllRead() { try { const csrf = getCsrf(); const body = new URLSearchParams(); body.set('all', '1'); if (csrf.key) body.set(csrf.key, csrf.token); const data = await fetchJson(endpoint('notifications/mark-read-data'), { method: 'POST', body, signal, }); if (data.ok) { updateBadge(data.unread_count ?? 0); if (details.open) loadNotifications(); } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; telemetry.capture('warn_once', { message: 'Notification bell: mark-all-read failed', meta: { module: 'notifications', component: 'notification-bell', action: 'mark-all-read' }, }); } } async function dismiss(id) { try { const csrf = getCsrf(); const body = new URLSearchParams(); body.set('id', String(id)); if (csrf.key) body.set(csrf.key, csrf.token); const data = await fetchJson(endpoint('notifications/delete-data'), { method: 'POST', body, signal, }); if (data.ok) { updateBadge(data.unread_count ?? 0); const el = list?.querySelector(`[data-notification-id="${id}"]`); if (el) { el.remove(); if (list && !list.querySelector('.app-notification-item') && emptyEl) { emptyEl.style.display = ''; } } } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; telemetry.capture('warn_once', { message: 'Notification bell: dismiss failed', meta: { module: 'notifications', component: 'notification-bell', action: 'dismiss' }, }); } } // --- Events --- details.addEventListener('toggle', () => { if (details.open) loadNotifications(); }, { signal }); if (markAllBtn) { markAllBtn.addEventListener('click', (e) => { e.stopPropagation(); markAllRead(); }, { signal }); } // --- Polling --- function startPolling() { stopPolling(); pollTimer = setInterval(() => { if (document.visibilityState !== 'hidden') { pollUnreadCount(); } }, POLL_INTERVAL_MS); } function stopPolling() { if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; } } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { pollUnreadCount(); startPolling(); } else { stopPolling(); } }, { signal }); startPolling(); return { destroy() { ac.abort(); stopPolling(); }, }; }