Add service worker and subscriptions controllers
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function pageIsTurboPreview() {
|
||||
return document.documentElement.hasAttribute("data-turbo-preview")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user