Files
breadcrumb-the-shire/modules/notifications/web/js/components/app-notification-bell.js

322 lines
11 KiB
JavaScript

/**
* Notification bell runtime component.
*
* Works with the native <details> 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 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';
}
}
// --- Render ---
function renderNotifications(notifications) {
if (!list) return;
// Remove rendered items, keep empty state partial
list.querySelectorAll('.app-notification-item').forEach(el => el.remove());
if (notifications.length === 0) {
if (emptyEl) emptyEl.style.display = '';
return;
}
if (emptyEl) emptyEl.style.display = 'none';
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 ? `<p class="app-notification-item__body">${escapeHtml(n.body)}</p>` : '';
item.innerHTML =
`<span class="app-notification-item__icon"><i class="bi ${escapeHtml(iconClass)}"></i></span>` +
`<div class="app-notification-item__content">` +
`<p class="app-notification-item__title">${escapeHtml(n.title)}</p>` +
bodyHtml +
`<span class="app-notification-item__time">${escapeHtml(timeAgo(n.created))}</span>` +
`</div>` +
`<div class="app-notification-item__actions">` +
(n.is_read === 0 ? `<button type="button" class="app-notification-item__action-btn" data-action="mark-read" title="${escapeHtml(dropdown.dataset.labelMarkRead || '')}"><i class="bi bi-check2"></i></button>` : '') +
`<button type="button" class="app-notification-item__action-btn" data-action="dismiss" title="${escapeHtml(dropdown.dataset.labelDismiss || '')}"><i class="bi bi-x"></i></button>` +
`</div>`;
// 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() {
try {
const data = await fetchJson(endpoint('notifications/list-data'), { signal });
if (data.ok) {
renderNotifications(data.notifications || []);
updateBadge(data.unread_count ?? 0);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
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();
},
};
}