Merge pull request #781 from basecamp/web-push

Web push
This commit is contained in:
Jason Zimdars
2025-07-23 12:58:38 -05:00
committed by GitHub
43 changed files with 1051 additions and 27 deletions
+2
View File
@@ -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"
+11 -3
View File
@@ -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
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg enable-background="new 0 0 22 22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="m20.3 4.7h-2.8c-.1 0-.3-.1-.3-.3v-2.7c0-.4-.4-.8-.8-.8s-.8.4-.8.8v2.8c0 .1-.1.3-.3.3h-2.8c-.4 0-.8.4-.8.8s.4.8.8.8h2.8c.1 0 .3.1.3.3v2.8c0 .4.4.8.8.8s.8-.4.8-.8v-3c0-.1.1-.3.3-.3h2.8c.4 0 .8-.4.8-.8 0-.3-.3-.7-.8-.7z"/><path d="m8.2 1.6c.4 0 .8.3.8.8v5.8c0 .4-.3.8-.8.8h-5.9c-.4 0-.8-.3-.8-.8v-5.9c0-.4.3-.8.8-.8h5.9m0-1.5h-5.9c-1.2 0-2.3 1.1-2.3 2.3v5.8c0 1.3 1.1 2.3 2.3 2.3h5.8c1.3 0 2.3-1.1 2.3-2.3v-5.8c.1-1.2-1-2.3-2.2-2.3z"/><path d="m8.2 13.1c.4 0 .8.3.8.8v5.8c0 .4-.3.8-.8.8h-5.9c-.4 0-.8-.3-.8-.8v-5.8c0-.4.3-.8.8-.8zm0-1.6h-5.9c-1.3 0-2.3 1.1-2.3 2.3v5.8c0 1.3 1.1 2.4 2.3 2.4h5.8c1.3 0 2.3-1.1 2.3-2.3v-5.8c.1-1.3-1-2.4-2.2-2.4z"/><path d="m19.7 13.1c.4 0 .8.3.8.8v5.8c0 .4-.3.8-.8.8h-5.8c-.4 0-.8-.3-.8-.8v-5.8c0-.4.3-.8.8-.8zm0-1.6h-5.8c-1.3 0-2.3 1.1-2.3 2.3v5.8c0 1.3 1.1 2.3 2.3 2.3h5.8c1.3 0 2.3-1.1 2.3-2.3v-5.8c0-1.2-1.1-2.3-2.3-2.3z"/></svg>

After

Width:  |  Height:  |  Size: 965 B

+1
View File
@@ -0,0 +1 @@
<svg enable-background="new 0 0 24 24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m20.3 16.9c0 .1-.1.2-.2.2h-16.1c-.1 0-.2-.1-.2-.2v-9.8c-.1-.1 0-.2.2-.2h5.8c0-.1 0-.2 0-.3 0-.6.1-1.1.2-1.7h-6.3c-1.1 0-2 .9-2 2v11.7c0 1.1.9 2 2 2h7.1c.1 0 .2.1.2.2v1c0 .1-.1.2-.2.2h-4.9c-.5 0-1 .4-1 1 0 .5.4 1 1 1h12.2c.5 0 1-.4 1-1 0-.5-.4-1-1-1h-4.9c-.1 0-.2-.1-.2-.2v-1c0-.1.1-.2.2-.2h7.1c1.1 0 2-.9 2-2v-5.8c-.6.5-1.2.8-2 1.1z"/><path d="m17.5.1c-3 0-5.6 2.1-6.3 4.9-.1.4-.2 1-.2 1.6v.3c.2 3.4 3 6.2 6.5 6.2 1 0 2-.2 2.8-.6.7-.4 1.4-.8 2-1.4 1-1.3 1.7-2.8 1.7-4.5 0-3.6-2.9-6.5-6.5-6.5zm2.4 7.3-2 2.5c-.2.2-.5.3-.7.1 0 0-.1 0-.1-.1l-2-2.5c-.2-.2-.1-.5.1-.7.1-.1.2-.1.3-.1h.8c.1 0 .2-.1.2-.2v-2.8c0-.6.4-1 1-1s1 .4 1 1v2.8c0 .1.1.2.2.2h.8c.3 0 .5.2.5.5 0 .1 0 .2-.1.3z"/></svg>

After

Width:  |  Height:  |  Size: 785 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m19.79 15.41c.95-2.17.95-4.66 0-6.82l-2.74 1.24c.6 1.38.6 2.95.01 4.34zm-4.37-11.2c-2.17-.95-4.66-.95-6.83 0l1.24 2.73c1.39-.59 2.96-.59 4.35.01zm-11.21 4.37c-.95 2.18-.95 4.66 0 6.84l2.74-1.25c-.6-1.38-.6-2.96 0-4.35zm4.38 11.21c2.17.95 4.66.95 6.83-.01l-1.24-2.73c-1.38.6-2.96.6-4.34.01zm3.41-17.79a10 10 0 0 1 10 10 10 10 0 0 1 -10 10 10 10 0 0 1 -10-10 10 10 0 0 1 10-10m0 6a4 4 0 0 0 -4 4 4 4 0 0 0 4 4 4 4 0 0 0 4-4 4 4 0 0 0 -4-4z"/></svg>

After

Width:  |  Height:  |  Size: 515 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m8 13c-1.86 0-3.41 1.28-3.86 3h-2.14v2h2.14c.45 1.72 2 3 3.86 3s3.41-1.28 3.86-3h10.14v-2h-10.14c-.45-1.72-2-3-3.86-3m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2m11.86-13c-.45-1.72-2-3-3.86-3s-3.41 1.28-3.86 3h-10.14v2h10.14c.45 1.72 2 3 3.86 3s3.41-1.28 3.86-3h2.14v-2zm-3.86 3c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>

After

Width:  |  Height:  |  Size: 403 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17 6H7c-3.31 0-6 2.69-6 6s2.69 6 6 6h10c3.31 0 6-2.69 6-6s-2.69-6-6-6zm0 10H7c-2.21 0-4-1.79-4-4s1.79-4 4-4h10c2.21 0 4 1.79 4 4s-1.79 4-4 4zM7 9c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" /></svg>

After

Width:  |  Height:  |  Size: 278 B

+7
View File
@@ -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 "); }
+48
View File
@@ -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);
}
}
+14
View File
@@ -0,0 +1,14 @@
/* PWA install */
.pwa__instructions {
@media (display-mode: standalone) {
display: none;
}
}
.pwa__installer {
display: none;
.pwa--can-install & {
display: block;
}
}
+12
View File
@@ -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;
}
+8
View File
@@ -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
@@ -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,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
}
}
+7
View File
@@ -0,0 +1,7 @@
class PushNotificationJob < ApplicationJob
queue_as :default
def perform(notification)
NotificationPusher.new(notification).push
end
end
+25
View File
@@ -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
+12
View File
@@ -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
+2
View File
@@ -1,4 +1,6 @@
class Notification < ApplicationRecord
include PushNotifiable
belongs_to :user
belongs_to :creator, class_name: "User"
belongs_to :source, polymorphic: true
+122
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
module Push
def self.table_name_prefix
"push_"
end
end
+7
View File
@@ -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
+1
View File
@@ -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 }
+1
View File
@@ -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 %>
@@ -0,0 +1,109 @@
<% unless (platform.safari? || platform.chrome?) && platform.ios? %>
<div class="notifications-help" data-notifications-target="details">
<% case
when platform.firefox? && platform.android? %>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<ol>
<li>Tap <em><%= icon_tag "lock", alt: "the View site information button" %></em> in the address bar.</li>
<li>Tap <em>Notification</em> to change to <em>Allowed</em>.</li>
</ol>
<% when platform.edge? && platform.desktop? %>
<h2>Turn on notifications for this website.</h2>
<ol>
<li>Click <em><%= icon_tag "lock", alt: "the View site information button" %></em> left of the address bar.</li>
<li>Under <em>Permissions for this site &gt; Notifications</em>, choose <em>Allow</em>.</li>
</ol>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<ol>
<% if platform.windows? %>
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
<li>Go to <em>System &gt; Notification</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
<% else %>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= platform.browser.capitalize %></em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
<% end %>
</ol>
<% when platform.firefox? && platform.desktop? %>
<h2>Turn on notifications for this website.</h2>
<ol>
<li>Click <em><%= platform.browser.capitalize %></em> in the top left.</li>
<li>Click <em>Settings…</em>.</li>
<li>Click <em>Privacy & Security</em> in the sidebar.</li>
<li>Scroll down to <em>Permissions</em>.</li>
<li>Click <em>Settings</em> next to <em>Notifications</em>.</li>
<li>Select <em>Allow</em> next to <em><%= root_url %></em>.</li>
</ol>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<ol>
<% if platform.windows? %>
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
<li>Go to <em>System &gt; Notification</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the toggle button" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
<% else %>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= platform.browser.capitalize %></em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
<% end %>
</ol>
<% when platform.chrome? && platform.desktop? %>
<h2>Turn on notifications for this website.</h2>
<ol>
<li>Click the <em><%= icon_tag "sliders", alt: "View site information" %></em> icon in the address bar.</li>
<li>Click <em>Site Settings</em>.</li>
<li>Ensure notifications are <em>Allowed</em>.</li>
</ol>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<ol>
<% if platform.windows? %>
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
<li>Go to <em>System &gt; Notification</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
<% else %>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= platform.browser.capitalize %></em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
<% end %>
</ol>
<% when platform.chrome? && platform.android? %>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<ol>
<li>Tap the <em><%= icon_tag "menu-dots-vertical", alt: "More options" %></em> menu button.</li>
<li>Tap <em>Settings</em>.</li>
<li>Tap <em>Notifications</em>.</li>
<li>Tap <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow <%= platform.browser.capitalize %> notifications</em>.</li>
<li>Tap <em><%= icon_tag "switch", alt: "the switch" %></em> next to <em>Web apps</em>.</li>
<li>Tap <em><%= icon_tag "notification-bell-alert", alt: "the notification bell" %></em> and select <em>Allow</em>.</li>
</ol>
<% when platform.safari? && platform.desktop? %>
<h2>Turn on notifications for this website.</h2>
<ol>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= request.base_url %></em> in the list.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
</ol>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<ol>
<li>Click <em><%= platform.browser.capitalize %></em> in the top left.</li>
<li>Click <em>Settings…</em>.</li>
<li>Click the <em>Websites</em> tab.</li>
<li>Click <em>Notifications</em> in the sidebar.</li>
<li>Click <em><%= request.base_url %></em> in the list.</li>
<li>Select <em>Allow</em>.</li>
</ol>
<% else %>
<p>Ensure notifications are enabled for <em><%= root_url %></em> in your web browser settings.</p>
<% end %>
</div>
<% end %>
@@ -0,0 +1,35 @@
<% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %>
<div class="notifications-help pwa__instructions hide-in-pwa">
<h2><%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.</h2>
<% case
when platform.edge? %>
<ol>
<li>Click <em><%= icon_tag "install-edge", alt: "the app available - install Fizzy button" %></em>in the address bar.</li>
<li>Click <em>Install</em>.</li>
</ol>
<% when platform.chrome? && platform.android? %>
<ol>
<li>Tap the <em><%= icon_tag "menu-dots-vertical", alt: "More options" %></em> menu button.</li>
<li>Tap <em>Install app</em> in the menu.</li>
</ol>
<% when platform.firefox? && platform.android? %>
<ol>
<li>Tap the <em><%= icon_tag "menu-dots-vertical", alt: "More options" %></em> menu button.</li>
<li>Tap <em>Install</em> in the menu.</li>
</ol>
<% when platform.safari? && platform.desktop? %>
<ol>
<li>Click <em>File</em> in the top left.</li>
<li>Click <em>Add to Dock…</em>.</li>
</ol>
<% when (platform.safari? || platform.chrome?) && platform.ios? %>
<p>To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.</p>
<ol>
<li>Tap <em><%= icon_tag "share", alt: "the share button" %></em></li>
<li>Tap <em>Add to Home Screen</em>.</li>
</ol>
<% else %>
<p>Some platforms require you to install Fizzy as a web app to receive push notifications.</p>
<% end %>
</div>
<% end %>
@@ -0,0 +1,57 @@
<div class="notifications-help hide-in-browser" data-notifications-target="details">
<h2>Check your <%= platform.operating_system %> settings</h2>
<% case
when platform.firefox? && platform.android? %>
<ol>
<li>Tap the <em><%= icon_tag "menu-dots-vertical.svg", alt: "More options" %></em> menu button.</li>
<li>Tap <em>Settings</em>.</li>
<li>Tap <em>Notifications</em>.</li>
<li>Tap <em><%= icon_tag "switch", alt: "the toggle button" %></em> to <em>Allow <%= platform.browser.capitalize %> notifications</em>.</li>
</ol>
<% when platform.edge? && platform.desktop? %>
<ol>
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
<li>Go to <em>System &gt; Notification</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the toggle button" %></em> <em>ON</em> for Fizzy.</li>
</ol>
<% when (platform.firefox? || platform.chrome?) && platform.desktop? %>
<ol>
<% if platform.windows? %>
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
<li>Go to <em>System &gt; Notification</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the toggle button" %></em> <em>ON</em> for Fizzy.</li>
<% else %>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em>Fizzy</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the allow notifications switch" %></em> to <em>Allow notifications</em>.</li>
<% end %>
</ol>
<% when platform.safari? && platform.desktop? %>
<ol>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em>Fizzy</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the allow notifications switch" %></em> to <em>Allow notifications</em>.</li>
</ol>
<% when (platform.safari? || platform.chrome?) && platform.ios? %>
<ol>
<li>Open the <em><%= icon_tag "gear", aria: { hidden: "true" } %></em> Settings app.</li>
<li>Scroll to and tap <em>Fizzy</em>.</li>
<li>Tap <em>Notifications</em>.</li>
<li>Tap <em><%= icon_tag "switch", alt: "the allow notifications switch button" %></em> to <em>Allow Notifications</em>.</li>
</ol>
<% when platform.chrome? && platform.android? %>
<ol>
<li>Open the <em><%= icon_tag "gear", aria: { hidden: "true" } %></em> Settings app.</li>
<li>Tap <em>Notifications</em>.</li>
<li>Tap <em>App notifications</em>.</li>
<li>Scroll to <em>Fizzy</em>.</li>
<li>Tap <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow Notifications</em>.</li>
</ol>
<% else %>
<p>Ensure notifications are allowed for <%= platform.browser.capitalize %> in your system settings.</p>
<% end %>
</div>
+29 -1
View File
@@ -12,6 +12,34 @@
</nav>
<% end %>
<section class="panel center flex flex-column gap-half margin-block">
<div class="notifications__status panel fill-shade center margin-block-start margin-block-end-double" data-controller="notifications" data-notifications-subscriptions-url-value="<%= user_push_subscriptions_path(Current.user) %>" data-notifications-enabled-class="notifications--on">
<h2 class="notifications__on-message txt-medium">Push notifications are ON</h2>
<h2 class="notifications__off-message txt-medium">Push notifications are OFF</h2>
<div class="margin-block-start-half" data-notifications-target="subscribeButton" hidden>
<button class="btn txt-small" data-action="notifications#attemptToSubscribe">
<%= icon_tag "bell" %>
<span>Turn ON push notifications on this device</span>
</button>
</div>
<details class="margin-block-start-half" data-notifications-target="explainer">
<summary class="btn txt-x-small">
<%= icon_tag "lifebuoy" %>
<span class="notifications__off-message">Help me fix this</span>
<span class="notifications__on-message">Not receiving notifications?</span>
</summary>
<div class="notifications-help__explainer fill-white margin-block-start border-radius border txt-align-start">
<p class="margin-none-block-start notifications__on-message">When push notifications arent working, this can usually be fixed by checking your notification settings to make sure theyre allowed.</p>
<%= render partial: "notifications/settings/browser" %>
<%= render partial: "notifications/settings/system" %>
<%= render partial: "notifications/settings/install" %>
</div>
</details>
</div>
<section class="panel unpad borderless center flex flex-column gap-half">
<h2 class="txt-medium txt-uppercase divider">Collections</h2>
<%= render partial: "notifications/settings/collection", collection: @collections, locals: { user: Current.user } %>
</section>
+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)
}
}
+1 -1
View File
@@ -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==
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==
+1 -1
View File
@@ -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==
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==
+1 -1
View File
@@ -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==
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==
+1 -1
View File
@@ -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==
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==
+1 -1
View File
@@ -1 +1 @@
0EWG0dpq/qELmTCqAgzEL2BZH7JCNFe6DDIynyKE06qiBCYhHs5+fMjmpzN0uwz43tqAAKJdhO/hZY4jXmTpxu+LSjwrUKAlxsWvSGu4vHEbJ5Fe+BzhnS84OFse6ma9akUAHaed3gt31wtrt2Y4AbSPedNvriqfYln7cvpgWbvjTgtEbHrcNMfjCBnaXXdO3n1zILunILgLiV7oBW0qurIrl2xOc4mbj4AqVAIElyh0md9fFtuBfsaRj2U9x/+7McbgTFODhHfeqoErEw7fc1RBFrNi2YNuHBSSqegQ6WtWbwq1Yg2Juv/Lw3g4IIdWadJsW3TdTMMiwmBMzOikx3lNX1Z399glvGNFVnJfBubKf6BxvFfAQ85PBzOZPl+/PTs=--GY44JMs5ZO1/eL6G--EhxEWuitiex6eHROpPQamw==
UFzTQdDbGY5BJ/SHVPWzNlBZoO2+BF0eHHwGICuSB5pi8ESFOHrGhmT7a9qpKLl8cbBMTPPayqLEuuuRbbtXEBS3EiDgAP7XaDViQHHAzBwgaATFT7U+1svnAwppTrAToXJlk51BdPLVRzftY9VZJ9RoDTfzKo0WriuYZ8KIRHmJjaiNDJK4xnqKaqPpF6SU0OVW2Qpo6xAKJVYKlV6SEO2Nt6C2lYra02G49f7poBypJnqouDnn+zlrLd2VGfDXOqFdi+R8T+ZS4HIzHUPi2IpEyg9jFt7qCNpSX7gy2NPZEx2mQmLC0jf5E+ew5ytKqxX1HnoJ7u8IMFvM9gQuNyA1+gaWT42uAqhUB9JKMoyE25KOSnV548cOfxCLnRNb0TgIbp5hpl66yjG32bQkyrvkCRmLBtkii/aGiwpA63UP2DTy0C/Nn86PFXJslHnVxbm8iSCRbhlu91YbmG08pzyCZQe9nRhIX2QBPufIELaJiMcFKYGfQ4Hs30TVCQ3ATdnC1UfWa7BdUsMD92ckneSrexaNofrH4WaRxyIf4VMtfRXqVgv9ovv5I4sr5BwYrPN5WvS3ksvAaoOJ/EXeq+io5OMB2ItzPJucfw==--q/sQOAAr2yruQ50H--dPendfhTyJuxPPImcH2Vqg==
+4
View File
@@ -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
+44
View File
@@ -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
+2
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
@@ -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"
@@ -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
Generated
+16
View File
@@ -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"
+140 -18
View File
@@ -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
+29
View File
@@ -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
+56
View File
@@ -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
@@ -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