PageSourceSearch

https://www.ensl.org/assets/controllers/gather_push-8ba938fa.js

js ensl.org collected 2026-09-25 10:50:30 UTC 5,996 bytes, 164 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { postJSON, destroyJSON } from "lib/request"
3import { showFlash } from "lib/flash"
4
5// Toggles browser push notifications for gathers. Enabling asks for notification permission,
6// registers the service worker and stores the resulting subscription server-side, which also
7// flips the permanent `notify_push_gather` profile preference.
8export default class extends Controller {
9  static targets = ["button"]
10  static values = {
11    vapidPublicKey: String,
12    url: String,
13    enabled: Boolean
14  }
15
16  // Hide the button on browsers that can't do push at all, otherwise show the current state
17  // and quietly make sure the server still holds a working subscription.
18  connect() {
19    if (!this.hasButtonTarget) return
20
21    if (!this.supported()) {
22      this.buttonTarget.hidden = true
23      return
24    }
25
26    if (this.enabledValue) this.resync()
27
28    this.updateButton()
29  }
30
31  // Browsers can rotate or drop a push subscription at any time, which would leave the stored
32  // endpoint dead with no visible symptom. Re-posting the current one on every page load keeps
33  // the database honest; re-registering also pulls in service worker updates.
34  async resync() {
35    try {
36      const registration = await navigator.serviceWorker.register("/service-worker.js")
37      await navigator.serviceWorker.ready
38
39      const subscription = await registration.pushManager.getSubscription()
40      if (!subscription) return
41
42      await this.save(subscription)
43    } catch (error) {
44      console.warn("[ENSL push] could not refresh subscription", error)
45    }
46  }
47
48  // Button click handler: switch the preference on or off, keeping the button locked while the
49  // permission prompt and network round trip are in flight.
50  async toggle() {
51    if (!this.supported()) return
52
53    this.buttonTarget.disabled = true
54    try {
55      if (this.enabledValue) {
56        await this.disable()
57      } else {
58        await this.enable()
59      }
60    } catch (error) {
61      showFlash(error.message || "Could not update push notifications.", "error")
62    } finally {
63      this.buttonTarget.disabled = false
64      this.updateButton()
65    }
66  }
67
68  // Ask for permission, subscribe this browser to the push service, and hand the subscription
69  // to the server (which also sets the permanent profile preference).
70  async enable() {
71    const permission = await Notification.requestPermission()
72    if (permission !== "granted") {
73      throw new Error("Notifications are blocked for this site in your browser settings.")
74    }
75
76    const registration = await navigator.serviceWorker.register("/service-worker.js")
77    await navigator.serviceWorker.ready
78
79    const subscription =
80      (await registration.pushManager.getSubscription()) ||
81      (await registration.pushManager.subscribe({
82        userVisibleOnly: true,
83        applicationServerKey: this.decodeVapidKey(this.vapidPublicKeyValue)
84      }))
85
86    await this.save(subscription)
87    this.enabledValue = true
88    this.confirmDelivery(registration)
89  }
90
91  // Drop this browser's subscription and clear the server-side preference.
92  async disable() {
93    const registration = await navigator.serviceWorker.getRegistration("/service-worker.js")
94    const subscription = registration ? await registration.pushManager.getSubscription() : null
95
96    if (subscription) {
97      await subscription.unsubscribe()
98    }
99
100    await destroyJSON(this.urlValue, { endpoint: subscription ? subscription.endpoint : "" })
101    this.enabledValue = false
102  }
103
104  // Store this browser's subscription server-side, which also sets the profile preference.
105  async save(subscription) {
106    await postJSON(this.urlValue, { subscription: subscription.toJSON() })
107  }
108
109  // The browser grants permission happily even when the operating system is silently discarding
110  // every notification, which looks to the user like the feature is simply broken. Fire one
111  // notification straight away and tell them where to look if it never appears.
112  confirmDelivery(registration) {
113    registration
114      .showNotification("Gather notifications enabled", {
115        body: "You'll get a notification here when your gather starts.",
116        tag: "ensl-optin",
117        icon: "/images/shared/discord.png"
118      })
119      .catch(() => {})
120
121    showFlash(
122      `Notifications enabled. We just sent a test one - if nothing appeared, ${this.systemSettingsHint()}`,
123      "notice"
124    )
125  }
126
127  // Where to re-enable notifications for the browser itself, which is the setting people miss.
128  systemSettingsHint() {
129    const platform = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ""
130
131    if (/mac/i.test(platform)) {
132      return "allow notifications for your browser in System Settings > Notifications."
133    }
134    if (/win/i.test(platform)) {
135      return "allow notifications for your browser in Windows Settings > System > Notifications."
136    }
137    return "check that your system allows notifications from your browser."
138  }
139
140  // Push needs a service worker, a push service and a configured VAPID key; without all three
141  // there is nothing to toggle.
142  supported() {
143    return (
144      this.vapidPublicKeyValue.length > 0 &&
145      "serviceWorker" in navigator &&
146      "PushManager" in window &&
147      typeof Notification !== "undefined"
148    )
149  }
150
151  // Keep the label and pressed state in step with the stored preference.
152  updateButton() {
153    this.buttonTarget.textContent = this.enabledValue ? "Disable Notifications" : "Notify Me"
154    this.buttonTarget.setAttribute("aria-pressed", this.enabledValue ? "true" : "false")
155  }
156
157  // The VAPID public key travels as base64url text but subscribe() wants raw bytes.
158  decodeVapidKey(base64String) {
159    const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
160    const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
161    const raw = window.atob(base64)
162    return Uint8Array.from(raw, (char) => char.charCodeAt(0))
163  }
164}

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.