@@ -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
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 "); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* PWA install */
|
||||
.pwa__instructions {
|
||||
@media (display-mode: standalone) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pwa__installer {
|
||||
display: none;
|
||||
|
||||
.pwa--can-install & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
class PushNotificationJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(notification)
|
||||
NotificationPusher.new(notification).push
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,6 @@
|
||||
class Notification < ApplicationRecord
|
||||
include PushNotifiable
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :creator, class_name: "User"
|
||||
belongs_to :source, polymorphic: true
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
module Push
|
||||
def self.table_name_prefix
|
||||
"push_"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 > 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 > 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 > 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 > 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 > 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 > 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>
|
||||
@@ -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 aren’t working, this can usually be fixed by checking your notification settings to make sure they’re 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>
|
||||
|
||||
@@ -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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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==
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user