PageSourceSearch

https://poppylist.com/assets/controllers/notification_tray_control…4bc889961e6fd304678ae6edb8c76840858ac16d.js

js poppylist.com collected 2026-10-01 16:19:35 UTC 12,320 bytes, 383 lines download raw bytes

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.