1import { Controller } from "@hotwired/stimulus" 2 3// Drives the notification bell: lazy-loads recent notifications as JSON, 4// renders rows, marks read on click, and navigates to each row's deep link. 5// Desktop uses a dropdown panel; mobile/native uses a right-side drawer 6// (mirror of the account menu). 7// 8// The top bar lives outside #registry_main, so its server-rendered badge can go 9// stale across turbo-frame tab switches. We re-sync unread_count from the 10// server on connect, on turbo navigations, and whenever the tray list loads. 11export default class extends Controller { 12 static values = { 13 indexUrl: String, 14 markAllUrl: String, 15 unreadCount: Number 16 } 17 18 static targets = ["panel", "list", "badge", "toggle", "drawer"] 19 20 connect() { 21 this.open = false 22 this.boundClose = this.closeOnOutside.bind(this) 23 this.boundEscape = this.closeOnEscape.bind(this) 24 this.boundSync = this.syncUnreadCount.bind(this) 25 this.boundExternalUnread = this.onExternalUnread.bind(this) 26 27 // The bell lives inside .np-top-bar, which uses transform (compositor pin). 28 // That makes position:fixed descendants size to the top bar only â so the 29 // drawer must live on <body>, same as the account menu. 30 if (this.hasDrawerTarget) { 31 this.mountDrawer(this.drawerTarget) 32 } else { 33 // Top bar may re-connect after the drawer was already moved to <body>. 34 const existing = document.getElementById("npNotificationDrawer") 35 if (existing) this.mountDrawer(existing) 36 } 37 38 window.__npNotifTray = this 39 window.closeNotificationDrawer = () => { 40 if (window.__npNotifTray) window.__npNotifTray.close() 41 } 42 window.markNotificationDrawerAllRead = () => { 43 if (window.__npNotifTray) { 44 window.__npNotifTray.markAllRead({ preventDefault() {}, stopPropagation() {} }) 45 } 46 } 47 48 // Drawer lives on <body>, outside this controller element â handle its 49 // clicks at document level (same idea as the account menu's onclick hooks). 50 if (!window.__npNotifDrawerClicksBound) { 51 window.__npNotifDrawerClicksBound = true 52 document.addEventListener("click", (event) => { 53 const tray = window.__npNotifTray 54 if (!tray || !tray.drawerEl) return 55 if (!tray.drawerEl.contains(event.target)) return 56 57 if (event.target.closest("[data-np-notif-close]")) { 58 tray.close(event) 59 return 60 } 61 if (event.target.closest("[data-np-notif-markall]")) { 62 tray.markAllRead(event) 63 return 64 } 65 const row = event.target.closest("#npNotificationDrawer [data-notification-id]") 66 if (row) { 67 tray.visit({ currentTarget: row, preventDefault() {} }) 68 } 69 }) 70 } 71 72 document.addEventListener("turbo:load", this.boundSync) 73 document.addEventListener("turbo:frame-load", this.boundSync) 74 document.addEventListener("np:notifications:unread", this.boundExternalUnread) 75 76 // Report the server-rendered count immediately (clears a stale native icon 77 // badge even before the async re-sync returns), then confirm with the server. 78 this.refreshBadge() 79 this.syncUnreadCount() 80 } 81 82 mountDrawer(drawer) { 83 // Drop duplicate drawers left behind by prior turbo connects. 84 document.querySelectorAll("#npNotificationDrawer").forEach((el) => { 85 if (el !== drawer) el.remove() 86 }) 87 drawer.id = "npNotificationDrawer" 88 this.drawerEl = drawer 89 this.panelEl = drawer.querySelector(".np-notif-drawer__panel") 90 this.listEl = drawer.querySelector(".np-notif__list") 91 if (drawer.parentElement !== document.body) { 92 document.body.appendChild(drawer) 93 } 94 } 95 96 disconnect() { 97 document.removeEventListener("click", this.boundClose) 98 document.removeEventListener("keydown", this.boundEscape) 99 document.removeEventListener("turbo:load", this.boundSync) 100 document.removeEventListener("turbo:frame-load", this.boundSync) 101 document.removeEventListener("np:notifications:unread", this.boundExternalUnread) 102 if (window.__npNotifTray === this) window.__npNotifTray = null 103 if (this.open && this.drawerEl) document.body.style.overflow = "" 104 } 105 106 get isDrawer() { 107 return !!this.drawerEl 108 } 109 110 listNode() { 111 return this.listEl || (this.hasListTarget ? this.listTarget : null) 112 } 113 114 panelNode() { 115 return this.panelEl || (this.hasPanelTarget ? this.panelTarget : null) 116 } 117 118 onExternalUnread(event) { 119 const count = event.detail && event.detail.count 120 if (typeof count !== "number") return 121 if (this.unreadCountValue === count) return 122 this.unreadCountValue = count 123 this.refreshBadge() 124 } 125 126 setUnread(count) { 127 const next = Math.max(0, Number(count) || 0) 128 if (this.unreadCountValue === next) { 129 this.refreshBadge() 130 return 131 } 132 this.unreadCountValue = next 133 this.refreshBadge() 134 document.dispatchEvent(new CustomEvent("np:notifications:unread", { 135 detail: { count: this.unreadCountValue } 136 })) 137 } 138 139 countOnlyUrl() { 140 const base = this.indexUrlValue 141 return base.includes("?") ? `${base}&count_only=1` : `${base}?count_only=1` 142 } 143 144 async syncUnreadCount() { 145 try { 146 const res = await fetch(this.countOnlyUrl(), { 147 headers: { Accept: "application/json" }, 148 credentials: "same-origin" 149 }) 150 if (!res.ok) return 151 const data = await res.json() 152 if (typeof data.unread_count === "number") { 153 this.setUnread(data.unread_count) 154 } 155 } catch { 156 /* best-effort */ 157 } 158 } 159 160 toggle(event) { 161 event.preventDefault() 162 event.stopPropagation() 163 if (this.open) { 164 this.close() 165 } else { 166 this.openTray() 167 } 168 } 169 170 openTray() { 171 this.open = true 172 this.toggleTarget.setAttribute("aria-expanded", "true") 173 document.addEventListener("keydown", this.boundEscape) 174 175 if (this.isDrawer) { 176 this.drawerEl.classList.remove("hidden") 177 requestAnimationFrame(() => this.drawerEl.classList.add("is-open")) 178 document.body.style.overflow = "hidden" 179 } else { 180 this.panelNode().classList.remove("hidden") 181 document.addEventListener("click", this.boundClose) 182 } 183 184 this.load() 185 } 186 187 closeOnOutside(event) { 188 if (!this.open || this.isDrawer) return 189 if (this.element.contains(event.target)) return 190 this.close() 191 } 192 193 closeOnEscape(event) { 194 if (event.key === "Escape") this.close() 195 } 196 197 close(event) { 198 if (event) { 199 event.preventDefault() 200 event.stopPropagation() 201 } 202 const drawerOpen = this.drawerEl && ( 203 this.drawerEl.classList.contains("is-open") || !this.drawerEl.classList.contains("hidden") 204 ) 205 const panel = this.panelNode() 206 const dropdownOpen = panel && !this.drawerEl && !panel.classList.contains("hidden") 207 if (!this.open && !drawerOpen && !dropdownOpen) return 208 209 this.open = false 210 if (this.hasToggleTarget) this.toggleTarget.setAttribute("aria-expanded", "false") 211 document.removeEventListener("click", this.boundClose) 212 document.removeEventListener("keydown", this.boundEscape) 213 214 if (this.drawerEl) { 215 this.drawerEl.classList.remove("is-open") 216 window.setTimeout(() => { 217 if (!this.open && this.drawerEl) this.drawerEl.classList.add("hidden") 218 }, 250) 219 document.body.style.overflow = "" 220 } else if (panel) { 221 panel.classList.add("hidden") 222 } 223 } 224 225 async load() { 226 const list = this.listNode() 227 if (!list) return 228 list.innerHTML = `<div class="np-notif__state">Loadingâ¦</div>` 229 try { 230 const res = await fetch(this.indexUrlValue, { 231 headers: { Accept: "application/json" }, 232 credentials: "same-origin" 233 }) 234 if (!res.ok) throw new Error("Failed to load") 235 const data = await res.json()
236 // Support both the new { notifications, unread_count } payload and a 237 // legacy bare array (in case an older deploy is still serving one). 238 const items = Array.isArray(data) ? data : (data.notifications || []) 239 if (!Array.isArray(data) && typeof data.unread_count === "number") { 240 this.setUnread(data.unread_count) 241 } else { 242 this.setUnread(items.filter((n) => !n.read_at).length) 243 } 244 this.renderList(items) 245 } catch { 246 list.innerHTML = `<div class="np-notif__state">Could not load notifications.</div>` 247 } 248 } 249 250 renderList(items) { 251 const list = this.listNode() 252 if (!list) return 253 if (!items.length) { 254 list.innerHTML = `<div class="np-notif__state">No notifications yet.</div>` 255 return 256 } 257 list.innerHTML = items.map((n) => this.rowHtml(n)).join("") 258 } 259 260 rowHtml(n) { 261 const unread = !n.read_at 262 const path = n.path || (n.metadata && n.metadata.path) || "#" 263 const title = this.escapeHtml(n.title || "") 264 const body = this.escapeHtml(n.body || "") 265 return `<button type="button" class="np-notif__row ${unread ? "np-notif__row--unread" : ""}" 266 data-notification-id="${n.id}" data-url="${this.escapeHtml(path)}" 267 data-action="click->notification-tray#visit"> 268 <div class="np-notif__row-title">${title}</div> 269 ${body ? `<div class="np-notif__row-body">${body}</div>` : ""} 270 <div class="np-notif__row-meta">${unread ? "Unread" : "Read"}</div> 271 </button>` 272 } 273 274 escapeHtml(str) { 275 const d = document.createElement("div") 276 d.textContent = str == null ? "" : String(str) 277 return d.innerHTML 278 } 279 280 csrfToken() { 281 return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") 282 } 283 284 async visit(event) { 285 const btn = event.currentTarget 286 const id = btn.dataset.notificationId 287 const url = btn.dataset.url 288 const wasUnread = btn.classList.contains("np-notif__row--unread") 289 event.preventDefault() 290 await this.patchRead(id, wasUnread) 291 this.close() 292 this.navigate(url || "/") 293 } 294 295 navigate(url) { 296 if (window.Turbo && typeof window.Turbo.visit === "function") { 297 window.Turbo.visit(url) 298 } else { 299 window.location.href = url 300 } 301 } 302 303 async patchRead(id, wasUnread) { 304 if (!id) return 305 try { 306 const res = await fetch(`/registrant_notifications/${id}`, { 307 method: "PATCH", 308 headers: { 309 Accept: "application/json", 310 "Content-Type": "application/json", 311 "X-CSRF-Token": this.csrfToken() 312 }, 313 credentials: "same-origin", 314 body: "{}" 315 }) 316 if (res.ok) { 317 const data = await res.json() 318 if (typeof data.unread_count === "number") { 319 this.setUnread(data.unread_count) 320 return 321 } 322 } 323 } catch { 324 /* best-effort; navigation still proceeds */ 325 } 326 if (wasUnread) this.setUnread(this.unreadCountValue - 1) 327 } 328 329 async markAllRead(event) { 330 event.preventDefault() 331 event.stopPropagation() 332 try { 333 const res = await fetch(this.markAllUrlValue, { 334 method: "POST", 335 headers: { 336 Accept: "application/json", 337 "Content-Type": "application/json", 338 "X-CSRF-Token": this.csrfToken() 339 }, 340 credentials: "same-origin", 341 body: "{}" 342 }) 343 if (res.ok) { 344 const data = await res.json().catch(() => ({ unread_count: 0 })) 345 this.setUnread(typeof data.unread_count === "number" ? data.unread_count : 0) 346 } else { 347 this.setUnread(0) 348 } 349 } catch { 350 this.setUnread(0) 351 } 352 this.load() 353 } 354 355 refreshBadge() { 356 const c = this.unreadCountValue 357 // Keep the native home-screen icon badge in sync (no-op on web). 358 this.reportBadgeToNative(c) 359 360 if (!this.hasBadgeTarget) return 361 if (c > 0) { 362 this.badgeTarget.textContent = c > 99 ? "99+" : String(c) 363 this.badgeTarget.classList.remove("hidden") 364 } else { 365 this.badgeTarget.textContent = "" 366 this.badgeTarget.classList.add("hidden") 367 } 368 } 369 370 // Bridges the unread count to the native app so the app icon badge matches 371 // the in-app state. Harmless on mobile web (the handler doesn't exist). 372 reportBadgeToNative(count) { 373 try { 374 const bridge = window.webkit && 375 window.webkit.messageHandlers && 376 window.webkit.messageHandlers.poppylist 377 if (!bridge) return 378 bridge.postMessage({ name: "setBadge", count: Math.max(0, Number(count) || 0) }) 379 } catch { 380 /* not running in the native app */ 381 } 382 } 383};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.