// ========================================== // 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 = `
Notifications
`; 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(); }