diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb new file mode 100644 index 000000000..841c8e921 --- /dev/null +++ b/app/controllers/pwa_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb new file mode 100644 index 000000000..bf5e5f87e --- /dev/null +++ b/app/controllers/users/push_subscriptions_controller.rb @@ -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 diff --git a/app/javascript/controllers/notifications.js b/app/javascript/controllers/notifications.js new file mode 100644 index 000000000..49fcc7f5a --- /dev/null +++ b/app/javascript/controllers/notifications.js @@ -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 + } +} diff --git a/app/javascript/helpers/turbo_helpers.js b/app/javascript/helpers/turbo_helpers.js new file mode 100644 index 000000000..b54f37354 --- /dev/null +++ b/app/javascript/helpers/turbo_helpers.js @@ -0,0 +1,3 @@ +export function pageIsTurboPreview() { + return document.documentElement.hasAttribute("data-turbo-preview") +} diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js new file mode 100644 index 000000000..e27c062f9 --- /dev/null +++ b/app/views/pwa/service_worker.js @@ -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) + } +} diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 5e60d6f9d..9ed623baf 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -8,6 +8,13 @@