diff --git a/Gemfile b/Gemfile
index 45b93a6ec..1cf6157fd 100644
--- a/Gemfile
+++ b/Gemfile
@@ -31,6 +31,8 @@ gem "actiontext-lexical", bc: "actiontext-lexical"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
+gem "web-push"
+gem "net-http-persistent"
# 37id and Queenbee integration
gem "signal_id", bc: "signal_id", branch: "rails4"
diff --git a/Gemfile.lock b/Gemfile.lock
index ae8a77c3a..76b2597a2 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -377,6 +377,8 @@ GEM
multipart-post (2.4.1)
net-http (0.6.0)
uri
+ net-http-persistent (4.0.6)
+ connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.5.9)
date
net-protocol
@@ -401,6 +403,7 @@ GEM
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
+ openssl (3.3.0)
ostruct (0.6.2)
parallel (1.27.0)
parser (3.3.8.0)
@@ -526,11 +529,11 @@ GEM
fugit (~> 1.11.0)
railties (>= 7.1)
thor (>= 1.3.1)
+ sqlite3 (2.7.2-arm64-darwin)
+ sqlite3 (2.7.2-x86_64-darwin)
+ sqlite3 (2.7.2-x86_64-linux-gnu)
sqlite3 (2.7.3)
mini_portile2 (~> 2.8.0)
- sqlite3 (2.7.3-arm64-darwin)
- sqlite3 (2.7.3-x86_64-darwin)
- sqlite3 (2.7.3-x86_64-linux-gnu)
sshkit (1.24.0)
base64
logger
@@ -565,6 +568,9 @@ GEM
useragent (0.16.11)
vcr (6.3.1)
base64
+ web-push (3.0.2)
+ jwt (~> 3.0)
+ openssl (~> 3.0)
webmock (3.25.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@@ -605,6 +611,7 @@ DEPENDENCIES
mission_control-jobs
mocha
mysql2!
+ net-http-persistent
platform_agent
portfolio (>= 4.6)!
propshaft
@@ -632,6 +639,7 @@ DEPENDENCIES
tiktoken_ruby
turbo-rails
vcr
+ web-push
webmock
BUNDLED WITH
diff --git a/app/assets/images/gear.svg b/app/assets/images/gear.svg
new file mode 100644
index 000000000..197da4fd9
--- /dev/null
+++ b/app/assets/images/gear.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/install-edge.svg b/app/assets/images/install-edge.svg
new file mode 100644
index 000000000..0f1ec118a
--- /dev/null
+++ b/app/assets/images/install-edge.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/install.svg b/app/assets/images/install.svg
new file mode 100644
index 000000000..82608c765
--- /dev/null
+++ b/app/assets/images/install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/lifebuoy.svg b/app/assets/images/lifebuoy.svg
new file mode 100644
index 000000000..b26ca7307
--- /dev/null
+++ b/app/assets/images/lifebuoy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/sliders.svg b/app/assets/images/sliders.svg
new file mode 100644
index 000000000..d84a39c1b
--- /dev/null
+++ b/app/assets/images/sliders.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/switch.svg b/app/assets/images/switch.svg
new file mode 100644
index 000000000..2dc1f1232
--- /dev/null
+++ b/app/assets/images/switch.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css
index 7b6b2f73a..2167c28bc 100644
--- a/app/assets/stylesheets/icons.css
+++ b/app/assets/stylesheets/icons.css
@@ -41,10 +41,15 @@
.icon--crown { --svg: url("crown.svg "); }
.icon--email { --svg: url("email.svg "); }
.icon--everyone { --svg: url("everyone.svg "); }
+ .icon--gear { --svg: url("gear.svg "); }
.icon--filter { --svg: url("filter.svg "); }
.icon--globe { --svg: url("globe.svg "); }
.icon--golden-ticket { --svg: url("golden-ticket.svg "); }
.icon--home { --svg: url("home.svg "); }
+ .icon--install { --svg: url("install.svg "); }
+ .icon--install { --svg: url("install.svg "); }
+ .icon--install-edge { --svg: url("install-edge.svg "); }
+ .icon--lifebuoy { --svg: url("lifebuoy.svg "); }
.icon--lock { --svg: url("lock.svg "); }
.icon--logout { --svg: url("logout.svg "); }
.icon--menu { --svg: url("menu.svg "); }
@@ -72,6 +77,8 @@
.icon--search { --svg: url("search.svg "); }
.icon--settings { --svg: url("settings.svg "); }
.icon--share { --svg: url("share.svg "); }
+ .icon--sliders { --svg: url("sliders.svg "); }
+ .icon--switch { --svg: url("switch.svg "); }
.icon--tag { --svg: url("tag.svg "); }
.icon--thumb-up { --svg: url("thumb-up.svg "); }
.icon--trash { --svg: url("trash.svg "); }
diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css
index e8659a268..7aac80d0b 100644
--- a/app/assets/stylesheets/notifications.css
+++ b/app/assets/stylesheets/notifications.css
@@ -44,5 +44,53 @@
display: none;
}
}
+
+
+ /* Help
+ /* ------------------------------------------------------------------------ */
+ .notifications-help {
+ h2 {
+ font-size: var(--text-medium);
+ margin: 0;
+ }
+
+ .icon {
+ --icon-size: 1.2em;
+
+ vertical-align: text-top;
+ }
+
+ ol {
+ margin-block: var(--block-space-half) var(--block-space);
+
+ &:last-of-type {
+ margin-block-end: var(--block-space-half);
+ }
+ }
+ }
+
+ .notifications-help__explainer {
+ padding: var(--block-space);
+ }
+
+ .notifications__on-message {
+ display: none;
+
+ .notifications--on & {
+ display: block;
+ }
+ }
+
+ .notifications__off-message {
+ display: block;
+
+ .notifications--on & {
+ display: none;
+ }
+ }
+
+ .notifications__status {
+ --panel-padding: var(--block-space);
+ }
}
diff --git a/app/assets/stylesheets/pwa.css b/app/assets/stylesheets/pwa.css
new file mode 100644
index 000000000..4f30941b0
--- /dev/null
+++ b/app/assets/stylesheets/pwa.css
@@ -0,0 +1,14 @@
+/* PWA install */
+.pwa__instructions {
+ @media (display-mode: standalone) {
+ display: none;
+ }
+}
+
+.pwa__installer {
+ display: none;
+
+ .pwa--can-install & {
+ display: block;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index 45cfd7d56..4661c391e 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -219,6 +219,18 @@
[hidden] { display: none !important; }
[contents] { display: contents; }
+ .hide-in-pwa {
+ @media (display-mode: standalone) {
+ display: none;
+ }
+ }
+
+ .hide-in-browser {
+ @media (display-mode: browser) {
+ display: none;
+ }
+ }
+
.hide-focus-ring {
--focus-ring-size: 0;
}
diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb
new file mode 100644
index 000000000..bd6127182
--- /dev/null
+++ b/app/controllers/pwa_controller.rb
@@ -0,0 +1,8 @@
+class PwaController < ApplicationController
+ require_untenanted_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
+end
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_controller.js b/app/javascript/controllers/notifications_controller.js
new file mode 100644
index 000000000..02fce86d7
--- /dev/null
+++ b/app/javascript/controllers/notifications_controller.js
@@ -0,0 +1,108 @@
+import { Controller } from "@hotwired/stimulus"
+import { post } from "@rails/request.js"
+
+export default class extends Controller {
+ static classes = [ "enabled" ]
+ static targets = [ "subscribeButton", "explainer" ]
+ static values = { subscriptionsUrl: String }
+
+ async connect() {
+ if (!this.#allowed) return
+
+ switch(Notification.permission) {
+ case "default":
+ this.subscribeButtonTarget.hidden = false
+ this.explainerTarget.hidden = true
+ break
+ case "granted":
+ const registration = await this.#getServiceWorkerRegistration()
+ const subscription = await registration?.pushManager?.getSubscription()
+
+ if (registration && subscription) {
+ this.element.classList.add(this.enabledClass)
+ }
+ break
+ }
+ }
+
+ async attemptToSubscribe() {
+ if (this.#allowed) {
+ const registration = await this.#getServiceWorkerRegistration() || 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.#getServiceWorkerRegistration()
+ const existingSubscription = await registration?.pushManager?.getSubscription()
+
+ return Notification.permission == "granted" && registration && existingSubscription
+ }
+ }
+
+ get #allowed() {
+ return navigator.serviceWorker && window.Notification
+ }
+
+ async #getServiceWorkerRegistration() {
+ return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" })
+ }
+
+ #registerServiceWorker() {
+ return navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
+ }
+
+ async #subscribe(registration) {
+ registration.pushManager
+ .subscribe({ userVisibleOnly: true, applicationServerKey: this.#vapidPublicKey })
+ .then(subscription => {
+ this.#syncPushSubscription(subscription)
+ })
+ }
+
+ async #syncPushSubscription(subscription) {
+ const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
+ if (response.ok) {
+ this.element.classList.add(this.enabledClass)
+ this.subscribeButtonTarget.hidden = true
+ } else {
+ 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/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb
new file mode 100644
index 000000000..128cf6a18
--- /dev/null
+++ b/app/jobs/push_notification_job.rb
@@ -0,0 +1,7 @@
+class PushNotificationJob < ApplicationJob
+ queue_as :default
+
+ def perform(notification)
+ NotificationPusher.new(notification).push
+ end
+end
diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb
index c8c24a50b..500f4adc6 100644
--- a/app/models/application_platform.rb
+++ b/app/models/application_platform.rb
@@ -15,6 +15,14 @@ class ApplicationPlatform < PlatformAgent
user_agent.browser.match? /Chrome/
end
+ def edge?
+ user_agent.browser.match? /Edg/
+ end
+
+ def firefox?
+ user_agent.browser.match? /Firefox|FxiOS/
+ end
+
def safari?
user_agent.browser.match? /Safari/
end
@@ -26,4 +34,21 @@ class ApplicationPlatform < PlatformAgent
def desktop?
!mobile?
end
+
+ def windows?
+ operating_system == "Windows"
+ end
+
+ def operating_system
+ case user_agent.platform
+ when /Android/ then "Android"
+ when /iPad/ then "iPad"
+ when /iPhone/ then "iPhone"
+ when /Macintosh/ then "macOS"
+ when /Windows/ then "Windows"
+ when /CrOS/ then "ChromeOS"
+ else
+ os =~ /Linux/ ? "Linux" : os
+ end
+ end
end
diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb
new file mode 100644
index 000000000..e497bc3fe
--- /dev/null
+++ b/app/models/concerns/push_notifiable.rb
@@ -0,0 +1,12 @@
+module PushNotifiable
+ extend ActiveSupport::Concern
+
+ included do
+ after_create_commit :push_notification_later
+ end
+
+ private
+ def push_notification_later
+ PushNotificationJob.perform_later(self)
+ end
+end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 73525f4ff..4eaf5786b 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -1,4 +1,6 @@
class Notification < ApplicationRecord
+ include PushNotifiable
+
belongs_to :user
belongs_to :creator, class_name: "User"
belongs_to :source, polymorphic: true
diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb
new file mode 100644
index 000000000..8f68e4491
--- /dev/null
+++ b/app/models/notification_pusher.rb
@@ -0,0 +1,122 @@
+class NotificationPusher
+ include Rails.application.routes.url_helpers
+
+ attr_reader :notification
+
+ def initialize(notification)
+ @notification = notification
+ end
+
+ def push
+ return unless should_push?
+
+ build_payload.tap do |payload|
+ push_to_user(payload)
+ end
+ end
+
+ private
+ def account_prefix
+ "/#{Account.sole.queenbee_id}"
+ end
+
+ def should_push?
+ notification.user.push_subscriptions.any? &&
+ !notification.creator.system? &&
+ notification.user.active?
+ end
+
+ def build_payload
+ case notification.source_type
+ when "Event"
+ build_event_payload
+ when "Mention"
+ build_mention_payload
+ else
+ build_default_payload
+ end
+ end
+
+ def build_event_payload
+ event = notification.source
+ card = event.card
+
+ base_payload = {
+ title: card_notification_title(card),
+ path: card_path(card)
+ }
+
+ case event.action
+ when "comment_created"
+ base_payload.merge(
+ title: "RE: #{base_payload[:title]}",
+ body: comment_notification_body(event)
+ )
+ when "card_assigned"
+ base_payload.merge(
+ body: "Assigned to you by #{event.creator.name}"
+ )
+ when "card_published"
+ base_payload.merge(
+ body: "Added by #{event.creator.name}"
+ )
+ when "card_closed"
+ base_payload.merge(
+ body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}"
+ )
+ when "card_reopened"
+ base_payload.merge(
+ body: "Reopened by #{event.creator.name}"
+ )
+ else
+ base_payload.merge(
+ body: event.creator.name
+ )
+ end
+ end
+
+ def build_mention_payload
+ mention = notification.source
+ card = mention.card
+
+ {
+ title: "#{mention.mentioner.first_name} mentioned you",
+ body: mention.source.mentionable_content.truncate(200),
+ path: card_path(card)
+ }
+ end
+
+ def build_default_payload
+ {
+ title: "New notification",
+ body: "You have a new notification",
+ path: "#{account_prefix}#{notifications_path}"
+ }
+ end
+
+ def push_to_user(payload)
+ subscriptions = notification.user.push_subscriptions
+ enqueue_payload_for_delivery(payload, subscriptions)
+ end
+
+ def enqueue_payload_for_delivery(payload, subscriptions)
+ Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
+ end
+
+ def card_notification_title(card)
+ card.title.presence || "Card #{card.id}"
+ end
+
+ def comment_notification_body(event)
+ comment = event.eventable
+ strip_tags(comment.body.to_s).truncate(200)
+ end
+
+ def strip_tags(text)
+ ActionView::Base.full_sanitizer.sanitize(text)
+ end
+
+ def card_path(card)
+ "#{account_prefix}#{collection_card_path(card.collection, card)}"
+ end
+end
diff --git a/app/models/push.rb b/app/models/push.rb
new file mode 100644
index 000000000..765323c0b
--- /dev/null
+++ b/app/models/push.rb
@@ -0,0 +1,5 @@
+module Push
+ def self.table_name_prefix
+ "push_"
+ end
+end
diff --git a/app/models/push/subscription.rb b/app/models/push/subscription.rb
new file mode 100644
index 000000000..ebc12c70d
--- /dev/null
+++ b/app/models/push/subscription.rb
@@ -0,0 +1,7 @@
+class Push::Subscription < ApplicationRecord
+ belongs_to :user
+
+ def notification(**params)
+ WebPush::Notification.new(**params, badge: user.notifications.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 94c2a5827..dcc3ba9b8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -17,6 +17,7 @@ class User < ApplicationRecord
has_many :pins, dependent: :destroy
has_many :pinned_cards, through: :pins, source: :card
has_many :commands, dependent: :destroy
+ has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
has_many :period_activity_summaries, dependent: :destroy
normalizes :email_address, with: ->(value) { value.strip.downcase }
diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb
index b79c9ba58..0922c2de4 100644
--- a/app/views/layouts/shared/_head.html.erb
+++ b/app/views/layouts/shared/_head.html.erb
@@ -9,6 +9,7 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= tag.meta name: "current-user-id", content: Current.user.id if Current.user %>
+ <%= tag.meta name: "vapid-public-key", content: Rails.configuration.x.vapid.public_key %>
<% turbo_refreshes_with method: :morph, scroll: :preserve %>
diff --git a/app/views/notifications/settings/_browser.html.erb b/app/views/notifications/settings/_browser.html.erb
new file mode 100644
index 000000000..4662c558b
--- /dev/null
+++ b/app/views/notifications/settings/_browser.html.erb
@@ -0,0 +1,109 @@
+<% unless (platform.safari? || platform.chrome?) && platform.ios? %>
+
+ <% case
+ when platform.firefox? && platform.android? %>
+
Turn on notifications for <%= platform.browser.capitalize %>.
+
+ - Tap <%= icon_tag "lock", alt: "the View site information button" %> in the address bar.
+ - Tap Notification to change to Allowed.
+
+ <% when platform.edge? && platform.desktop? %>
+
Turn on notifications for this website.
+
+ - Click <%= icon_tag "lock", alt: "the View site information button" %> left of the address bar.
+ - Under Permissions for this site > Notifications, choose Allow.
+
+
Turn on notifications for <%= platform.browser.capitalize %>.
+
+ <% if platform.windows? %>
+ - Click Start, then Settings.
+ - Go to System > Notification.
+ - Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
+ <% else %>
+ - Click in the top left.
+ - Click System Settings….
+ - Click Notifications.
+ - Click <%= platform.browser.capitalize %>.
+ - Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
+ <% end %>
+
+ <% when platform.firefox? && platform.desktop? %>
+
Turn on notifications for this website.
+
+ - Click <%= platform.browser.capitalize %> in the top left.
+ - Click Settings….
+ - Click Privacy & Security in the sidebar.
+ - Scroll down to Permissions.
+ - Click Settings next to Notifications.
+ - Select Allow next to <%= root_url %>.
+
+
+
Turn on notifications for <%= platform.browser.capitalize %>.
+
+ <% if platform.windows? %>
+ - Click Start, then Settings.
+ - Go to System > Notification.
+ - Click <%= icon_tag "switch", alt: "the toggle button" %> ON for <%= platform.browser.capitalize %>.
+ <% else %>
+ - Click in the top left.
+ - Click System Settings….
+ - Click Notifications.
+ - Click <%= platform.browser.capitalize %>.
+ - Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
+ <% end %>
+
+ <% when platform.chrome? && platform.desktop? %>
+
Turn on notifications for this website.
+
+ - Click the <%= icon_tag "sliders", alt: "View site information" %> icon in the address bar.
+ - Click Site Settings.
+ - Ensure notifications are Allowed.
+
+
+
Turn on notifications for <%= platform.browser.capitalize %>.
+
+ <% if platform.windows? %>
+ - Click Start, then Settings.
+ - Go to System > Notification.
+ - Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
+ <% else %>
+ - Click in the top left.
+ - Click System Settings….
+ - Click Notifications.
+ - Click <%= platform.browser.capitalize %>.
+ - Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
+ <% end %>
+
+ <% when platform.chrome? && platform.android? %>
+
Turn on notifications for <%= platform.browser.capitalize %>.
+
+ - Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
+ - Tap Settings.
+ - Tap Notifications.
+ - Tap <%= icon_tag "switch", alt: "the switch" %> to Allow <%= platform.browser.capitalize %> notifications.
+ - Tap <%= icon_tag "switch", alt: "the switch" %> next to Web apps.
+ - Tap <%= icon_tag "notification-bell-alert", alt: "the notification bell" %> and select Allow.
+
+ <% when platform.safari? && platform.desktop? %>
+
Turn on notifications for this website.
+
+ - Click in the top left.
+ - Click System Settings….
+ - Click Notifications.
+ - Click <%= request.base_url %> in the list.
+ - Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
+
+
Turn on notifications for <%= platform.browser.capitalize %>.
+
+ - Click <%= platform.browser.capitalize %> in the top left.
+ - Click Settings….
+ - Click the Websites tab.
+ - Click Notifications in the sidebar.
+ - Click <%= request.base_url %> in the list.
+ - Select Allow.
+
+ <% else %>
+
Ensure notifications are enabled for <%= root_url %> in your web browser settings.
+ <% end %>
+
+<% end %>
diff --git a/app/views/notifications/settings/_install.html.erb b/app/views/notifications/settings/_install.html.erb
new file mode 100644
index 000000000..f8d664043
--- /dev/null
+++ b/app/views/notifications/settings/_install.html.erb
@@ -0,0 +1,35 @@
+<% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %>
+
+
<%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.
+ <% case
+ when platform.edge? %>
+
+ - Click <%= icon_tag "install-edge", alt: "the app available - install Fizzy button" %>in the address bar.
+ - Click Install.
+
+ <% when platform.chrome? && platform.android? %>
+
+ - Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
+ - Tap Install app in the menu.
+
+ <% when platform.firefox? && platform.android? %>
+
+ - Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
+ - Tap Install in the menu.
+
+ <% when platform.safari? && platform.desktop? %>
+
+ - Click File in the top left.
+ - Click Add to Dock….
+
+ <% when (platform.safari? || platform.chrome?) && platform.ios? %>
+
To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.
+
+ - Tap <%= icon_tag "share", alt: "the share button" %>
+ - Tap Add to Home Screen.
+
+ <% else %>
+
Some platforms require you to install Fizzy as a web app to receive push notifications.
+ <% end %>
+
+<% end %>
diff --git a/app/views/notifications/settings/_system.html.erb b/app/views/notifications/settings/_system.html.erb
new file mode 100644
index 000000000..afe0efd45
--- /dev/null
+++ b/app/views/notifications/settings/_system.html.erb
@@ -0,0 +1,57 @@
+
+
Check your <%= platform.operating_system %> settings
+ <% case
+ when platform.firefox? && platform.android? %>
+
+ - Tap the <%= icon_tag "menu-dots-vertical.svg", alt: "More options" %> menu button.
+ - Tap Settings.
+ - Tap Notifications.
+ - Tap <%= icon_tag "switch", alt: "the toggle button" %> to Allow <%= platform.browser.capitalize %> notifications.
+
+ <% when platform.edge? && platform.desktop? %>
+
+ - Click Start, then Settings.
+ - Go to System > Notification.
+ - Click <%= icon_tag "switch", alt: "the toggle button" %> ON for Fizzy.
+
+ <% when (platform.firefox? || platform.chrome?) && platform.desktop? %>
+
+ <% if platform.windows? %>
+ - Click Start, then Settings.
+ - Go to System > Notification.
+ - Click <%= icon_tag "switch", alt: "the toggle button" %> ON for Fizzy.
+ <% else %>
+ - Click in the top left.
+ - Click System Settings….
+ - Click Notifications.
+ - Click Fizzy.
+ - Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
+ <% end %>
+
+ <% when platform.safari? && platform.desktop? %>
+
+ - Click in the top left.
+ - Click System Settings….
+ - Click Notifications.
+ - Click Fizzy.
+ - Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
+
+ <% when (platform.safari? || platform.chrome?) && platform.ios? %>
+
+ - Open the <%= icon_tag "gear", aria: { hidden: "true" } %> Settings app.
+ - Scroll to and tap Fizzy.
+ - Tap Notifications.
+ - Tap <%= icon_tag "switch", alt: "the allow notifications switch button" %> to Allow Notifications.
+
+ <% when platform.chrome? && platform.android? %>
+
+ - Open the <%= icon_tag "gear", aria: { hidden: "true" } %> Settings app.
+ - Tap Notifications.
+ - Tap App notifications.
+ - Scroll to Fizzy.
+ - Tap <%= icon_tag "switch", alt: "the switch" %> to Allow Notifications.
+
+ <% else %>
+
Ensure notifications are allowed for <%= platform.browser.capitalize %> in your system settings.
+ <% end %>
+
diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb
index 89ee03f45..74dd03731 100644
--- a/app/views/notifications/settings/show.html.erb
+++ b/app/views/notifications/settings/show.html.erb
@@ -12,6 +12,34 @@
<% end %>
-
+
+
Push notifications are ON
+
Push notifications are OFF
+
+
+
+
+
+
+
+ <%= icon_tag "lifebuoy" %>
+ Help me fix this
+ Not receiving notifications?
+
+
+
+
When push notifications aren’t working, this can usually be fixed by checking your notification settings to make sure they’re allowed.
+ <%= render partial: "notifications/settings/browser" %>
+ <%= render partial: "notifications/settings/system" %>
+ <%= render partial: "notifications/settings/install" %>
+
+
+
+
+
+ Collections
<%= render partial: "notifications/settings/collection", collection: @collections, locals: { user: Current.user } %>
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/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc
index 8eb55f4cd..21bf6f859 100644
--- a/config/credentials/beta.yml.enc
+++ b/config/credentials/beta.yml.enc
@@ -1 +1 @@
-yD+Wb/DYMXUXf16brcYmNb7OZngx7PuA6gLP8w6ZV7pA/GFweTIXQw67YoXebL6A7gWxhXX4tvpnrz9xPC1p5+5a8q00lOhcybc/480bSfenIXExDpg6mSDbQLRomrpwZsr+IqBWRC8WoXTtpVYRybEPRP88ZXdyE0MjnfOlGcr1fUcF3r9T8biXfNhS9v9UTxck1Q2yzGoNFRvBm3KgBN2QlJocG5AXaDYUSMfLoLmtkicO0KVOD7eYBxe0kDevUK7ZQGxbtATGIsjAJEifhuaPTj/Q2DH134TU4rlPr6n6vm9YEgC/CGVoqPIDgvovVd5IiqZlxGuVDDw3ct0cPH4/AnDLOVHiubi5/pZRCQyeOF+G1OmLVFUVeuZ6alPVrJSxHFxGVM/HT+pLWiReR/IJYacLzb3b2OOtBp/AKxbt25YxFFHuFHABfVEqPRo0qVwbQaxKZEyDyEW0t0ktTkqHywqkbmsx3HPU3skV42rTHf9c1udJhzTXZJPKIWKP96ahgX6eaOhy09rzwD99RnKLJuAuMl80Cw1L+ftLG5hM4tHYmv+JbpWDSInvP1jtA5iWtDRSiqhTxvXw4ItHximvsERI5k6hSPJzrzqnhLUxNVi10KQHh5KGq7EiC2J3HJwioY+fWVivzEJdw2WuVFsovcFyolrT3tyqcMVF5V3dQw4/gOGQ7gXsz0a9sb5wQgXJ9pyZBAmD4Bg7r4wD0f0JiL/5EdKOkAOTV2JDt0OgoSWzU+yuvByuWxPn2quoX0ZSG0huHvl2NK33RoR9aXLcSjtmi5XJGXpjATfKMoA2d8JBIDiJcOPAPwWkpI7AOnmvlV2SmrqPlLTkO0MfdJ528k8CFi2puSRQN+CEPhyhZclDO/J2IP94qmE35xAmWM7yjY6E+EZY9uhlF39uBhAIXylZwTyF31PSCqwpQJthH62pDFWpCgjy1iax482cCVt5n2HLQpmSNaPAvsI7hZAGFJsrDncwfJiK68YXrgmTeg8=--Gd2i+KrusH11oais--SraJ0tcCfEgIdRSJYoCoNw==
\ No newline at end of file
+hTAo532D++IfAePtvgvwjKd6Cx3RRCdoO+77b4yFvgZSGtEXB6mfNGKlEICRL4o0qh0lqimE2/3Sbmnx97lzhfdYXE3b09KewXIub14s1jzTRWzZSRPNcxPeYou10kiN0b/4R/IFON8+/P7Ratok1MHIrAt4M3EUYwtEpzsi3aOYDGlRoLW5rAxFAHRl7zhgkMH3Kzkmjh2tr+QOc5fxqmYT8ETmLJrOtvJtULMW4mfie722zmwm9RFJxtHpz4gnMAwSr2vaZ0edk0sHeErnDJo7Zl1Ul7uCJyXPP4PCqmuNVOQbxjLMNVTmo8ntUWZa7pvocpa5uYy8DIy3iCAYYIxdh/xhJPnqPC0XQnS6Aj4zaOD/fOXSnEuSQjR8d5XyIUAWuywh6Li7qWyj3W8btfmc1HWOo4cwx8LkwRuUG1r/SvMQdIR1E2xtgROaoXZmPOXmYlFJm3mzy+oGOk7Pa/u/1iiBiNEkDx4nhHe+0XuPZcBe4c9PKn3zw/3SOm32SpBqe6zPZI/68VoiXx5wVaogY5gnKQOHNkhi9RhN6SSrM58oS+EKYkCJ4+iRqj80IQNtj4in4pyQ/ONZgc0tjMh1+C342NkhbaJYmGGUPpPVhZPCUf3clbBXqex15+6UkmymWtz3WH6GixPVwrvp1gGUqOmqCiWVNJM/kQ84IOTzlxfeyKjGK5xKhEC3ajYqXDDUDek/tUIhqtOYxBS9T44cKDPDTL1BKFMdIDR7gdtRyQouW3RvD4juqyjh710I9aq3JB1Jvj8UZ7uSHYEJ9NpmllfAx0RrrxMFHcwr3LqjDlDf7mtACfDISVtD6TQLuGySDIhs8gWTiSuZ75jRTswx8y3paI17Yy0en23lDIQ9hlPh6xvvJWQQtCT6B8GCz56GrLOVDfKM/0qeqJtyed2p2/MIJfy+QmqTSMSeQz+wiLi14I2Vgy229A1CW4izMSoR+RqoZzIzCbZg29croJv9DRhp/nVtkjj/enHnHksZKqoxzf0c7nHSWtGSYtCiewDydsBCioKdo9tn/FM9UKpnRAy1XHiq1DvBQ+wuq74KLnC/4nBZ7P90xlI8jQuQfldpDRqKaIYjIvz8UbLdKOjETT1dJjZ9Tk629zdLEVybLSGhoE0bs460oFvlrjJDCXtgi/3tWbTIuwsxY6Iy3eMiS5Kxl42dnlOdVrxwwY/om/MzL4+yvsx38wXS2QG8lDKz2JuGBS4GYq5EEg==--P2KPkoocz8wNhqSN--1u5WuEBtEaqJckCflHkb6g==
\ No newline at end of file
diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc
index ce2cc3c0f..fbd189683 100644
--- a/config/credentials/development.yml.enc
+++ b/config/credentials/development.yml.enc
@@ -1 +1 @@
-hHi9VfqfZI57GYR5r75Rh00L3FYeoUgsdtCgBzJGdPhhGZLGlCgUAcY6tgVhqhgM/u5WxyPAzmoV+bbQKb0wO0U6KUmrDKDv9uRnNSUjXGYsg8gWdTUTfx7WBFOFawV3p1Dsm7zM1oUJbRWgy2TiMFl5kP9CN4Lk78n5a1/Y7mnx/wmzwUyQv+BUFJ0UKCfRmWieaNoLkm5kWwyepWHaSRU5EVda6J9VtuwP3hISIzncXm0jpr9r/tJm8flgUR3o2STIVHVgg+YcWz0j3st5RMv7i3D2OHfNGx84vsf3LNlsiLGB1ZU0IsmchLOSqPmlDK2oiB7UKepTPthGUFzNmcDG4LpUQfNwn7B8P7by0zadVefdLSB3kFCNLX+KCAbcjHPlWufkagwHo+dV+iIVACC+hfWl3L/IouqYS0ln2XCBDz6A8lI8PHeGaJe9UhfA5JEuJLRPui8znoq7LrKo4/oBB7ArKdHz/W/oHJA9+aLHcLOq9TPSExAsGi1wcjdgF0nk5mB9iX+az3//1zM64rRz8EyzNkBg1I6PV6wpzROLubttv1+I5nRVe4BernNfVJPi/hbvrlrcZmouU32oOzd2aZuPL5/H8gBhMOPlN25Rr51XCziaczgdEfOwTO8da6r7pxsJDkrvKi+Ju6efxhZ6GeotWJuEUtQtWhHnzvhcJAXfUtJf6A==--LWtZauLkWq+tD+4L--mkMcqYebO9TDi6RTRNUQvA==
\ No newline at end of file
+qGtDqUHZuqVpajiHSIgxtaKdNgBjWFZaKMWitxGBAtV/q/P0uVAzry+MRonSMglb8SZEW28/LAKF28kUeAMGZVwnbMHAmcmOdOHNwfPBDu/3+c67J0qedEH2k8998o7wBqDi7iFTaWlDQROOi8LiiE3oVnpsRZGXtjXgClYI5ED062ITCd/tMLDsXiZjq/PhodWjI/YgEycwy0r1OldJZjOCzS1VD924AfOIH2/qtszc7oeArEpv8XYm3DJDCixYy5LLnhNIek7o8CHaZIeQxfm9pEh5ZEXaMNfSB4Gq+laAyLoXqsoPWuonjw0273Rjw1U3PU+6GlU85ZUrm9dBnekZiatLIHt3yX1CKynS8yqen0ON+MrP5W98pAa9Njq2GtB2SUrGY1xQtyoIfYPxFbNZoOph4PubcXqCT94bkAbZOiYKSiNLONuhhvKR6r9amh+LlDELp7QLR8EIfRpgoN5D8qhbhEi1/c2Sxogv/7+YtkpaYq47Ram2THll+URQ3L4iLKyPIaimxt9vsqdO5COn69fTqtqkYR/fKy93/3JIJxiNQ8I6z5xhGRLFk9ADWB3ZVz1VzXzW0Q1B0UQmMg0KEEM9ZQTxignWeJasbG2L/T656Lg/wPdKdAw0jcZAOh+BGtePgOOjZ5Le8rweSHKou0kTn1EjR81csRuSnHEEGTdlNp34LC0NirqwEwmPySRrQxmNwslS82R8uQvZQ+XtalJBBvUVwaB+o2rm9AfZFzzjGmZhVOKCQLZx4ugG+CUfc+soLGuQtcyPQe+0V2hJ97zzqdWxKvdUQ/aCerBwUQczl7r/PPxKucl4LtF+3mNYNMWjIdNIHJRzhvCnmZ8Zj1TuO4SGWMKNx7kwNP54dd4pqRD6Z0vm/x8zZXeccfu8Y39IOlawyEDR7idd0CSZ--41mPjeBDVAaVR2Q8--Qfq5LzxnQOYGDaGie2K6FQ==
\ No newline at end of file
diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc
index e97e68755..0720c2059 100644
--- a/config/credentials/production.yml.enc
+++ b/config/credentials/production.yml.enc
@@ -1 +1 @@
-cPaSSbJ2XWIjNXDRi25iIH41V4zR1fKVTe3OV1XwZKsDE3950DL9Sk0RI/1bxYY+agT3x88vrsqm8xyqLwqcMeMg7J4el52WI4hORe5XLGJTcvs8CTgguut1s7c5Jb5ihOSPX9ZYEKnONxLU2BFe951WZgO+7Us2IBFbCQiqo8PXEBEONePuE9Aw2z1fUFW7NvxhptN9ws7DptP2O/wpHP8+KJmVuAJl+/qJHJoBh770dwQU3yCFz9mERowkARK4Z16Q+HelkrkvW9UziV53tMIExDRvfMnEKJz4wrU/AqVSiQIMQA6eVfTMQLxbVhBhLAlcV0Qic7yEbXLcAXwLX6Kga6aMOqyJvxh+JUnMpQavtHKikVncN8EI1lGxd/+mddS+khHZmCqkRvWl+LiaOe2WOkNENYXlGYANeRSTDy2BqMeT1ymAj3YxfWKYdR34Ukmm2iZKQ1d6fF+Dg/IT8/W/rIZPajQjMTO05WqeoE8kZ4lhgI7AxZHg9apHGsfkq6iBaf4kZUBFiLgOv0CR4Xz3zWVUHEyPx5afkkxs1u9NCspOaYWT4gA1tekFmaH7cMyhaQ/SEjOEs5AWyh47PeMBvwUumJKIEeil6p6K9109nSFG+BPszfhshY8G3aonuIbiWoJQAiGGnbvw4oZeAPjl8XNBYTWRspl2T9ep3krPg9L4eTrAyXdT0dG7GYXSa5NkrV86HugU5rDfx87N4QJVu6qr7Myzw6kEWlLRXHR6HPg+H7qZenNwuOfgnD3YoFOEWQfLiz9U3MqDRJIq5j+i2OfL4pv0VVpc51z42mWM1hvazGNiiTZvK0DohBja6D9h5Gl6ZP/eOGB8O6ZxIwn6mp23BIvxA5jBRGslCj/Op7msnkSAP0vtuI0hnCtfsOx0RfAPvj1/ETjVkRFhM7heWTgfqGQbjSVGvd/Dj6j3vbqGz3RASVL48juoWx/pezUMnWGZqRTqx5tD6FrZQy1yf/2Uoc8yMJQU14raqiShEqNveJswMv1D9kBnT3551DtpAmrMT+iuCX5aZHwVnY/sI8J8HdMAqFfpipMbCbccI/PgTc+U8uOqc+BBKYy8u04wHLnGu5wREewU5xRIkxcwGm4M4LkTLiVEJFUQuFEhylmxkCIDYG+WSBZ5xy/0dRUeZs26Uuvvs2//bXqn75Ia86Fpv54AzfdDqC9s0B1r5/ZqXtFjjL3bQyhv+SP5TMurAZcI0rLYay+oFcLZAv31zoo0dFcufiCKYTjbYEIlO+HW2aeVeOhUnCalqqslt4ql5A==--tVMd1KheRgH8F0gZ--x8Wzmk8JsMuHPG3JMOODLA==
\ No newline at end of file
+gJofgOA5kUBwjcFwc6IDRjLbYQfG7qGpm8BcAEnS0FODuMbEE48FKN4jdUfhGAw0yCHu9DT1cJiP2wKR8s7nyf6dUGl6fPljR0sKTvO0eznmWigWCc+Ki6zVef0mRqQ2lZguiokjapG9c4qBcy2MYhVOhw3JAYvH6X4piiyO52nLL/kKLEQ64/uqC9VDKgR8TRhyVRqQN2jvB62VnIGxWX/qSPcapF/jw8ECgPsXgOSY/s4oNvQ8gCuZMweDlSYqFLAXpn2GP+wAaT9u6UmprPx7niDb1EIIm+LFWU1Ar4lY2n5Mo9ckN0ddsoMnwlsYTjlAWnIn9m0o/GbaxWmtSScnxHXbzqr630rEffvZrzLTG60dxkI0it8cVXwSHI6pMQHZw/lUkHd13IHMzR9eNGUq4pV+fdQjhvcfS2sTwVeCZ6VV1DbnY1vOeyMnVfvFLlJ7eqChkpFBAmOKS4hV/Mrqks6OP7K6ubyXt/REwuNhRMeJ1SJAfgiDSKbmNFtSnODyXKP1nFTCvvdndiP1H6t8qkJo8COSnSWWzcyyVKMwEKwbOxxtX2C+V4aySTllw0gFODLjQPxPBwqnkwcEcDEHxtsgImOYnOc0Ro/4faI1C5WdCuF6+VOBGpq1nI4HCQqepx4b/HZt/EyJSaQU08emhHht2vlCqJUN2yPUxnbCKBAbQhz0khRANBWIf8q0wfwSLaAqQHJ9FsZp4ztOC5U4lULzaMJh7dBUbv/9+mH6Yb3lFbg57178GYCgbzFwhvEOlEKBjfwDRFNmYfiVH4YYbZTIgQvu3tSywNNQJ4flu6TSjMC6zdOBi41BAIXH7j04zwpQV7Zndlu3xE1LTMbcLfBdqzOBGuRVD5KvQH5XFj0RfKTg2DN7Mo2XwSb7IZnl3Ga/Tuvb7DwKpRtql2RCYIYXwg5EnCYF3rw8J9m4tLzr9AFh3KALq+Lnfa0d1MysB1NQJ5XqtJ3jeYW+sxv4ivv9gpzQ9SZF26Z3rZoQHnb2S7usIgzEquiDyhAQr67AYOMJe5DKRw/QShJwYK+k+n/IrpQxUBnTrZvf2I47KF6UkhgWpZgmQaDB7WR1PePHVqJdkdQgqQzUU2t0SQIUY0FHH6MRLyHDcqOFiPl76HNFOmElZzrfXzqtwQdXOau7hV645LqaUy2xinA97zkEdpAGr/P/eloveh6wIgSIZNTsm8RbgRkl8SS1O9Q9goxMir4S3JvjWTJmAhxKaKlGeIVcMRVIv5C634Ugpp5jzh8PHX9nHAN2w9HHI+8AMaBUeFyuixbfk88EIvby72wY8MWxYzv1H/THWSL8WTiQJGxWE4PjSBQtwuc1zzHqG8Ve45Z1nIkt4TmC1VXftUHj5SghncAHCA27v+Hdk+cjSw/ymaVwIRreaZYMjOs2xX8XGOYXpbChFJaMk7ywR3FyBzOVvFXGnGCuA55f+j/7E2KuBI4lnr/mmCTeYvLOBrBDd2c1sEjKTURM4cSlDBw9NKc8oloxYZQKn9vA--z6fcV0Ojv6v39SCA--yOvnjjoQdp6k7P89GMEgYw==
\ No newline at end of file
diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc
index ef3fa3ffb..b9f413f86 100644
--- a/config/credentials/staging.yml.enc
+++ b/config/credentials/staging.yml.enc
@@ -1 +1 @@
-UV9AzQwwjGoWFh78GyKkNewL4lmsL80XT+6ca0JIxZ632OMKbYzHbPtJAD6RK1gaEB4GRkiBE2mLEdk8nYI47p8K9NVqZuI/ykgl23Sr2JzBpnHfjtUYlPQC7nYyeAR3gYtASD5+jSXvP7z/9BquIVdTvih3hWPPujDK5OROXagDtlGTZ4SgKZwihCTZczTwZRLxx5UguwbtuowrtcuPTg+r/H+zdJyRUU9kF7vYqV5VrP0vj0mXZUf5hWNBpKvhDtqgKUJNU87ds7xTLwMuQT0MCASoJXS0JSpIKn653baXczQue6JyuFcOlqDPDmBWs2GLh6pq3XnNolmWoyNehD9WClmJ5cu0l1dPmYZ0WnOfPgJCPtuJI4onIUj2nLC5m0tHpQPNs9WjazQeCeiw7TJBESK3b3lB4/Q7STshfJd74IIJ0crzfm0a6MwxvT184etKpNRQkgJpwK6WRNPzzzHdlTmbkEher4ilcs3QrmyLPYaLcuG+0YyDcjgJXRvLumkZilONbuBHluMc64u4GAU8QyW1dp31DrCw/HTOTtPEsRJSKdTIiFFCDpYt0Uq16Qbp9Hsg1ruPqFPELXDqRSl+N9ixDdXjF8WyQc6Gx4i8Fsur0WBLGBEtCEkCCQvwsH+TVH47S68CSiUui0c2zX1sM2SEcOOL7Cn0TUDWg6d8MgHQ5977aGLGFUkeuldv8KfQSra5uHKESSxOwn/eJJQMERSiVHSEj1EK3v4R+shXnviPsFTr3uERjdn2s1CcSk3qJ7J5WFpmgTL3/qKRWDroI96QO24CSr8JWJ3MzrwkLLpiEf6qLyfswiRjJ79sxk1pTdx7zedb8MuG/596J/xFI7GdbOI6qr2NvVUHH6r6PqXltMBoT/JU4pO7QOXfuok8zYGOj2gMzktCOdvEoydh+8QrsKN6W3mpg8oweQoq/2tgRm/YTeJMz+MQMR+J+aid2lSu8/wiHtsv8WdxJ7qxI7G3DslzbTEz0e3FAP+CEFOVgwN+nitWZpSyst6WiupzU5Ou9NxYiWbmhy5nhDJFC+MiLs8bS2kWfq3z1/Wsuyp8hBn/T0+MYtQ/cRnAxTE0sUZMB5xpffuGPlx3lgrmxV7wlPAzGooCGkGVUQQ29ljYMoGWqW34EAniIYHLUnBfdS+bNbui5rxVEAFyD4r57eqEaYYOKCFwkhNfYhHUeV+noDQ8uvCzppvRHGKJrkUCS0/A6z4KP8iW8wsETMBX/ShvFf6m44KNGR4IPzfn+r7uO9Pbukit3RJ8RsHZcC3RmH7/gtO3vW8G1BBrT5nI6BdRp9a9LdTx2qn32cKQD7Wc7S1KyFYn5reWSewlxNHHmgy4R/pEXTtdZJ5tuzweeYK/jr78CTrgEEN/btNawNh6SdTwRls5lu33LAQ=--3mgI5AzJFOeaM6Y1--KgIowEbtW/iHXiDrWGk2ug==
\ No newline at end of file
+DHJSW4c/xFkMWWYW3b1uqnoiwuqVvRabPVvSKZWaoM+EPzUses5EaJ7GBXjpVS/4TYQ+0XsuLBTniuhUtFx56PHo1vWWFRy3n8I1YGPOs2X9mvG2wK5MlwSGO55xatQ1MabRyfjVJfW/inx4oWaqh4IsZhQ5uL/PUWwWy+7tKx3yWbPDa9K2vsgJuJJqZav0gJ7Bn5j8sdpj8xRofA0ZmiA3QxVIHOLiBYRXH9gmuzJ9BcDiAfZ1EA2FnM7T/2v5u6K4k5Lkp4Khn7t5kSF5hrUIuU7dWFaz4WFS6sfcCkRTHgPKu1ZfunzQl9vDXxkwVUjnfGnIk6XUQz8qfI1sDwhxjaiKAtn/6V8/zSo0v9lj91kfVWg/e8byzDxtZhRN71lnDgwaRIQxSDumfFIX2Yd5aXoQw7b+VymZaA+8J7vh18GPkBSsWTZ9b1fA1iVHncSDSEYH/FVXK3Ul/mpcN1bJrlBXMkZ0HmDA1EXhMJqPGosE6/OT5f1Zsn5SSpbqM6VZjQ6LOHwNCZs24F8BhGnCQtdl/VlIHKyGq8/zcyWUuROAVSxV9fiMDddWrprjDsjXPUigFqgtyco1CCZ9p6ScMihOzpOhT0dcvvjKZY56e3GR6n7u5JfSL9CpykQOXATHBXJlAc3HYxPxfESkEa31VmwZGOZxUsTpIZDLC/7VCHXGgtvOYLfoPBJBZ7BP8Eun+q/hz4iAr40xlfSeg3G18JpQZYHeF2/3WucrTW8NinwkPW/Yr/jtd9duyuIZNY2/zGyLiyoKiuHnk/iVDsyLIth1P7ZoQNyyiITzB/OQ2KqAvqwjnNYqLfBAQfe6grBdAGrNInsBHc7HNMcuHP9bvvS2cjlepP4zZyKo5oiAkmaWUPuBkUqmY6ZcOmwLTwl//fBXlUzCuUhR+0k7af6IlCsDhmp0HL575JmYN+HKGZ2LtQo+1v4MFJR5c2gh0Q4zTAkJsaOz+39aI5e2VPXkpXNyz+VVFJO36lg9tpaomUgtI2BjrO3Y3qQ8Y5jilnSX2HslL4lFMLDvyphDYJx7ODPimUjNkJ616YXSgFJSBUVa/ui1CnxNqOfO+AtuxJz26cuQw8ORKZNihd0QT276pHGhnXb+B0JT+XCuniZnSF+oCn6Kmbt1bsElCN/sMrII7UdsRGD5sQWicjpOM0g50GPYF9z6v8tCYlEmTQSjp7/KOwKLo4l4re3KeuD4FuK/1n0x2CNm1MLbvXcOGkmCa3P79YsKl8L1x8+hbNoHpEpswc0dPb6FLthCxCqrGQiREO4pbTrLasVL6CJA0eRd39B/8AcXnsJwNj4B3a2PbafnkdhJMPAuXvieFFN6leMXBF0YCsg+1Br8h9YmZfzmkECo9JVkNCfYdNbkfo8AA+hHu/youi6vcJD+x+eKTVdi88jy9qwyDdzIf8GdiIHy00PnfXj4lgTx7SQr+WUpsUYnQZh8xqkAuh5Lgi7ExqZMjjo4FORFxoJHToNU6c44+aVb36P9Fg6v3YedINYZkRWMTUq/f9O1zoOx3wIE10Qk4SkF+MLPSuLJWbEmfCyNZZwFUH7xde9lSDfB1s+1XhtEz0Li3Kq8jCeftRcIZvQ3LVjFuirf6nfZmZoTjaVDyUiWHtXyrQ==--NSFlvZtAfCQmzAp3--o8XatBC+Yh/p55fY485A7g==
\ No newline at end of file
diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc
index 8c7aab16b..53a7eea96 100644
--- a/config/credentials/test.yml.enc
+++ b/config/credentials/test.yml.enc
@@ -1 +1 @@
-0EWG0dpq/qELmTCqAgzEL2BZH7JCNFe6DDIynyKE06qiBCYhHs5+fMjmpzN0uwz43tqAAKJdhO/hZY4jXmTpxu+LSjwrUKAlxsWvSGu4vHEbJ5Fe+BzhnS84OFse6ma9akUAHaed3gt31wtrt2Y4AbSPedNvriqfYln7cvpgWbvjTgtEbHrcNMfjCBnaXXdO3n1zILunILgLiV7oBW0qurIrl2xOc4mbj4AqVAIElyh0md9fFtuBfsaRj2U9x/+7McbgTFODhHfeqoErEw7fc1RBFrNi2YNuHBSSqegQ6WtWbwq1Yg2Juv/Lw3g4IIdWadJsW3TdTMMiwmBMzOikx3lNX1Z399glvGNFVnJfBubKf6BxvFfAQ85PBzOZPl+/PTs=--GY44JMs5ZO1/eL6G--EhxEWuitiex6eHROpPQamw==
\ No newline at end of file
+UFzTQdDbGY5BJ/SHVPWzNlBZoO2+BF0eHHwGICuSB5pi8ESFOHrGhmT7a9qpKLl8cbBMTPPayqLEuuuRbbtXEBS3EiDgAP7XaDViQHHAzBwgaATFT7U+1svnAwppTrAToXJlk51BdPLVRzftY9VZJ9RoDTfzKo0WriuYZ8KIRHmJjaiNDJK4xnqKaqPpF6SU0OVW2Qpo6xAKJVYKlV6SEO2Nt6C2lYra02G49f7poBypJnqouDnn+zlrLd2VGfDXOqFdi+R8T+ZS4HIzHUPi2IpEyg9jFt7qCNpSX7gy2NPZEx2mQmLC0jf5E+ew5ytKqxX1HnoJ7u8IMFvM9gQuNyA1+gaWT42uAqhUB9JKMoyE25KOSnV548cOfxCLnRNb0TgIbp5hpl66yjG32bQkyrvkCRmLBtkii/aGiwpA63UP2DTy0C/Nn86PFXJslHnVxbm8iSCRbhlu91YbmG08pzyCZQe9nRhIX2QBPufIELaJiMcFKYGfQ4Hs30TVCQ3ATdnC1UfWa7BdUsMD92ckneSrexaNofrH4WaRxyIf4VMtfRXqVgv9ovv5I4sr5BwYrPN5WvS3ksvAaoOJ/EXeq+io5OMB2ItzPJucfw==--q/sQOAAr2yruQ50H--dPendfhTyJuxPPImcH2Vqg==
\ No newline at end of file
diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb
new file mode 100644
index 000000000..2739f6ca1
--- /dev/null
+++ b/config/initializers/vapid.rb
@@ -0,0 +1,4 @@
+Rails.application.configure do
+ config.x.vapid.private_key = ENV.fetch("VAPID_PRIVATE_KEY", Rails.application.credentials.dig(:vapid, :private_key))
+ config.x.vapid.public_key = ENV.fetch("VAPID_PUBLIC_KEY", Rails.application.credentials.dig(:vapid, :public_key))
+end
diff --git a/config/initializers/web_push.rb b/config/initializers/web_push.rb
new file mode 100644
index 000000000..ac960468e
--- /dev/null
+++ b/config/initializers/web_push.rb
@@ -0,0 +1,44 @@
+require "web-push"
+require "web_push/pool"
+require "web_push/notification"
+
+Rails.application.configure do
+ config.x.web_push_pool = WebPush::Pool.new(
+ invalid_subscription_handler: ->(subscription_id) do
+ Rails.application.executor.wrap do
+ Rails.logger.info "Destroying push subscription: #{subscription_id}"
+ Push::Subscription.find_by(id: subscription_id)&.destroy
+ end
+ end
+ )
+
+ at_exit { config.x.web_push_pool.shutdown }
+end
+
+module WebPush::PersistentRequest
+ def perform
+ if @options[:connection]
+ http = @options[:connection]
+ else
+ http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
+ http.use_ssl = true
+ http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
+ http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
+ http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
+ end
+
+ req = Net::HTTP::Post.new(uri.request_uri, headers)
+ req.body = body
+
+ if http.is_a?(Net::HTTP::Persistent)
+ response = http.request uri, req
+ else
+ resp = http.request(req)
+ verify_response(resp)
+ end
+
+ resp
+ end
+end
+
+WebPush::Request.prepend WebPush::PersistentRequest
diff --git a/config/routes.rb b/config/routes.rb
index 6b61d0716..1d342ada7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
@@ -175,6 +176,7 @@ Rails.application.routes.draw do
get "up", to: "rails/health#show", as: :rails_health_check
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
+ get "service-worker" => "pwa#service_worker"
root "events#index"
diff --git a/db/migrate/20250714222221_add_push_subscriptions.rb b/db/migrate/20250714222221_add_push_subscriptions.rb
new file mode 100644
index 000000000..486d975ea
--- /dev/null
+++ b/db/migrate/20250714222221_add_push_subscriptions.rb
@@ -0,0 +1,17 @@
+class AddPushSubscriptions < ActiveRecord::Migration[8.1]
+ def change
+ create_table :push_subscriptions do |t|
+ t.references :user, null: false, foreign_key: true
+ t.string :endpoint
+ t.string :p256dh_key
+ t.string :auth_key
+ t.string :user_agent
+ t.timestamps
+
+ t.index [ :user_id, :endpoint ], unique: true
+ t.index :endpoint
+ t.index :user_agent
+ t.index [ "endpoint", "p256dh_key", "auth_key" ]
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 03cc51766..7c5a63f08 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -307,6 +307,21 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_22_132226) do
t.index ["user_id"], name: "index_pins_on_user_id"
end
+ create_table "push_subscriptions", force: :cascade do |t|
+ t.string "auth_key"
+ t.datetime "created_at", null: false
+ t.string "endpoint"
+ t.string "p256dh_key"
+ t.datetime "updated_at", null: false
+ t.string "user_agent"
+ t.integer "user_id", null: false
+ t.index ["endpoint", "p256dh_key", "auth_key"], name: "idx_on_endpoint_p256dh_key_auth_key_7553014576"
+ t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint"
+ t.index ["user_agent"], name: "index_push_subscriptions_on_user_agent"
+ t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true
+ t.index ["user_id"], name: "index_push_subscriptions_on_user_id"
+ end
+
create_table "reactions", force: :cascade do |t|
t.integer "comment_id", null: false
t.string "content", limit: 16, null: false
@@ -424,6 +439,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_22_132226) do
add_foreign_key "notifications", "users", column: "creator_id"
add_foreign_key "pins", "cards"
add_foreign_key "pins", "users"
+ add_foreign_key "push_subscriptions", "users"
add_foreign_key "search_queries", "users"
add_foreign_key "sessions", "users"
add_foreign_key "steps", "cards"
diff --git a/db/schema_cache.yml b/db/schema_cache.yml
index a90d62239..09bb886c7 100644
--- a/db/schema_cache.yml
+++ b/db/schema_cache.yml
@@ -532,11 +532,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
- cast_type: &30 !ruby/object:ActiveModel::Type::Boolean
+ cast_type: &31 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
- sql_type_metadata: &31 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
+ sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -858,6 +858,51 @@ columns:
- *6
- *9
- *25
+ push_subscriptions:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: auth_key
+ cast_type: *7
+ sql_type_metadata: *8
+ 'null': true
+ default:
+ default_function:
+ collation:
+ comment:
+ - *5
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: endpoint
+ cast_type: *7
+ sql_type_metadata: *8
+ 'null': true
+ default:
+ default_function:
+ collation:
+ comment:
+ - *6
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: p256dh_key
+ cast_type: *7
+ sql_type_metadata: *8
+ 'null': true
+ default:
+ default_function:
+ collation:
+ comment:
+ - *9
+ - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: user_agent
+ cast_type: *7
+ sql_type_metadata: *8
+ 'null': true
+ default:
+ default_function:
+ collation:
+ comment:
+ - *25
reactions:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -956,24 +1001,15 @@ columns:
collation:
comment:
- *9
- - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- auto_increment:
- name: user_agent
- cast_type: *7
- sql_type_metadata: *8
- 'null': true
- default:
- default_function:
- collation:
- comment:
+ - *30
- *25
steps:
- *22
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: completed
- cast_type: *30
- sql_type_metadata: *31
+ cast_type: *31
+ sql_type_metadata: *32
'null': false
default: false
default_function:
@@ -998,8 +1034,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
- cast_type: *30
- sql_type_metadata: *31
+ cast_type: *31
+ sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -1058,8 +1094,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
- cast_type: *30
- sql_type_metadata: *31
+ cast_type: *31
+ sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -1128,6 +1164,7 @@ primary_keys:
mentions: id
notifications: id
pins: id
+ push_subscriptions: id
reactions: id
schema_migrations: version
search_queries: id
@@ -1173,6 +1210,7 @@ data_sources:
mentions: true
notifications: true
pins: true
+ push_subscriptions: true
reactions: true
schema_migrations: true
search_queries: true
@@ -2269,6 +2307,90 @@ indexes:
nulls_not_distinct:
comment:
valid: true
+ push_subscriptions:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: push_subscriptions
+ name: idx_on_endpoint_p256dh_key_auth_key_7553014576
+ unique: false
+ columns:
+ - endpoint
+ - p256dh_key
+ - auth_key
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: push_subscriptions
+ name: index_push_subscriptions_on_endpoint
+ unique: false
+ columns:
+ - endpoint
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: push_subscriptions
+ name: index_push_subscriptions_on_user_agent
+ unique: false
+ columns:
+ - user_agent
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: push_subscriptions
+ name: index_push_subscriptions_on_user_id
+ unique: false
+ columns:
+ - user_id
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: push_subscriptions
+ name: index_push_subscriptions_on_user_id_and_endpoint
+ unique: true
+ columns:
+ - user_id
+ - endpoint
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
reactions:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: reactions
diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb
new file mode 100644
index 000000000..891f53799
--- /dev/null
+++ b/lib/web_push/notification.rb
@@ -0,0 +1,29 @@
+class WebPush::Notification
+ def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:)
+ @title, @body, @path, @badge = title, body, path, badge
+ @endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key
+ end
+
+ def deliver(connection: nil)
+ WebPush.payload_send \
+ message: encoded_message,
+ endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key,
+ vapid: vapid_identification,
+ connection: connection,
+ urgency: "high"
+ end
+
+ private
+ def vapid_identification
+ { subject: "mailto:support@37signals.com" }.merge \
+ Rails.configuration.x.vapid.symbolize_keys
+ end
+
+ def encoded_message
+ JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } }
+ end
+
+ def icon_path
+ "/apple-touch-icon.png"
+ end
+end
diff --git a/lib/web_push/pool.rb b/lib/web_push/pool.rb
new file mode 100644
index 000000000..2a0533ae3
--- /dev/null
+++ b/lib/web_push/pool.rb
@@ -0,0 +1,56 @@
+# This is in lib so we can use it in a thread pool without the Rails executor
+class WebPush::Pool
+ attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler
+
+ def initialize(invalid_subscription_handler:)
+ @delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000)
+ @invalidation_pool = Concurrent::FixedThreadPool.new(1)
+ @connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150)
+ @invalid_subscription_handler = invalid_subscription_handler
+ end
+
+ def queue(payload, subscriptions)
+ subscriptions.find_each do |subscription|
+ deliver_later(payload, subscription)
+ end
+ end
+
+ def shutdown
+ connection.shutdown
+ shutdown_pool(delivery_pool)
+ shutdown_pool(invalidation_pool)
+ end
+
+ private
+ def deliver_later(payload, subscription)
+ # Ensure any AR operations happen before we post to the thread pool
+ notification = subscription.notification(**payload)
+ subscription_id = subscription.id
+
+ delivery_pool.post do
+ deliver(notification, subscription_id)
+ rescue Exception => e
+ Rails.logger.error "Error in WebPush::Pool.deliver: #{e.class} #{e.message}"
+ end
+ rescue Concurrent::RejectedExecutionError
+ end
+
+ def deliver(notification, id)
+ notification.deliver(connection: connection)
+ rescue WebPush::ExpiredSubscription, OpenSSL::OpenSSLError => ex
+ invalidate_subscription_later(id) if invalid_subscription_handler
+ end
+
+ def invalidate_subscription_later(id)
+ invalidation_pool.post do
+ invalid_subscription_handler.call(id)
+ rescue Exception => e
+ Rails.logger.error "Error in WebPush::Pool.invalid_subscription_handler: #{e.class} #{e.message}"
+ end
+ end
+
+ def shutdown_pool(pool)
+ pool.shutdown
+ pool.kill unless pool.wait_for_termination(1)
+ end
+end
diff --git a/test/controllers/users/push_subscriptions_controller_test.rb b/test/controllers/users/push_subscriptions_controller_test.rb
new file mode 100644
index 000000000..9c77dec9d
--- /dev/null
+++ b/test/controllers/users/push_subscriptions_controller_test.rb
@@ -0,0 +1,50 @@
+require "test_helper"
+
+class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :david
+ end
+
+ test "create new push subscription" do
+ subscription_params = { "endpoint" => "https://apple", "p256dh_key" => "123", "auth_key" => "456" }
+
+ post user_push_subscriptions_path(users(:david)),
+ params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" }
+
+ assert_response :ok
+
+ assert_equal subscription_params, users(:david).push_subscriptions.last.attributes.slice("endpoint", "p256dh_key", "auth_key")
+ assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent
+ end
+
+ test "touch existing subscription" do
+ existing_subscription = users(:david).push_subscriptions.create!(
+ endpoint: "https://apple",
+ p256dh_key: "123",
+ auth_key: "456"
+ )
+
+ assert_no_difference -> { users(:david).push_subscriptions.count } do
+ assert_changes -> { existing_subscription.reload.updated_at } do
+ post user_push_subscriptions_path(users(:david)), params: {
+ push_subscription: existing_subscription.attributes.slice("endpoint", "p256dh_key", "auth_key")
+ }
+ end
+ end
+
+ assert_response :ok
+ end
+
+ test "destroy a push subscription" do
+ subscription = users(:david).push_subscriptions.create!(
+ endpoint: "https://apple",
+ p256dh_key: "123",
+ auth_key: "456"
+ )
+
+ assert_difference -> { Push::Subscription.count }, -1 do
+ delete user_push_subscription_path(users(:david), subscription)
+ assert_redirected_to user_push_subscriptions_path(users(:david))
+ end
+ end
+end