mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
The lookup under the write lock repeated the one made before
validation, so the query cache could answer it with that earlier miss
even after another connection had registered the endpoint. It runs
uncached now.
Enabling notifications unsubscribed the browser on any refusal, so a
session signed out between authenticating and saving lost the browser's
subscription too, and the next session had nothing to post. Only a
refused endpoint unsubscribes it now.
From #371.
(cherry picked from commit aa93269ceb)
167 lines
5.0 KiB
JavaScript
167 lines
5.0 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"
|
|
|
|
export default class extends Controller {
|
|
static values = { subscriptionsUrl: 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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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" })
|
|
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
|
|
if (response.unprocessableEntity) subscription.unsubscribe()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|