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.