mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-27 07:56:48 +09:00
A push subscription belongs to the user, not a session, so a device signed out by a password change kept getting notifications. Changing the password now removes every subscription along with the sessions, in the same transaction. A browser still signed in keeps its own subscription in its service worker, so the notifications controller posts it once per session: the bell names the session by a digest of its id, never its token, and the browser remembers the last one it posted for. That post never unsubscribes the browser when the server refuses it. A request that authenticated just before the change could put a subscription back after it. The create action now checks the session still exists and saves in one immediate transaction, after validating the endpoint outside it.
185 lines
5.9 KiB
JavaScript
185 lines
5.9 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
import { post } from "@rails/request.js"
|
|
import { pageIsTurboPreview } from "helpers/turbo_helpers"
|
|
import { onNextEventLoopTick } from "helpers/timing_helpers"
|
|
import { getCookie, setCookie } from "lib/cookie"
|
|
|
|
const PUSH_SUBSCRIPTION_SYNCED_KEY = "push-subscription-synced-session"
|
|
|
|
export default class extends Controller {
|
|
static values = { subscriptionsUrl: String, session: String }
|
|
static targets = [ "notAllowedNotice", "bell", "details" ]
|
|
static classes = [ "attention" ]
|
|
|
|
async connect() {
|
|
if (!pageIsTurboPreview()) {
|
|
if (window.notificationsPreviouslyReady) {
|
|
onNextEventLoopTick(() => this.dispatch("ready"))
|
|
} else {
|
|
const firstTimeReady = await this.isEnabled()
|
|
|
|
this.#pulseBellButton()
|
|
|
|
if (firstTimeReady) {
|
|
onNextEventLoopTick(() => this.dispatch("ready"))
|
|
window.notificationsPreviouslyReady = true
|
|
} else {
|
|
this.#showBellAlert()
|
|
}
|
|
}
|
|
|
|
this.#resyncPushSubscriptionOncePerSession()
|
|
}
|
|
}
|
|
|
|
async attemptToSubscribe() {
|
|
if (this.#allowed) {
|
|
const registration = await this.#serviceWorkerRegistration || await this.#registerServiceWorker()
|
|
|
|
switch(Notification.permission) {
|
|
case "denied": { this.#revealNotAllowedNotice(); break }
|
|
case "granted": { this.#subscribe(registration); break }
|
|
case "default": { this.#requestPermissionAndSubscribe(registration) }
|
|
}
|
|
} else {
|
|
this.#revealNotAllowedNotice()
|
|
}
|
|
|
|
this.#endFirstRun()
|
|
}
|
|
|
|
async isEnabled() {
|
|
if (this.#allowed) {
|
|
const registration = await this.#serviceWorkerRegistration
|
|
const existingSubscription = await registration?.pushManager?.getSubscription()
|
|
|
|
return Notification.permission == "granted" && registration && existingSubscription
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
get #allowed() {
|
|
return navigator.serviceWorker && window.Notification
|
|
}
|
|
|
|
get #serviceWorkerRegistration() {
|
|
return navigator.serviceWorker.getRegistration(window.location.origin)
|
|
}
|
|
|
|
#registerServiceWorker() {
|
|
return navigator.serviceWorker.register("/service-worker.js")
|
|
}
|
|
|
|
#revealNotAllowedNotice() {
|
|
this.notAllowedNoticeTarget.showModal()
|
|
this.#openSingleOption()
|
|
}
|
|
|
|
#openSingleOption() {
|
|
const visibleElements = this.detailsTargets.filter(item => !this.#isHidden(item))
|
|
|
|
if (visibleElements.length === 1) {
|
|
this.detailsTargets.forEach(item => item.toggleAttribute("open", item === visibleElements[0]))
|
|
}
|
|
}
|
|
|
|
#showBellAlert() {
|
|
this.bellTarget.querySelectorAll("img").forEach(img => img.toggleAttribute("hidden"))
|
|
}
|
|
|
|
#pulseBellButton() {
|
|
if (!this.#hasSeenFirstRun) {
|
|
this.bellTarget.classList.add(this.attentionClass)
|
|
}
|
|
}
|
|
|
|
#endFirstRun() {
|
|
this.bellTarget.classList.remove(this.attentionClass)
|
|
this.#markFirstRunSeen()
|
|
}
|
|
|
|
async #subscribe(registration) {
|
|
registration.pushManager
|
|
.subscribe({ userVisibleOnly: true, applicationServerKey: this.#vapidPublicKey })
|
|
.then(subscription => {
|
|
this.#syncPushSubscription(subscription)
|
|
this.dispatch("ready")
|
|
})
|
|
}
|
|
|
|
async #syncPushSubscription(subscription) {
|
|
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
|
if (!response.ok) subscription.unsubscribe()
|
|
}
|
|
|
|
// A password change removes the account's subscriptions, the one this browser holds included. Post it again
|
|
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
|
|
// browser's own subscription alone: the next session tries again.
|
|
async #resyncPushSubscriptionOncePerSession() {
|
|
if (this.#allowed && this.sessionValue && localStorage.getItem(PUSH_SUBSCRIPTION_SYNCED_KEY) !== this.sessionValue) {
|
|
const registration = await this.#serviceWorkerRegistration
|
|
const subscription = await registration?.pushManager?.getSubscription()
|
|
|
|
if (Notification.permission === "granted" && subscription) {
|
|
await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
|
localStorage.setItem(PUSH_SUBSCRIPTION_SYNCED_KEY, this.sessionValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
async #requestPermissionAndSubscribe(registration) {
|
|
const permission = await Notification.requestPermission()
|
|
if (permission === "granted") this.#subscribe(registration)
|
|
}
|
|
|
|
get #vapidPublicKey() {
|
|
const encodedVapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content
|
|
return this.#urlBase64ToUint8Array(encodedVapidPublicKey)
|
|
}
|
|
|
|
get #hasSeenFirstRun() {
|
|
if (this.#isPWA) {
|
|
return getCookie("notifications-pwa-first-run-seen")
|
|
} else {
|
|
return getCookie("notifications-first-run-seen")
|
|
}
|
|
}
|
|
|
|
#markFirstRunSeen = (event) => {
|
|
if (this.#isPWA) {
|
|
setCookie("notifications-pwa-first-run-seen", true)
|
|
} else {
|
|
setCookie("notifications-first-run-seen", true)
|
|
}
|
|
}
|
|
|
|
#extractJsonPayloadAsString(subscription) {
|
|
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
|
|
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
|
|
}
|
|
|
|
// VAPID public key comes encoded as base64 but service worker registration needs it as a Uint8Array
|
|
#urlBase64ToUint8Array(base64String) {
|
|
const padding = "=".repeat((4 - base64String.length % 4) % 4)
|
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
|
|
|
|
const rawData = window.atob(base64)
|
|
const outputArray = new Uint8Array(rawData.length)
|
|
|
|
for (let i = 0; i < rawData.length; ++i) {
|
|
outputArray[i] = rawData.charCodeAt(i)
|
|
}
|
|
|
|
return outputArray
|
|
}
|
|
|
|
#isHidden(item) {
|
|
return (item.offsetParent === null)
|
|
}
|
|
|
|
get #isPWA() {
|
|
return window.matchMedia("(display-mode: standalone)").matches
|
|
}
|
|
}
|