// ==========================================
// Chat Switchboard – Notifications (v0.20.0)
// ==========================================
// In-app notification bell with dropdown and
// side panel. Real-time push via WebSocket.
//
// Dependencies: API, Events, PanelRegistry, UI
// ==========================================
const Notifications = {
_items: [], // cached notifications (latest first)
_unreadCount: 0,
_loaded: false, // full list fetched at least once
_dropdownOpen: false,
// ── Init ─────────────────────────────────
async init() {
this._bindEvents();
this._registerPanel();
await this._fetchUnreadCount();
},
// ── Data ─────────────────────────────────
async _fetchUnreadCount() {
try {
const data = await API._get('/notifications/unread-count');
this._unreadCount = data.count || 0;
this._renderBadge();
} catch (e) {
// Silently fail — badge just stays at 0
}
},
async _fetchList(opts = {}) {
const limit = opts.limit || 20;
const offset = opts.offset || 0;
const unreadOnly = opts.unreadOnly || false;
try {
const data = await API._get(
`/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`);
return data;
} catch (e) {
return { data: [], total: 0 };
}
},
async _loadLatest() {
const result = await this._fetchList({ limit: 10 });
this._items = result.data || [];
this._loaded = true;
return result;
},
// ── Badge ────────────────────────────────
_renderBadge() {
const badge = document.getElementById('notifBadge');
if (!badge) return;
if (this._unreadCount > 0) {
badge.textContent = this._unreadCount > 9 ? '9+' : String(this._unreadCount);
badge.style.display = '';
} else {
badge.style.display = 'none';
}
},
// ── Dropdown ─────────────────────────────
async toggleDropdown() {
if (this._dropdownOpen) {
this._closeDropdown();
return;
}
// Lazy-load on first open
if (!this._loaded) {
await this._loadLatest();
}
const dd = document.getElementById('notifDropdown');
if (!dd) return;
dd.innerHTML = this._renderDropdownContent();
dd.style.display = 'block';
this._dropdownOpen = true;
// Click-outside handler
setTimeout(() => {
document.addEventListener('click', this._outsideClickHandler);
}, 0);
},
_closeDropdown() {
const dd = document.getElementById('notifDropdown');
if (dd) dd.style.display = 'none';
this._dropdownOpen = false;
document.removeEventListener('click', this._outsideClickHandler);
},
_outsideClickHandler(e) {
const wrap = document.getElementById('notifWrap');
if (wrap && !wrap.contains(e.target)) {
Notifications._closeDropdown();
}
},
_renderDropdownContent() {
const items = this._items;
if (!items.length) {
return `
No notifications
`;
}
let html = ``;
for (const n of items.slice(0, 10)) {
const unread = !n.is_read;
const dot = unread ? '●' : '○';
const cls = unread ? 'notif-item notif-unread' : 'notif-item';
const ago = _timeAgo(n.created_at);
html += `
${dot}
${_esc(n.title)}
${n.body ? `
${_esc(n.body)}
` : ''}
${ago}
`;
}
html += `
`;
html += ``;
return html;
},
// ── Item Actions ─────────────────────────
async _onItemClick(el) {
const id = el.dataset.id;
const resType = el.dataset.resourceType;
const resId = el.dataset.resourceId;
// Mark read
if (el.classList.contains('notif-unread')) {
await this._markRead(id);
el.classList.remove('notif-unread');
el.querySelector('.notif-dot').textContent = '○';
}
// Navigate
this._navigate(resType, resId);
this._closeDropdown();
},
_navigate(resourceType, resourceId) {
if (!resourceType || !resourceId) return;
switch (resourceType) {
case 'channel':
if (typeof selectChat === 'function') selectChat(resourceId);
break;
case 'knowledge_base':
// Open admin panel → KB section (if admin)
if (API.isAdmin && typeof showAdmin === 'function') {
showAdmin('ai', 'knowledge-bases');
}
break;
case 'project':
// Open project detail panel
if (typeof openProjectPanel === 'function') openProjectPanel(resourceId);
break;
case 'group':
if (API.isAdmin && typeof showAdmin === 'function') {
showAdmin('people', 'groups');
}
break;
}
},
async _markRead(id) {
try {
await API._authed(`/notifications/${id}/read`, 'PATCH');
this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === id);
if (item) item.is_read = true;
this._renderBadge();
} catch (e) { /* best effort */ }
},
async markAllRead() {
try {
await API._post('/notifications/mark-all-read');
this._unreadCount = 0;
this._items.forEach(n => n.is_read = true);
this._renderBadge();
// Re-render dropdown if open
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
// Re-render panel if open
this._renderPanelContent();
} catch (e) { /* best effort */ }
},
// ── Side Panel ───────────────────────────
_registerPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined') return;
const el = document.createElement('div');
el.className = 'side-panel-page';
el.id = 'sidePanelNotifications';
el.style.display = 'none';
el.innerHTML = `
`;
body.appendChild(el);
PanelRegistry.register('notifications', {
element: el,
label: 'Notifications',
onOpen: () => this._onPanelOpen(),
saveState: () => ({ scrollTop: el.querySelector('.notif-panel-list')?.scrollTop || 0 }),
restoreState: (s) => {
const list = el.querySelector('.notif-panel-list');
if (list && s.scrollTop) list.scrollTop = s.scrollTop;
},
});
},
_panelOffset: 0,
_panelTotal: 0,
async _onPanelOpen() {
this._panelOffset = 0;
const result = await this._fetchList({ limit: 30 });
this._panelTotal = result.total || 0;
this._panelOffset = (result.data || []).length;
this._panelItems = result.data || [];
this._renderPanelContent();
},
_panelItems: [],
_renderPanelContent() {
const list = document.getElementById('notifPanelList');
if (!list) return;
if (!this._panelItems.length) {
list.innerHTML = 'No notifications yet
';
return;
}
let html = '';
for (const n of this._panelItems) {
const unread = !n.is_read;
const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item';
const dot = unread ? '●' : '○';
const ago = _timeAgo(n.created_at);
html += `
${dot}
${_esc(n.title)}
${n.body ? `
${_esc(n.body)}
` : ''}
${ago}
`;
}
list.innerHTML = html;
// Show/hide load more
const moreBtn = document.getElementById('notifPanelMore');
if (moreBtn) {
moreBtn.style.display = this._panelOffset < this._panelTotal ? '' : 'none';
}
},
async _onPanelItemClick(el) {
const id = el.dataset.id;
const resType = el.dataset.resourceType;
const resId = el.dataset.resourceId;
if (el.classList.contains('notif-unread')) {
await this._markRead(id);
el.classList.remove('notif-unread');
el.querySelector('.notif-dot').textContent = '○';
const pItem = this._panelItems.find(n => n.id === id);
if (pItem) pItem.is_read = true;
}
this._navigate(resType, resId);
},
async _deleteItem(id) {
try {
await API._del(`/notifications/${id}`);
// Check if it was unread before removal
const item = this._panelItems.find(n => n.id === id);
if (item && !item.is_read) {
this._unreadCount = Math.max(0, this._unreadCount - 1);
this._renderBadge();
}
this._panelItems = this._panelItems.filter(n => n.id !== id);
this._items = this._items.filter(n => n.id !== id);
this._panelTotal = Math.max(0, this._panelTotal - 1);
this._renderPanelContent();
} catch (e) { /* best effort */ }
},
async loadMore() {
const result = await this._fetchList({ limit: 30, offset: this._panelOffset });
const newItems = result.data || [];
this._panelItems = this._panelItems.concat(newItems);
this._panelOffset += newItems.length;
this._renderPanelContent();
},
async clearAll() {
if (typeof showConfirm === 'function') {
const ok = await showConfirm('Delete all notifications?', 'This cannot be undone.');
if (!ok) return;
}
// Delete visible items (API doesn't have bulk delete, so mark all read and let retention handle it)
await this.markAllRead();
},
openPanel() {
this._closeDropdown();
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('notifications');
}
},
// ── WebSocket Events ─────────────────────
_bindEvents() {
if (typeof Events === 'undefined') return;
// New notification from server
Events.on('notification.new', (payload) => {
// Prepend to cached list
this._items.unshift(payload);
if (this._items.length > 50) this._items.length = 50;
this._unreadCount++;
this._renderBadge();
// Update dropdown if open
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
// Update panel if it has items
if (this._panelItems.length) {
this._panelItems.unshift(payload);
this._panelTotal++;
this._panelOffset++;
this._renderPanelContent();
}
// Toast for high-priority types
const highPriority = ['kb.error', 'role.fallback'];
if (highPriority.includes(payload.type)) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast(payload.title, 'warning');
}
}
});
// Badge sync across tabs (from markRead / markAllRead in another tab)
Events.on('notification.read', (payload) => {
if (payload.action === 'mark_all_read') {
this._unreadCount = 0;
this._items.forEach(n => n.is_read = true);
this._panelItems.forEach(n => n.is_read = true);
} else if (payload.id) {
this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === payload.id);
if (item) item.is_read = true;
const pItem = this._panelItems.find(n => n.id === payload.id);
if (pItem) pItem.is_read = true;
}
this._renderBadge();
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
this._renderPanelContent();
});
},
};
// ── Helpers ──────────────────────────────────
function _esc(s) {
if (!s) return '';
const el = document.createElement('span');
el.textContent = s;
return el.innerHTML;
}
function _timeAgo(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const now = Date.now();
const sec = Math.floor((now - d.getTime()) / 1000);
if (sec < 60) return 'just now';
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
return d.toLocaleDateString();
}