/** * — Notification bell with dropdown panel. * * Shows unread count badge over a bell icon. Click opens a dropdown * with the notification list. Listens for real-time WS events and * polls /api/notifications/count as a fallback. */ import { getSession } from "./rstack-identity"; const POLL_INTERVAL = 30_000; // 30s fallback poll interface NotificationItem { id: string; category: string; eventType: string; title: string; body: string | null; spaceSlug: string | null; actorUsername: string | null; actionUrl: string | null; createdAt: string; read: boolean; } export class RStackNotificationBell extends HTMLElement { #shadow: ShadowRoot; #unreadCount = 0; #notifications: NotificationItem[] = []; #pollTimer: ReturnType | null = null; #open = false; #loading = false; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.#render(); this.#fetchCount(); this.#pollTimer = setInterval(() => this.#fetchCount(), POLL_INTERVAL); // Listen for real-time WS notifications window.addEventListener("rspace-notification", this.#onWsNotification as EventListener); // Re-render on auth change document.addEventListener("auth-change", this.#onAuthChange); } disconnectedCallback() { if (this.#pollTimer) clearInterval(this.#pollTimer); window.removeEventListener("rspace-notification", this.#onWsNotification as EventListener); document.removeEventListener("auth-change", this.#onAuthChange); } #onAuthChange = () => { this.#unreadCount = 0; this.#notifications = []; this.#open = false; this.#render(); this.#fetchCount(); }; #onWsNotification = (e: CustomEvent) => { const data = e.detail; if (!data?.notification) return; this.#unreadCount = data.unreadCount ?? this.#unreadCount + 1; // Prepend to list if panel is loaded this.#notifications.unshift({ ...data.notification, read: false, }); this.#render(); }; #getToken(): string | null { const session = getSession(); return session?.accessToken ?? null; } async #fetchCount() { const token = this.#getToken(); if (!token) { this.#unreadCount = 0; this.#render(); return; } try { const res = await fetch("/api/notifications/count", { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) { const data = await res.json(); this.#unreadCount = data.unreadCount || 0; this.#render(); } } catch { // Silently fail } } async #fetchNotifications() { const token = this.#getToken(); if (!token) return; this.#loading = true; this.#render(); try { const res = await fetch("/api/notifications?limit=20", { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) { const data = await res.json(); this.#notifications = data.notifications || []; } } catch { // Silently fail } this.#loading = false; this.#render(); } async #markRead(id: string) { const token = this.#getToken(); if (!token) return; try { await fetch(`/api/notifications/${id}/read`, { method: "PATCH", headers: { Authorization: `Bearer ${token}` }, }); } catch { // Silently fail } const n = this.#notifications.find(n => n.id === id); if (n && !n.read) { n.read = true; this.#unreadCount = Math.max(0, this.#unreadCount - 1); this.#render(); } } async #markAllRead() { const token = this.#getToken(); if (!token) return; try { await fetch("/api/notifications/read-all", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: "{}", }); } catch { // Silently fail } this.#notifications.forEach(n => n.read = true); this.#unreadCount = 0; this.#render(); } async #dismiss(id: string) { const token = this.#getToken(); if (!token) return; try { await fetch(`/api/notifications/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); } catch { // Silently fail } const idx = this.#notifications.findIndex(n => n.id === id); if (idx >= 0) { const n = this.#notifications[idx]; if (!n.read) this.#unreadCount = Math.max(0, this.#unreadCount - 1); this.#notifications.splice(idx, 1); this.#render(); } } #togglePanel() { this.#open = !this.#open; if (this.#open && this.#notifications.length === 0) { this.#fetchNotifications(); } this.#render(); } #categoryIcon(category: string): string { switch (category) { case "space": return "🏠"; case "module": return "📦"; case "system": return "🔐"; case "social": return "💬"; default: return "🔔"; } } #timeAgo(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const mins = Math.floor(diff / 60_000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); return `${days}d ago`; } #render() { const session = getSession(); if (!session) { this.#shadow.innerHTML = ""; return; } const badge = this.#unreadCount > 0 ? `${this.#unreadCount > 99 ? "99+" : this.#unreadCount}` : ""; let panelHTML = ""; if (this.#open) { const header = `
Notifications ${this.#unreadCount > 0 ? `` : ""}
`; let body: string; if (this.#loading) { body = `
Loading...
`; } else if (this.#notifications.length === 0) { body = `
No notifications yet
`; } else { body = this.#notifications.map(n => `
${this.#categoryIcon(n.category)}
${n.title}
${n.body ? `
${n.body}
` : ""}
${n.actorUsername ? `${n.actorUsername}` : ""} ${this.#timeAgo(n.createdAt)}
`).join(""); } panelHTML = `
${header}${body}
`; } this.#shadow.innerHTML = `
${panelHTML}
`; // ── Event listeners ── const toggleBtn = this.#shadow.getElementById("bell-toggle"); toggleBtn?.addEventListener("click", (e) => { e.stopPropagation(); this.#togglePanel(); }); // Close on outside click const closeHandler = () => { if (this.#open) { this.#open = false; this.#render(); } }; document.addEventListener("click", closeHandler, { once: true }); // Stop propagation from panel clicks this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation()); // Mark all read this.#shadow.querySelector('[data-action="mark-all-read"]')?.addEventListener("click", (e) => { e.stopPropagation(); this.#markAllRead(); }); // Notification item clicks (mark read + navigate) this.#shadow.querySelectorAll(".notif-item").forEach((el) => { const id = (el as HTMLElement).dataset.id!; el.addEventListener("click", (e) => { e.stopPropagation(); this.#markRead(id); const n = this.#notifications.find(n => n.id === id); if (n?.actionUrl) { window.location.href = n.actionUrl; } }); }); // Dismiss buttons this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => { const id = (btn as HTMLElement).dataset.dismiss!; btn.addEventListener("click", (e) => { e.stopPropagation(); this.#dismiss(id); }); }); } static define(tag = "rstack-notification-bell") { if (!customElements.get(tag)) customElements.define(tag, RStackNotificationBell); } } // ============================================================================ // STYLES // ============================================================================ const STYLES = ` :host { display: inline-flex; align-items: center; } .bell-wrapper { position: relative; } .bell-btn { position: relative; background: none; border: none; color: var(--rs-text-muted, #94a3b8); cursor: pointer; padding: 6px; border-radius: 8px; display: flex; align-items: center; justify-content: center; transition: color 0.15s, background 0.15s; } .bell-btn:hover { color: var(--rs-text-primary, #e2e8f0); background: var(--rs-bg-hover, rgba(255,255,255,0.08)); } .badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; border-radius: 8px; background: #ef4444; color: white; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; line-height: 1; pointer-events: none; } .panel { position: absolute; top: 100%; right: 0; margin-top: 8px; width: 360px; max-height: 480px; overflow-y: auto; border-radius: 10px; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); box-shadow: 0 8px 30px rgba(0,0,0,0.3); z-index: 200; } .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1)); } .panel-title { font-size: 0.875rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); } .mark-all-btn { background: none; border: none; color: var(--rs-accent, #06b6d4); font-size: 0.75rem; cursor: pointer; padding: 4px 8px; border-radius: 4px; transition: background 0.15s; } .mark-all-btn:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.08)); } .panel-empty { padding: 32px 16px; text-align: center; color: var(--rs-text-muted, #94a3b8); font-size: 0.8rem; } .notif-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 16px; cursor: pointer; transition: background 0.15s; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.05)); } .notif-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.06)); } .notif-item.unread { background: rgba(6, 182, 212, 0.05); } .notif-item.unread .notif-title { font-weight: 600; } .notif-icon { flex-shrink: 0; font-size: 1rem; margin-top: 2px; } .notif-content { flex: 1; min-width: 0; } .notif-title { font-size: 0.8rem; color: var(--rs-text-primary, #e2e8f0); line-height: 1.3; } .notif-body { font-size: 0.75rem; color: var(--rs-text-muted, #94a3b8); margin-top: 2px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .notif-meta { display: flex; gap: 8px; margin-top: 4px; font-size: 0.7rem; color: var(--rs-text-muted, #64748b); } .notif-actor { font-weight: 500; } .notif-dismiss { flex-shrink: 0; background: none; border: none; color: var(--rs-text-muted, #64748b); font-size: 1rem; cursor: pointer; padding: 2px 4px; border-radius: 4px; opacity: 0; transition: opacity 0.15s, color 0.15s; } .notif-item:hover .notif-dismiss { opacity: 1; } .notif-dismiss:hover { color: var(--rs-text-primary, #e2e8f0); } `;