Add service worker and subscriptions controllers

This commit is contained in:
Jason Zimdars
2025-07-15 15:22:32 -05:00
parent 38d13f78a3
commit 8f47db3ced
7 changed files with 191 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
class PwaController < ApplicationController
allow_unauthenticated_access
skip_forgery_protection
# We need a stable URL at the root, so we can't use the regular asset path here.
def service_worker
end
# Need ERB interpolation for paths, so can't use asset path here either.
def manifest
end
end
@@ -0,0 +1,30 @@
class Users::PushSubscriptionsController < ApplicationController
before_action :set_push_subscriptions
def index
end
def create
if subscription = @push_subscriptions.find_by(push_subscription_params)
subscription.touch
else
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
end
head :ok
end
def destroy
@push_subscriptions.destroy_by(id: params[:id])
redirect_to user_push_subscriptions_url
end
private
def set_push_subscriptions
@push_subscriptions = Current.user.push_subscriptions
end
def push_subscription_params
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
end
end
+106
View File
@@ -0,0 +1,106 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
import { pageIsTurboPreview } from "helpers/turbo_helpers"
import { onNextEventLoopTick } from "helpers/timing_helpers"
console.log("Notifications controller module loaded")
export default class extends Controller {
static values = { subscriptionsUrl: String }
async connect() {
console.log("connected")
if (!pageIsTurboPreview()) {
if (window.notificationsPreviouslyReady) {
onNextEventLoopTick(() => this.dispatch("ready"))
} else {
const firstTimeReady = await this.isEnabled()
if (firstTimeReady) {
onNextEventLoopTick(() => this.dispatch("ready"))
window.notificationsPreviouslyReady = true
}
}
}
}
async attemptToSubscribe() {
console.log("attemptToSubscribe")
if (this.#allowed) {
const registration = await this.#serviceWorkerRegistration || await this.#registerServiceWorker()
switch(Notification.permission) {
case "denied": { break }
case "granted": { this.#subscribe(registration); break }
case "default": { this.#requestPermissionAndSubscribe(registration) }
}
}
}
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.host)
}
#registerServiceWorker() {
return navigator.serviceWorker.register("/service-worker.js")
}
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()
}
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)
}
#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
}
}
+3
View File
@@ -0,0 +1,3 @@
export function pageIsTurboPreview() {
return document.documentElement.hasAttribute("data-turbo-preview")
}
+30
View File
@@ -0,0 +1,30 @@
self.addEventListener("push", async (event) => {
const data = await event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
})
async function showNotification({ title, options }) {
return self.registration.showNotification(title, options)
}
async function updateBadgeCount({ data: { badge } }) {
return self.navigator.setAppBadge?.(badge || 0)
}
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.path, self.location.origin).href
event.waitUntil(openURL(url))
})
async function openURL(url) {
const clients = await self.clients.matchAll({ type: "window" })
const focused = clients.find((client) => client.focused)
if (focused) {
await focused.navigate(url)
} else {
await self.clients.openWindow(url)
}
}
+7
View File
@@ -8,6 +8,13 @@
<div class="header__actions header__actions--end">
<% if Current.user == @user %>
<span data-controller="notifications" data-notifications-subscriptions-url-value="<%= user_push_subscriptions_path(Current.user) %>">
<button class="btn" data-action="notifications#attemptToSubscribe">
<%= icon_tag "bell" %>
<span class="for-screen-reader">Turn on push notifications</span>
</button>
</span>
<%= link_to edit_user_path(@user), class: "btn flex-item-justify-end" do %>
<%= icon_tag "pencil" %>
<span class="for-screen-reader">Edit</span>
+3 -1
View File
@@ -10,6 +10,7 @@ Rails.application.routes.draw do
resources :users do
resource :role, module: :users
resources :push_subscriptions, module: :users
end
resources :collections do
@@ -169,7 +170,8 @@ Rails.application.routes.draw do
end
get "up", to: "rails/health#show", as: :rails_health_check
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
get "manifest" => "pwa#manifest", as: :pwa_manifest
get "service-worker" => "pwa#service_worker"
root "events#index"