diff --git a/Gemfile b/Gemfile index 45b93a6ec..1cf6157fd 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,8 @@ gem "actiontext-lexical", bc: "actiontext-lexical" gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false +gem "web-push" +gem "net-http-persistent" # 37id and Queenbee integration gem "signal_id", bc: "signal_id", branch: "rails4" diff --git a/Gemfile.lock b/Gemfile.lock index ae8a77c3a..76b2597a2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -377,6 +377,8 @@ GEM multipart-post (2.4.1) net-http (0.6.0) uri + net-http-persistent (4.0.6) + connection_pool (~> 2.2, >= 2.2.4) net-imap (0.5.9) date net-protocol @@ -401,6 +403,7 @@ GEM racc (~> 1.4) nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) + openssl (3.3.0) ostruct (0.6.2) parallel (1.27.0) parser (3.3.8.0) @@ -526,11 +529,11 @@ GEM fugit (~> 1.11.0) railties (>= 7.1) thor (>= 1.3.1) + sqlite3 (2.7.2-arm64-darwin) + sqlite3 (2.7.2-x86_64-darwin) + sqlite3 (2.7.2-x86_64-linux-gnu) sqlite3 (2.7.3) mini_portile2 (~> 2.8.0) - sqlite3 (2.7.3-arm64-darwin) - sqlite3 (2.7.3-x86_64-darwin) - sqlite3 (2.7.3-x86_64-linux-gnu) sshkit (1.24.0) base64 logger @@ -565,6 +568,9 @@ GEM useragent (0.16.11) vcr (6.3.1) base64 + web-push (3.0.2) + jwt (~> 3.0) + openssl (~> 3.0) webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -605,6 +611,7 @@ DEPENDENCIES mission_control-jobs mocha mysql2! + net-http-persistent platform_agent portfolio (>= 4.6)! propshaft @@ -632,6 +639,7 @@ DEPENDENCIES tiktoken_ruby turbo-rails vcr + web-push webmock BUNDLED WITH diff --git a/app/assets/images/gear.svg b/app/assets/images/gear.svg new file mode 100644 index 000000000..197da4fd9 --- /dev/null +++ b/app/assets/images/gear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/install-edge.svg b/app/assets/images/install-edge.svg new file mode 100644 index 000000000..0f1ec118a --- /dev/null +++ b/app/assets/images/install-edge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/install.svg b/app/assets/images/install.svg new file mode 100644 index 000000000..82608c765 --- /dev/null +++ b/app/assets/images/install.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/lifebuoy.svg b/app/assets/images/lifebuoy.svg new file mode 100644 index 000000000..b26ca7307 --- /dev/null +++ b/app/assets/images/lifebuoy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/sliders.svg b/app/assets/images/sliders.svg new file mode 100644 index 000000000..d84a39c1b --- /dev/null +++ b/app/assets/images/sliders.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/switch.svg b/app/assets/images/switch.svg new file mode 100644 index 000000000..2dc1f1232 --- /dev/null +++ b/app/assets/images/switch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 7b6b2f73a..2167c28bc 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -41,10 +41,15 @@ .icon--crown { --svg: url("crown.svg "); } .icon--email { --svg: url("email.svg "); } .icon--everyone { --svg: url("everyone.svg "); } + .icon--gear { --svg: url("gear.svg "); } .icon--filter { --svg: url("filter.svg "); } .icon--globe { --svg: url("globe.svg "); } .icon--golden-ticket { --svg: url("golden-ticket.svg "); } .icon--home { --svg: url("home.svg "); } + .icon--install { --svg: url("install.svg "); } + .icon--install { --svg: url("install.svg "); } + .icon--install-edge { --svg: url("install-edge.svg "); } + .icon--lifebuoy { --svg: url("lifebuoy.svg "); } .icon--lock { --svg: url("lock.svg "); } .icon--logout { --svg: url("logout.svg "); } .icon--menu { --svg: url("menu.svg "); } @@ -72,6 +77,8 @@ .icon--search { --svg: url("search.svg "); } .icon--settings { --svg: url("settings.svg "); } .icon--share { --svg: url("share.svg "); } + .icon--sliders { --svg: url("sliders.svg "); } + .icon--switch { --svg: url("switch.svg "); } .icon--tag { --svg: url("tag.svg "); } .icon--thumb-up { --svg: url("thumb-up.svg "); } .icon--trash { --svg: url("trash.svg "); } diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index e8659a268..7aac80d0b 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -44,5 +44,53 @@ display: none; } } + + + /* Help + /* ------------------------------------------------------------------------ */ + .notifications-help { + h2 { + font-size: var(--text-medium); + margin: 0; + } + + .icon { + --icon-size: 1.2em; + + vertical-align: text-top; + } + + ol { + margin-block: var(--block-space-half) var(--block-space); + + &:last-of-type { + margin-block-end: var(--block-space-half); + } + } + } + + .notifications-help__explainer { + padding: var(--block-space); + } + + .notifications__on-message { + display: none; + + .notifications--on & { + display: block; + } + } + + .notifications__off-message { + display: block; + + .notifications--on & { + display: none; + } + } + + .notifications__status { + --panel-padding: var(--block-space); + } } diff --git a/app/assets/stylesheets/pwa.css b/app/assets/stylesheets/pwa.css new file mode 100644 index 000000000..4f30941b0 --- /dev/null +++ b/app/assets/stylesheets/pwa.css @@ -0,0 +1,14 @@ +/* PWA install */ +.pwa__instructions { + @media (display-mode: standalone) { + display: none; + } +} + +.pwa__installer { + display: none; + + .pwa--can-install & { + display: block; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 45cfd7d56..4661c391e 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -219,6 +219,18 @@ [hidden] { display: none !important; } [contents] { display: contents; } + .hide-in-pwa { + @media (display-mode: standalone) { + display: none; + } + } + + .hide-in-browser { + @media (display-mode: browser) { + display: none; + } + } + .hide-focus-ring { --focus-ring-size: 0; } diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb new file mode 100644 index 000000000..bd6127182 --- /dev/null +++ b/app/controllers/pwa_controller.rb @@ -0,0 +1,8 @@ +class PwaController < ApplicationController + require_untenanted_access + skip_forgery_protection + + # We need a stable URL at the root, so we can't use the regular asset path here. + def service_worker + end +end diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb new file mode 100644 index 000000000..bf5e5f87e --- /dev/null +++ b/app/controllers/users/push_subscriptions_controller.rb @@ -0,0 +1,30 @@ +class Users::PushSubscriptionsController < ApplicationController + before_action :set_push_subscriptions + + def index + end + + def create + if subscription = @push_subscriptions.find_by(push_subscription_params) + subscription.touch + else + @push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent) + end + + head :ok + end + + def destroy + @push_subscriptions.destroy_by(id: params[:id]) + redirect_to user_push_subscriptions_url + end + + private + def set_push_subscriptions + @push_subscriptions = Current.user.push_subscriptions + end + + def push_subscription_params + params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key) + end +end diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js new file mode 100644 index 000000000..02fce86d7 --- /dev/null +++ b/app/javascript/controllers/notifications_controller.js @@ -0,0 +1,108 @@ +import { Controller } from "@hotwired/stimulus" +import { post } from "@rails/request.js" + +export default class extends Controller { + static classes = [ "enabled" ] + static targets = [ "subscribeButton", "explainer" ] + static values = { subscriptionsUrl: String } + + async connect() { + if (!this.#allowed) return + + switch(Notification.permission) { + case "default": + this.subscribeButtonTarget.hidden = false + this.explainerTarget.hidden = true + break + case "granted": + const registration = await this.#getServiceWorkerRegistration() + const subscription = await registration?.pushManager?.getSubscription() + + if (registration && subscription) { + this.element.classList.add(this.enabledClass) + } + break + } + } + + async attemptToSubscribe() { + if (this.#allowed) { + const registration = await this.#getServiceWorkerRegistration() || await this.#registerServiceWorker() + + switch(Notification.permission) { + case "denied": { break } + case "granted": { this.#subscribe(registration); break } + case "default": { this.#requestPermissionAndSubscribe(registration) } + } + } + } + + async isEnabled() { + if (this.#allowed) { + const registration = await this.#getServiceWorkerRegistration() + const existingSubscription = await registration?.pushManager?.getSubscription() + + return Notification.permission == "granted" && registration && existingSubscription + } + } + + get #allowed() { + return navigator.serviceWorker && window.Notification + } + + async #getServiceWorkerRegistration() { + return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" }) + } + + #registerServiceWorker() { + return navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) + } + + async #subscribe(registration) { + registration.pushManager + .subscribe({ userVisibleOnly: true, applicationServerKey: this.#vapidPublicKey }) + .then(subscription => { + this.#syncPushSubscription(subscription) + }) + } + + async #syncPushSubscription(subscription) { + const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" }) + if (response.ok) { + this.element.classList.add(this.enabledClass) + this.subscribeButtonTarget.hidden = true + } else { + subscription.unsubscribe() + } + } + + async #requestPermissionAndSubscribe(registration) { + const permission = await Notification.requestPermission() + if (permission === "granted") this.#subscribe(registration) + } + + get #vapidPublicKey() { + const encodedVapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content + return this.#urlBase64ToUint8Array(encodedVapidPublicKey) + } + + #extractJsonPayloadAsString(subscription) { + const { endpoint, keys: { p256dh, auth } } = subscription.toJSON() + return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } }) + } + + // VAPID public key comes encoded as base64 but service worker registration needs it as a Uint8Array + #urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") + + const rawData = window.atob(base64) + const outputArray = new Uint8Array(rawData.length) + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + + return outputArray + } +} diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb new file mode 100644 index 000000000..128cf6a18 --- /dev/null +++ b/app/jobs/push_notification_job.rb @@ -0,0 +1,7 @@ +class PushNotificationJob < ApplicationJob + queue_as :default + + def perform(notification) + NotificationPusher.new(notification).push + end +end diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index c8c24a50b..500f4adc6 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -15,6 +15,14 @@ class ApplicationPlatform < PlatformAgent user_agent.browser.match? /Chrome/ end + def edge? + user_agent.browser.match? /Edg/ + end + + def firefox? + user_agent.browser.match? /Firefox|FxiOS/ + end + def safari? user_agent.browser.match? /Safari/ end @@ -26,4 +34,21 @@ class ApplicationPlatform < PlatformAgent def desktop? !mobile? end + + def windows? + operating_system == "Windows" + end + + def operating_system + case user_agent.platform + when /Android/ then "Android" + when /iPad/ then "iPad" + when /iPhone/ then "iPhone" + when /Macintosh/ then "macOS" + when /Windows/ then "Windows" + when /CrOS/ then "ChromeOS" + else + os =~ /Linux/ ? "Linux" : os + end + end end diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb new file mode 100644 index 000000000..e497bc3fe --- /dev/null +++ b/app/models/concerns/push_notifiable.rb @@ -0,0 +1,12 @@ +module PushNotifiable + extend ActiveSupport::Concern + + included do + after_create_commit :push_notification_later + end + + private + def push_notification_later + PushNotificationJob.perform_later(self) + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb index 73525f4ff..4eaf5786b 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,4 +1,6 @@ class Notification < ApplicationRecord + include PushNotifiable + belongs_to :user belongs_to :creator, class_name: "User" belongs_to :source, polymorphic: true diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb new file mode 100644 index 000000000..8f68e4491 --- /dev/null +++ b/app/models/notification_pusher.rb @@ -0,0 +1,122 @@ +class NotificationPusher + include Rails.application.routes.url_helpers + + attr_reader :notification + + def initialize(notification) + @notification = notification + end + + def push + return unless should_push? + + build_payload.tap do |payload| + push_to_user(payload) + end + end + + private + def account_prefix + "/#{Account.sole.queenbee_id}" + end + + def should_push? + notification.user.push_subscriptions.any? && + !notification.creator.system? && + notification.user.active? + end + + def build_payload + case notification.source_type + when "Event" + build_event_payload + when "Mention" + build_mention_payload + else + build_default_payload + end + end + + def build_event_payload + event = notification.source + card = event.card + + base_payload = { + title: card_notification_title(card), + path: card_path(card) + } + + case event.action + when "comment_created" + base_payload.merge( + title: "RE: #{base_payload[:title]}", + body: comment_notification_body(event) + ) + when "card_assigned" + base_payload.merge( + body: "Assigned to you by #{event.creator.name}" + ) + when "card_published" + base_payload.merge( + body: "Added by #{event.creator.name}" + ) + when "card_closed" + base_payload.merge( + body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}" + ) + when "card_reopened" + base_payload.merge( + body: "Reopened by #{event.creator.name}" + ) + else + base_payload.merge( + body: event.creator.name + ) + end + end + + def build_mention_payload + mention = notification.source + card = mention.card + + { + title: "#{mention.mentioner.first_name} mentioned you", + body: mention.source.mentionable_content.truncate(200), + path: card_path(card) + } + end + + def build_default_payload + { + title: "New notification", + body: "You have a new notification", + path: "#{account_prefix}#{notifications_path}" + } + end + + def push_to_user(payload) + subscriptions = notification.user.push_subscriptions + enqueue_payload_for_delivery(payload, subscriptions) + end + + def enqueue_payload_for_delivery(payload, subscriptions) + Rails.configuration.x.web_push_pool.queue(payload, subscriptions) + end + + def card_notification_title(card) + card.title.presence || "Card #{card.id}" + end + + def comment_notification_body(event) + comment = event.eventable + strip_tags(comment.body.to_s).truncate(200) + end + + def strip_tags(text) + ActionView::Base.full_sanitizer.sanitize(text) + end + + def card_path(card) + "#{account_prefix}#{collection_card_path(card.collection, card)}" + end +end diff --git a/app/models/push.rb b/app/models/push.rb new file mode 100644 index 000000000..765323c0b --- /dev/null +++ b/app/models/push.rb @@ -0,0 +1,5 @@ +module Push + def self.table_name_prefix + "push_" + end +end diff --git a/app/models/push/subscription.rb b/app/models/push/subscription.rb new file mode 100644 index 000000000..ebc12c70d --- /dev/null +++ b/app/models/push/subscription.rb @@ -0,0 +1,7 @@ +class Push::Subscription < ApplicationRecord + belongs_to :user + + def notification(**params) + WebPush::Notification.new(**params, badge: user.notifications.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 94c2a5827..dcc3ba9b8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,7 @@ class User < ApplicationRecord has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card has_many :commands, dependent: :destroy + has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all has_many :period_activity_summaries, dependent: :destroy normalizes :email_address, with: ->(value) { value.strip.downcase } diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index b79c9ba58..0922c2de4 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -9,6 +9,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= tag.meta name: "current-user-id", content: Current.user.id if Current.user %> + <%= tag.meta name: "vapid-public-key", content: Rails.configuration.x.vapid.public_key %> <% turbo_refreshes_with method: :morph, scroll: :preserve %> diff --git a/app/views/notifications/settings/_browser.html.erb b/app/views/notifications/settings/_browser.html.erb new file mode 100644 index 000000000..4662c558b --- /dev/null +++ b/app/views/notifications/settings/_browser.html.erb @@ -0,0 +1,109 @@ +<% unless (platform.safari? || platform.chrome?) && platform.ios? %> +
+ <% case + when platform.firefox? && platform.android? %> +

Turn on notifications for <%= platform.browser.capitalize %>.

+
    +
  1. Tap <%= icon_tag "lock", alt: "the View site information button" %> in the address bar.
  2. +
  3. Tap Notification to change to Allowed.
  4. +
+ <% when platform.edge? && platform.desktop? %> +

Turn on notifications for this website.

+
    +
  1. Click <%= icon_tag "lock", alt: "the View site information button" %> left of the address bar.
  2. +
  3. Under Permissions for this site > Notifications, choose Allow.
  4. +
+

Turn on notifications for <%= platform.browser.capitalize %>.

+
    + <% if platform.windows? %> +
  1. Click Start, then Settings.
  2. +
  3. Go to System > Notification.
  4. +
  5. Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
  6. + <% else %> +
  7. Click in the top left.
  8. +
  9. Click System Settings….
  10. +
  11. Click Notifications.
  12. +
  13. Click <%= platform.browser.capitalize %>.
  14. +
  15. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
  16. + <% end %> +
+ <% when platform.firefox? && platform.desktop? %> +

Turn on notifications for this website.

+
    +
  1. Click <%= platform.browser.capitalize %> in the top left.
  2. +
  3. Click Settings….
  4. +
  5. Click Privacy & Security in the sidebar.
  6. +
  7. Scroll down to Permissions.
  8. +
  9. Click Settings next to Notifications.
  10. +
  11. Select Allow next to <%= root_url %>.
  12. +
+ +

Turn on notifications for <%= platform.browser.capitalize %>.

+
    + <% if platform.windows? %> +
  1. Click Start, then Settings.
  2. +
  3. Go to System > Notification.
  4. +
  5. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for <%= platform.browser.capitalize %>.
  6. + <% else %> +
  7. Click in the top left.
  8. +
  9. Click System Settings….
  10. +
  11. Click Notifications.
  12. +
  13. Click <%= platform.browser.capitalize %>.
  14. +
  15. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
  16. + <% end %> +
+ <% when platform.chrome? && platform.desktop? %> +

Turn on notifications for this website.

+
    +
  1. Click the <%= icon_tag "sliders", alt: "View site information" %> icon in the address bar.
  2. +
  3. Click Site Settings.
  4. +
  5. Ensure notifications are Allowed.
  6. +
+ +

Turn on notifications for <%= platform.browser.capitalize %>.

+
    + <% if platform.windows? %> +
  1. Click Start, then Settings.
  2. +
  3. Go to System > Notification.
  4. +
  5. Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
  6. + <% else %> +
  7. Click in the top left.
  8. +
  9. Click System Settings….
  10. +
  11. Click Notifications.
  12. +
  13. Click <%= platform.browser.capitalize %>.
  14. +
  15. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
  16. + <% end %> +
+ <% when platform.chrome? && platform.android? %> +

Turn on notifications for <%= platform.browser.capitalize %>.

+
    +
  1. Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
  2. +
  3. Tap Settings.
  4. +
  5. Tap Notifications.
  6. +
  7. Tap <%= icon_tag "switch", alt: "the switch" %> to Allow <%= platform.browser.capitalize %> notifications.
  8. +
  9. Tap <%= icon_tag "switch", alt: "the switch" %> next to Web apps.
  10. +
  11. Tap <%= icon_tag "notification-bell-alert", alt: "the notification bell" %> and select Allow.
  12. +
+ <% when platform.safari? && platform.desktop? %> +

Turn on notifications for this website.

+
    +
  1. Click in the top left.
  2. +
  3. Click System Settings….
  4. +
  5. Click Notifications.
  6. +
  7. Click <%= request.base_url %> in the list.
  8. +
  9. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
  10. +
+

Turn on notifications for <%= platform.browser.capitalize %>.

+
    +
  1. Click <%= platform.browser.capitalize %> in the top left.
  2. +
  3. Click Settings….
  4. +
  5. Click the Websites tab.
  6. +
  7. Click Notifications in the sidebar.
  8. +
  9. Click <%= request.base_url %> in the list.
  10. +
  11. Select Allow.
  12. +
+ <% else %> +

Ensure notifications are enabled for <%= root_url %> in your web browser settings.

+ <% end %> +
+<% end %> diff --git a/app/views/notifications/settings/_install.html.erb b/app/views/notifications/settings/_install.html.erb new file mode 100644 index 000000000..f8d664043 --- /dev/null +++ b/app/views/notifications/settings/_install.html.erb @@ -0,0 +1,35 @@ +<% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %> +
+

<%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.

+ <% case + when platform.edge? %> +
    +
  1. Click <%= icon_tag "install-edge", alt: "the app available - install Fizzy button" %>in the address bar.
  2. +
  3. Click Install.
  4. +
+ <% when platform.chrome? && platform.android? %> +
    +
  1. Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
  2. +
  3. Tap Install app in the menu.
  4. +
+ <% when platform.firefox? && platform.android? %> +
    +
  1. Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
  2. +
  3. Tap Install in the menu.
  4. +
+ <% when platform.safari? && platform.desktop? %> +
    +
  1. Click File in the top left.
  2. +
  3. Click Add to Dock….
  4. +
+ <% when (platform.safari? || platform.chrome?) && platform.ios? %> +

To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.

+
    +
  1. Tap <%= icon_tag "share", alt: "the share button" %>
  2. +
  3. Tap Add to Home Screen.
  4. +
+ <% else %> +

Some platforms require you to install Fizzy as a web app to receive push notifications.

+ <% end %> +
+<% end %> diff --git a/app/views/notifications/settings/_system.html.erb b/app/views/notifications/settings/_system.html.erb new file mode 100644 index 000000000..afe0efd45 --- /dev/null +++ b/app/views/notifications/settings/_system.html.erb @@ -0,0 +1,57 @@ +
+

Check your <%= platform.operating_system %> settings

+ <% case + when platform.firefox? && platform.android? %> +
    +
  1. Tap the <%= icon_tag "menu-dots-vertical.svg", alt: "More options" %> menu button.
  2. +
  3. Tap Settings.
  4. +
  5. Tap Notifications.
  6. +
  7. Tap <%= icon_tag "switch", alt: "the toggle button" %> to Allow <%= platform.browser.capitalize %> notifications.
  8. +
+ <% when platform.edge? && platform.desktop? %> +
    +
  1. Click Start, then Settings.
  2. +
  3. Go to System > Notification.
  4. +
  5. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for Fizzy.
  6. +
+ <% when (platform.firefox? || platform.chrome?) && platform.desktop? %> +
    + <% if platform.windows? %> +
  1. Click Start, then Settings.
  2. +
  3. Go to System > Notification.
  4. +
  5. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for Fizzy.
  6. + <% else %> +
  7. Click in the top left.
  8. +
  9. Click System Settings….
  10. +
  11. Click Notifications.
  12. +
  13. Click Fizzy.
  14. +
  15. Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
  16. + <% end %> +
+ <% when platform.safari? && platform.desktop? %> +
    +
  1. Click in the top left.
  2. +
  3. Click System Settings….
  4. +
  5. Click Notifications.
  6. +
  7. Click Fizzy.
  8. +
  9. Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
  10. +
+ <% when (platform.safari? || platform.chrome?) && platform.ios? %> +
    +
  1. Open the <%= icon_tag "gear", aria: { hidden: "true" } %> Settings app.
  2. +
  3. Scroll to and tap Fizzy.
  4. +
  5. Tap Notifications.
  6. +
  7. Tap <%= icon_tag "switch", alt: "the allow notifications switch button" %> to Allow Notifications.
  8. +
+ <% when platform.chrome? && platform.android? %> +
    +
  1. Open the <%= icon_tag "gear", aria: { hidden: "true" } %> Settings app.
  2. +
  3. Tap Notifications.
  4. +
  5. Tap App notifications.
  6. +
  7. Scroll to Fizzy.
  8. +
  9. Tap <%= icon_tag "switch", alt: "the switch" %> to Allow Notifications.
  10. +
+ <% else %> +

Ensure notifications are allowed for <%= platform.browser.capitalize %> in your system settings.

+ <% end %> +
diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index 89ee03f45..74dd03731 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -12,6 +12,34 @@ <% end %> -
+
+

Push notifications are ON

+

Push notifications are OFF

+ + + +
+ + <%= icon_tag "lifebuoy" %> + Help me fix this + Not receiving notifications? + + +
+

When push notifications aren’t working, this can usually be fixed by checking your notification settings to make sure they’re allowed.

+ <%= render partial: "notifications/settings/browser" %> + <%= render partial: "notifications/settings/system" %> + <%= render partial: "notifications/settings/install" %> +
+
+
+ +
+

Collections

<%= render partial: "notifications/settings/collection", collection: @collections, locals: { user: Current.user } %>
diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js new file mode 100644 index 000000000..e27c062f9 --- /dev/null +++ b/app/views/pwa/service_worker.js @@ -0,0 +1,30 @@ +self.addEventListener("push", async (event) => { + const data = await event.data.json() + event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ])) +}) + +async function showNotification({ title, options }) { + return self.registration.showNotification(title, options) +} + +async function updateBadgeCount({ data: { badge } }) { + return self.navigator.setAppBadge?.(badge || 0) +} + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + const url = new URL(event.notification.data.path, self.location.origin).href + event.waitUntil(openURL(url)) +}) + +async function openURL(url) { + const clients = await self.clients.matchAll({ type: "window" }) + const focused = clients.find((client) => client.focused) + + if (focused) { + await focused.navigate(url) + } else { + await self.clients.openWindow(url) + } +} diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc index 8eb55f4cd..21bf6f859 100644 --- a/config/credentials/beta.yml.enc +++ b/config/credentials/beta.yml.enc @@ -1 +1 @@ -yD+Wb/DYMXUXf16brcYmNb7OZngx7PuA6gLP8w6ZV7pA/GFweTIXQw67YoXebL6A7gWxhXX4tvpnrz9xPC1p5+5a8q00lOhcybc/480bSfenIXExDpg6mSDbQLRomrpwZsr+IqBWRC8WoXTtpVYRybEPRP88ZXdyE0MjnfOlGcr1fUcF3r9T8biXfNhS9v9UTxck1Q2yzGoNFRvBm3KgBN2QlJocG5AXaDYUSMfLoLmtkicO0KVOD7eYBxe0kDevUK7ZQGxbtATGIsjAJEifhuaPTj/Q2DH134TU4rlPr6n6vm9YEgC/CGVoqPIDgvovVd5IiqZlxGuVDDw3ct0cPH4/AnDLOVHiubi5/pZRCQyeOF+G1OmLVFUVeuZ6alPVrJSxHFxGVM/HT+pLWiReR/IJYacLzb3b2OOtBp/AKxbt25YxFFHuFHABfVEqPRo0qVwbQaxKZEyDyEW0t0ktTkqHywqkbmsx3HPU3skV42rTHf9c1udJhzTXZJPKIWKP96ahgX6eaOhy09rzwD99RnKLJuAuMl80Cw1L+ftLG5hM4tHYmv+JbpWDSInvP1jtA5iWtDRSiqhTxvXw4ItHximvsERI5k6hSPJzrzqnhLUxNVi10KQHh5KGq7EiC2J3HJwioY+fWVivzEJdw2WuVFsovcFyolrT3tyqcMVF5V3dQw4/gOGQ7gXsz0a9sb5wQgXJ9pyZBAmD4Bg7r4wD0f0JiL/5EdKOkAOTV2JDt0OgoSWzU+yuvByuWxPn2quoX0ZSG0huHvl2NK33RoR9aXLcSjtmi5XJGXpjATfKMoA2d8JBIDiJcOPAPwWkpI7AOnmvlV2SmrqPlLTkO0MfdJ528k8CFi2puSRQN+CEPhyhZclDO/J2IP94qmE35xAmWM7yjY6E+EZY9uhlF39uBhAIXylZwTyF31PSCqwpQJthH62pDFWpCgjy1iax482cCVt5n2HLQpmSNaPAvsI7hZAGFJsrDncwfJiK68YXrgmTeg8=--Gd2i+KrusH11oais--SraJ0tcCfEgIdRSJYoCoNw== \ No newline at end of file +hTAo532D++IfAePtvgvwjKd6Cx3RRCdoO+77b4yFvgZSGtEXB6mfNGKlEICRL4o0qh0lqimE2/3Sbmnx97lzhfdYXE3b09KewXIub14s1jzTRWzZSRPNcxPeYou10kiN0b/4R/IFON8+/P7Ratok1MHIrAt4M3EUYwtEpzsi3aOYDGlRoLW5rAxFAHRl7zhgkMH3Kzkmjh2tr+QOc5fxqmYT8ETmLJrOtvJtULMW4mfie722zmwm9RFJxtHpz4gnMAwSr2vaZ0edk0sHeErnDJo7Zl1Ul7uCJyXPP4PCqmuNVOQbxjLMNVTmo8ntUWZa7pvocpa5uYy8DIy3iCAYYIxdh/xhJPnqPC0XQnS6Aj4zaOD/fOXSnEuSQjR8d5XyIUAWuywh6Li7qWyj3W8btfmc1HWOo4cwx8LkwRuUG1r/SvMQdIR1E2xtgROaoXZmPOXmYlFJm3mzy+oGOk7Pa/u/1iiBiNEkDx4nhHe+0XuPZcBe4c9PKn3zw/3SOm32SpBqe6zPZI/68VoiXx5wVaogY5gnKQOHNkhi9RhN6SSrM58oS+EKYkCJ4+iRqj80IQNtj4in4pyQ/ONZgc0tjMh1+C342NkhbaJYmGGUPpPVhZPCUf3clbBXqex15+6UkmymWtz3WH6GixPVwrvp1gGUqOmqCiWVNJM/kQ84IOTzlxfeyKjGK5xKhEC3ajYqXDDUDek/tUIhqtOYxBS9T44cKDPDTL1BKFMdIDR7gdtRyQouW3RvD4juqyjh710I9aq3JB1Jvj8UZ7uSHYEJ9NpmllfAx0RrrxMFHcwr3LqjDlDf7mtACfDISVtD6TQLuGySDIhs8gWTiSuZ75jRTswx8y3paI17Yy0en23lDIQ9hlPh6xvvJWQQtCT6B8GCz56GrLOVDfKM/0qeqJtyed2p2/MIJfy+QmqTSMSeQz+wiLi14I2Vgy229A1CW4izMSoR+RqoZzIzCbZg29croJv9DRhp/nVtkjj/enHnHksZKqoxzf0c7nHSWtGSYtCiewDydsBCioKdo9tn/FM9UKpnRAy1XHiq1DvBQ+wuq74KLnC/4nBZ7P90xlI8jQuQfldpDRqKaIYjIvz8UbLdKOjETT1dJjZ9Tk629zdLEVybLSGhoE0bs460oFvlrjJDCXtgi/3tWbTIuwsxY6Iy3eMiS5Kxl42dnlOdVrxwwY/om/MzL4+yvsx38wXS2QG8lDKz2JuGBS4GYq5EEg==--P2KPkoocz8wNhqSN--1u5WuEBtEaqJckCflHkb6g== \ No newline at end of file diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index ce2cc3c0f..fbd189683 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -hHi9VfqfZI57GYR5r75Rh00L3FYeoUgsdtCgBzJGdPhhGZLGlCgUAcY6tgVhqhgM/u5WxyPAzmoV+bbQKb0wO0U6KUmrDKDv9uRnNSUjXGYsg8gWdTUTfx7WBFOFawV3p1Dsm7zM1oUJbRWgy2TiMFl5kP9CN4Lk78n5a1/Y7mnx/wmzwUyQv+BUFJ0UKCfRmWieaNoLkm5kWwyepWHaSRU5EVda6J9VtuwP3hISIzncXm0jpr9r/tJm8flgUR3o2STIVHVgg+YcWz0j3st5RMv7i3D2OHfNGx84vsf3LNlsiLGB1ZU0IsmchLOSqPmlDK2oiB7UKepTPthGUFzNmcDG4LpUQfNwn7B8P7by0zadVefdLSB3kFCNLX+KCAbcjHPlWufkagwHo+dV+iIVACC+hfWl3L/IouqYS0ln2XCBDz6A8lI8PHeGaJe9UhfA5JEuJLRPui8znoq7LrKo4/oBB7ArKdHz/W/oHJA9+aLHcLOq9TPSExAsGi1wcjdgF0nk5mB9iX+az3//1zM64rRz8EyzNkBg1I6PV6wpzROLubttv1+I5nRVe4BernNfVJPi/hbvrlrcZmouU32oOzd2aZuPL5/H8gBhMOPlN25Rr51XCziaczgdEfOwTO8da6r7pxsJDkrvKi+Ju6efxhZ6GeotWJuEUtQtWhHnzvhcJAXfUtJf6A==--LWtZauLkWq+tD+4L--mkMcqYebO9TDi6RTRNUQvA== \ No newline at end of file +qGtDqUHZuqVpajiHSIgxtaKdNgBjWFZaKMWitxGBAtV/q/P0uVAzry+MRonSMglb8SZEW28/LAKF28kUeAMGZVwnbMHAmcmOdOHNwfPBDu/3+c67J0qedEH2k8998o7wBqDi7iFTaWlDQROOi8LiiE3oVnpsRZGXtjXgClYI5ED062ITCd/tMLDsXiZjq/PhodWjI/YgEycwy0r1OldJZjOCzS1VD924AfOIH2/qtszc7oeArEpv8XYm3DJDCixYy5LLnhNIek7o8CHaZIeQxfm9pEh5ZEXaMNfSB4Gq+laAyLoXqsoPWuonjw0273Rjw1U3PU+6GlU85ZUrm9dBnekZiatLIHt3yX1CKynS8yqen0ON+MrP5W98pAa9Njq2GtB2SUrGY1xQtyoIfYPxFbNZoOph4PubcXqCT94bkAbZOiYKSiNLONuhhvKR6r9amh+LlDELp7QLR8EIfRpgoN5D8qhbhEi1/c2Sxogv/7+YtkpaYq47Ram2THll+URQ3L4iLKyPIaimxt9vsqdO5COn69fTqtqkYR/fKy93/3JIJxiNQ8I6z5xhGRLFk9ADWB3ZVz1VzXzW0Q1B0UQmMg0KEEM9ZQTxignWeJasbG2L/T656Lg/wPdKdAw0jcZAOh+BGtePgOOjZ5Le8rweSHKou0kTn1EjR81csRuSnHEEGTdlNp34LC0NirqwEwmPySRrQxmNwslS82R8uQvZQ+XtalJBBvUVwaB+o2rm9AfZFzzjGmZhVOKCQLZx4ugG+CUfc+soLGuQtcyPQe+0V2hJ97zzqdWxKvdUQ/aCerBwUQczl7r/PPxKucl4LtF+3mNYNMWjIdNIHJRzhvCnmZ8Zj1TuO4SGWMKNx7kwNP54dd4pqRD6Z0vm/x8zZXeccfu8Y39IOlawyEDR7idd0CSZ--41mPjeBDVAaVR2Q8--Qfq5LzxnQOYGDaGie2K6FQ== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index e97e68755..0720c2059 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -cPaSSbJ2XWIjNXDRi25iIH41V4zR1fKVTe3OV1XwZKsDE3950DL9Sk0RI/1bxYY+agT3x88vrsqm8xyqLwqcMeMg7J4el52WI4hORe5XLGJTcvs8CTgguut1s7c5Jb5ihOSPX9ZYEKnONxLU2BFe951WZgO+7Us2IBFbCQiqo8PXEBEONePuE9Aw2z1fUFW7NvxhptN9ws7DptP2O/wpHP8+KJmVuAJl+/qJHJoBh770dwQU3yCFz9mERowkARK4Z16Q+HelkrkvW9UziV53tMIExDRvfMnEKJz4wrU/AqVSiQIMQA6eVfTMQLxbVhBhLAlcV0Qic7yEbXLcAXwLX6Kga6aMOqyJvxh+JUnMpQavtHKikVncN8EI1lGxd/+mddS+khHZmCqkRvWl+LiaOe2WOkNENYXlGYANeRSTDy2BqMeT1ymAj3YxfWKYdR34Ukmm2iZKQ1d6fF+Dg/IT8/W/rIZPajQjMTO05WqeoE8kZ4lhgI7AxZHg9apHGsfkq6iBaf4kZUBFiLgOv0CR4Xz3zWVUHEyPx5afkkxs1u9NCspOaYWT4gA1tekFmaH7cMyhaQ/SEjOEs5AWyh47PeMBvwUumJKIEeil6p6K9109nSFG+BPszfhshY8G3aonuIbiWoJQAiGGnbvw4oZeAPjl8XNBYTWRspl2T9ep3krPg9L4eTrAyXdT0dG7GYXSa5NkrV86HugU5rDfx87N4QJVu6qr7Myzw6kEWlLRXHR6HPg+H7qZenNwuOfgnD3YoFOEWQfLiz9U3MqDRJIq5j+i2OfL4pv0VVpc51z42mWM1hvazGNiiTZvK0DohBja6D9h5Gl6ZP/eOGB8O6ZxIwn6mp23BIvxA5jBRGslCj/Op7msnkSAP0vtuI0hnCtfsOx0RfAPvj1/ETjVkRFhM7heWTgfqGQbjSVGvd/Dj6j3vbqGz3RASVL48juoWx/pezUMnWGZqRTqx5tD6FrZQy1yf/2Uoc8yMJQU14raqiShEqNveJswMv1D9kBnT3551DtpAmrMT+iuCX5aZHwVnY/sI8J8HdMAqFfpipMbCbccI/PgTc+U8uOqc+BBKYy8u04wHLnGu5wREewU5xRIkxcwGm4M4LkTLiVEJFUQuFEhylmxkCIDYG+WSBZ5xy/0dRUeZs26Uuvvs2//bXqn75Ia86Fpv54AzfdDqC9s0B1r5/ZqXtFjjL3bQyhv+SP5TMurAZcI0rLYay+oFcLZAv31zoo0dFcufiCKYTjbYEIlO+HW2aeVeOhUnCalqqslt4ql5A==--tVMd1KheRgH8F0gZ--x8Wzmk8JsMuHPG3JMOODLA== \ No newline at end of file +gJofgOA5kUBwjcFwc6IDRjLbYQfG7qGpm8BcAEnS0FODuMbEE48FKN4jdUfhGAw0yCHu9DT1cJiP2wKR8s7nyf6dUGl6fPljR0sKTvO0eznmWigWCc+Ki6zVef0mRqQ2lZguiokjapG9c4qBcy2MYhVOhw3JAYvH6X4piiyO52nLL/kKLEQ64/uqC9VDKgR8TRhyVRqQN2jvB62VnIGxWX/qSPcapF/jw8ECgPsXgOSY/s4oNvQ8gCuZMweDlSYqFLAXpn2GP+wAaT9u6UmprPx7niDb1EIIm+LFWU1Ar4lY2n5Mo9ckN0ddsoMnwlsYTjlAWnIn9m0o/GbaxWmtSScnxHXbzqr630rEffvZrzLTG60dxkI0it8cVXwSHI6pMQHZw/lUkHd13IHMzR9eNGUq4pV+fdQjhvcfS2sTwVeCZ6VV1DbnY1vOeyMnVfvFLlJ7eqChkpFBAmOKS4hV/Mrqks6OP7K6ubyXt/REwuNhRMeJ1SJAfgiDSKbmNFtSnODyXKP1nFTCvvdndiP1H6t8qkJo8COSnSWWzcyyVKMwEKwbOxxtX2C+V4aySTllw0gFODLjQPxPBwqnkwcEcDEHxtsgImOYnOc0Ro/4faI1C5WdCuF6+VOBGpq1nI4HCQqepx4b/HZt/EyJSaQU08emhHht2vlCqJUN2yPUxnbCKBAbQhz0khRANBWIf8q0wfwSLaAqQHJ9FsZp4ztOC5U4lULzaMJh7dBUbv/9+mH6Yb3lFbg57178GYCgbzFwhvEOlEKBjfwDRFNmYfiVH4YYbZTIgQvu3tSywNNQJ4flu6TSjMC6zdOBi41BAIXH7j04zwpQV7Zndlu3xE1LTMbcLfBdqzOBGuRVD5KvQH5XFj0RfKTg2DN7Mo2XwSb7IZnl3Ga/Tuvb7DwKpRtql2RCYIYXwg5EnCYF3rw8J9m4tLzr9AFh3KALq+Lnfa0d1MysB1NQJ5XqtJ3jeYW+sxv4ivv9gpzQ9SZF26Z3rZoQHnb2S7usIgzEquiDyhAQr67AYOMJe5DKRw/QShJwYK+k+n/IrpQxUBnTrZvf2I47KF6UkhgWpZgmQaDB7WR1PePHVqJdkdQgqQzUU2t0SQIUY0FHH6MRLyHDcqOFiPl76HNFOmElZzrfXzqtwQdXOau7hV645LqaUy2xinA97zkEdpAGr/P/eloveh6wIgSIZNTsm8RbgRkl8SS1O9Q9goxMir4S3JvjWTJmAhxKaKlGeIVcMRVIv5C634Ugpp5jzh8PHX9nHAN2w9HHI+8AMaBUeFyuixbfk88EIvby72wY8MWxYzv1H/THWSL8WTiQJGxWE4PjSBQtwuc1zzHqG8Ve45Z1nIkt4TmC1VXftUHj5SghncAHCA27v+Hdk+cjSw/ymaVwIRreaZYMjOs2xX8XGOYXpbChFJaMk7ywR3FyBzOVvFXGnGCuA55f+j/7E2KuBI4lnr/mmCTeYvLOBrBDd2c1sEjKTURM4cSlDBw9NKc8oloxYZQKn9vA--z6fcV0Ojv6v39SCA--yOvnjjoQdp6k7P89GMEgYw== \ No newline at end of file diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc index ef3fa3ffb..b9f413f86 100644 --- a/config/credentials/staging.yml.enc +++ b/config/credentials/staging.yml.enc @@ -1 +1 @@ -UV9AzQwwjGoWFh78GyKkNewL4lmsL80XT+6ca0JIxZ632OMKbYzHbPtJAD6RK1gaEB4GRkiBE2mLEdk8nYI47p8K9NVqZuI/ykgl23Sr2JzBpnHfjtUYlPQC7nYyeAR3gYtASD5+jSXvP7z/9BquIVdTvih3hWPPujDK5OROXagDtlGTZ4SgKZwihCTZczTwZRLxx5UguwbtuowrtcuPTg+r/H+zdJyRUU9kF7vYqV5VrP0vj0mXZUf5hWNBpKvhDtqgKUJNU87ds7xTLwMuQT0MCASoJXS0JSpIKn653baXczQue6JyuFcOlqDPDmBWs2GLh6pq3XnNolmWoyNehD9WClmJ5cu0l1dPmYZ0WnOfPgJCPtuJI4onIUj2nLC5m0tHpQPNs9WjazQeCeiw7TJBESK3b3lB4/Q7STshfJd74IIJ0crzfm0a6MwxvT184etKpNRQkgJpwK6WRNPzzzHdlTmbkEher4ilcs3QrmyLPYaLcuG+0YyDcjgJXRvLumkZilONbuBHluMc64u4GAU8QyW1dp31DrCw/HTOTtPEsRJSKdTIiFFCDpYt0Uq16Qbp9Hsg1ruPqFPELXDqRSl+N9ixDdXjF8WyQc6Gx4i8Fsur0WBLGBEtCEkCCQvwsH+TVH47S68CSiUui0c2zX1sM2SEcOOL7Cn0TUDWg6d8MgHQ5977aGLGFUkeuldv8KfQSra5uHKESSxOwn/eJJQMERSiVHSEj1EK3v4R+shXnviPsFTr3uERjdn2s1CcSk3qJ7J5WFpmgTL3/qKRWDroI96QO24CSr8JWJ3MzrwkLLpiEf6qLyfswiRjJ79sxk1pTdx7zedb8MuG/596J/xFI7GdbOI6qr2NvVUHH6r6PqXltMBoT/JU4pO7QOXfuok8zYGOj2gMzktCOdvEoydh+8QrsKN6W3mpg8oweQoq/2tgRm/YTeJMz+MQMR+J+aid2lSu8/wiHtsv8WdxJ7qxI7G3DslzbTEz0e3FAP+CEFOVgwN+nitWZpSyst6WiupzU5Ou9NxYiWbmhy5nhDJFC+MiLs8bS2kWfq3z1/Wsuyp8hBn/T0+MYtQ/cRnAxTE0sUZMB5xpffuGPlx3lgrmxV7wlPAzGooCGkGVUQQ29ljYMoGWqW34EAniIYHLUnBfdS+bNbui5rxVEAFyD4r57eqEaYYOKCFwkhNfYhHUeV+noDQ8uvCzppvRHGKJrkUCS0/A6z4KP8iW8wsETMBX/ShvFf6m44KNGR4IPzfn+r7uO9Pbukit3RJ8RsHZcC3RmH7/gtO3vW8G1BBrT5nI6BdRp9a9LdTx2qn32cKQD7Wc7S1KyFYn5reWSewlxNHHmgy4R/pEXTtdZJ5tuzweeYK/jr78CTrgEEN/btNawNh6SdTwRls5lu33LAQ=--3mgI5AzJFOeaM6Y1--KgIowEbtW/iHXiDrWGk2ug== \ No newline at end of file +DHJSW4c/xFkMWWYW3b1uqnoiwuqVvRabPVvSKZWaoM+EPzUses5EaJ7GBXjpVS/4TYQ+0XsuLBTniuhUtFx56PHo1vWWFRy3n8I1YGPOs2X9mvG2wK5MlwSGO55xatQ1MabRyfjVJfW/inx4oWaqh4IsZhQ5uL/PUWwWy+7tKx3yWbPDa9K2vsgJuJJqZav0gJ7Bn5j8sdpj8xRofA0ZmiA3QxVIHOLiBYRXH9gmuzJ9BcDiAfZ1EA2FnM7T/2v5u6K4k5Lkp4Khn7t5kSF5hrUIuU7dWFaz4WFS6sfcCkRTHgPKu1ZfunzQl9vDXxkwVUjnfGnIk6XUQz8qfI1sDwhxjaiKAtn/6V8/zSo0v9lj91kfVWg/e8byzDxtZhRN71lnDgwaRIQxSDumfFIX2Yd5aXoQw7b+VymZaA+8J7vh18GPkBSsWTZ9b1fA1iVHncSDSEYH/FVXK3Ul/mpcN1bJrlBXMkZ0HmDA1EXhMJqPGosE6/OT5f1Zsn5SSpbqM6VZjQ6LOHwNCZs24F8BhGnCQtdl/VlIHKyGq8/zcyWUuROAVSxV9fiMDddWrprjDsjXPUigFqgtyco1CCZ9p6ScMihOzpOhT0dcvvjKZY56e3GR6n7u5JfSL9CpykQOXATHBXJlAc3HYxPxfESkEa31VmwZGOZxUsTpIZDLC/7VCHXGgtvOYLfoPBJBZ7BP8Eun+q/hz4iAr40xlfSeg3G18JpQZYHeF2/3WucrTW8NinwkPW/Yr/jtd9duyuIZNY2/zGyLiyoKiuHnk/iVDsyLIth1P7ZoQNyyiITzB/OQ2KqAvqwjnNYqLfBAQfe6grBdAGrNInsBHc7HNMcuHP9bvvS2cjlepP4zZyKo5oiAkmaWUPuBkUqmY6ZcOmwLTwl//fBXlUzCuUhR+0k7af6IlCsDhmp0HL575JmYN+HKGZ2LtQo+1v4MFJR5c2gh0Q4zTAkJsaOz+39aI5e2VPXkpXNyz+VVFJO36lg9tpaomUgtI2BjrO3Y3qQ8Y5jilnSX2HslL4lFMLDvyphDYJx7ODPimUjNkJ616YXSgFJSBUVa/ui1CnxNqOfO+AtuxJz26cuQw8ORKZNihd0QT276pHGhnXb+B0JT+XCuniZnSF+oCn6Kmbt1bsElCN/sMrII7UdsRGD5sQWicjpOM0g50GPYF9z6v8tCYlEmTQSjp7/KOwKLo4l4re3KeuD4FuK/1n0x2CNm1MLbvXcOGkmCa3P79YsKl8L1x8+hbNoHpEpswc0dPb6FLthCxCqrGQiREO4pbTrLasVL6CJA0eRd39B/8AcXnsJwNj4B3a2PbafnkdhJMPAuXvieFFN6leMXBF0YCsg+1Br8h9YmZfzmkECo9JVkNCfYdNbkfo8AA+hHu/youi6vcJD+x+eKTVdi88jy9qwyDdzIf8GdiIHy00PnfXj4lgTx7SQr+WUpsUYnQZh8xqkAuh5Lgi7ExqZMjjo4FORFxoJHToNU6c44+aVb36P9Fg6v3YedINYZkRWMTUq/f9O1zoOx3wIE10Qk4SkF+MLPSuLJWbEmfCyNZZwFUH7xde9lSDfB1s+1XhtEz0Li3Kq8jCeftRcIZvQ3LVjFuirf6nfZmZoTjaVDyUiWHtXyrQ==--NSFlvZtAfCQmzAp3--o8XatBC+Yh/p55fY485A7g== \ No newline at end of file diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc index 8c7aab16b..53a7eea96 100644 --- a/config/credentials/test.yml.enc +++ b/config/credentials/test.yml.enc @@ -1 +1 @@ -0EWG0dpq/qELmTCqAgzEL2BZH7JCNFe6DDIynyKE06qiBCYhHs5+fMjmpzN0uwz43tqAAKJdhO/hZY4jXmTpxu+LSjwrUKAlxsWvSGu4vHEbJ5Fe+BzhnS84OFse6ma9akUAHaed3gt31wtrt2Y4AbSPedNvriqfYln7cvpgWbvjTgtEbHrcNMfjCBnaXXdO3n1zILunILgLiV7oBW0qurIrl2xOc4mbj4AqVAIElyh0md9fFtuBfsaRj2U9x/+7McbgTFODhHfeqoErEw7fc1RBFrNi2YNuHBSSqegQ6WtWbwq1Yg2Juv/Lw3g4IIdWadJsW3TdTMMiwmBMzOikx3lNX1Z399glvGNFVnJfBubKf6BxvFfAQ85PBzOZPl+/PTs=--GY44JMs5ZO1/eL6G--EhxEWuitiex6eHROpPQamw== \ No newline at end of file +UFzTQdDbGY5BJ/SHVPWzNlBZoO2+BF0eHHwGICuSB5pi8ESFOHrGhmT7a9qpKLl8cbBMTPPayqLEuuuRbbtXEBS3EiDgAP7XaDViQHHAzBwgaATFT7U+1svnAwppTrAToXJlk51BdPLVRzftY9VZJ9RoDTfzKo0WriuYZ8KIRHmJjaiNDJK4xnqKaqPpF6SU0OVW2Qpo6xAKJVYKlV6SEO2Nt6C2lYra02G49f7poBypJnqouDnn+zlrLd2VGfDXOqFdi+R8T+ZS4HIzHUPi2IpEyg9jFt7qCNpSX7gy2NPZEx2mQmLC0jf5E+ew5ytKqxX1HnoJ7u8IMFvM9gQuNyA1+gaWT42uAqhUB9JKMoyE25KOSnV548cOfxCLnRNb0TgIbp5hpl66yjG32bQkyrvkCRmLBtkii/aGiwpA63UP2DTy0C/Nn86PFXJslHnVxbm8iSCRbhlu91YbmG08pzyCZQe9nRhIX2QBPufIELaJiMcFKYGfQ4Hs30TVCQ3ATdnC1UfWa7BdUsMD92ckneSrexaNofrH4WaRxyIf4VMtfRXqVgv9ovv5I4sr5BwYrPN5WvS3ksvAaoOJ/EXeq+io5OMB2ItzPJucfw==--q/sQOAAr2yruQ50H--dPendfhTyJuxPPImcH2Vqg== \ No newline at end of file diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb new file mode 100644 index 000000000..2739f6ca1 --- /dev/null +++ b/config/initializers/vapid.rb @@ -0,0 +1,4 @@ +Rails.application.configure do + config.x.vapid.private_key = ENV.fetch("VAPID_PRIVATE_KEY", Rails.application.credentials.dig(:vapid, :private_key)) + config.x.vapid.public_key = ENV.fetch("VAPID_PUBLIC_KEY", Rails.application.credentials.dig(:vapid, :public_key)) +end diff --git a/config/initializers/web_push.rb b/config/initializers/web_push.rb new file mode 100644 index 000000000..ac960468e --- /dev/null +++ b/config/initializers/web_push.rb @@ -0,0 +1,44 @@ +require "web-push" +require "web_push/pool" +require "web_push/notification" + +Rails.application.configure do + config.x.web_push_pool = WebPush::Pool.new( + invalid_subscription_handler: ->(subscription_id) do + Rails.application.executor.wrap do + Rails.logger.info "Destroying push subscription: #{subscription_id}" + Push::Subscription.find_by(id: subscription_id)&.destroy + end + end + ) + + at_exit { config.x.web_push_pool.shutdown } +end + +module WebPush::PersistentRequest + def perform + if @options[:connection] + http = @options[:connection] + else + http = Net::HTTP.new(uri.host, uri.port, *proxy_options) + http.use_ssl = true + http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil? + http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil? + http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil? + end + + req = Net::HTTP::Post.new(uri.request_uri, headers) + req.body = body + + if http.is_a?(Net::HTTP::Persistent) + response = http.request uri, req + else + resp = http.request(req) + verify_response(resp) + end + + resp + end +end + +WebPush::Request.prepend WebPush::PersistentRequest diff --git a/config/routes.rb b/config/routes.rb index 6b61d0716..1d342ada7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,7 @@ Rails.application.routes.draw do resources :users do resource :role, module: :users + resources :push_subscriptions, module: :users end resources :collections do @@ -175,6 +176,7 @@ Rails.application.routes.draw do get "up", to: "rails/health#show", as: :rails_health_check get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + get "service-worker" => "pwa#service_worker" root "events#index" diff --git a/db/migrate/20250714222221_add_push_subscriptions.rb b/db/migrate/20250714222221_add_push_subscriptions.rb new file mode 100644 index 000000000..486d975ea --- /dev/null +++ b/db/migrate/20250714222221_add_push_subscriptions.rb @@ -0,0 +1,17 @@ +class AddPushSubscriptions < ActiveRecord::Migration[8.1] + def change + create_table :push_subscriptions do |t| + t.references :user, null: false, foreign_key: true + t.string :endpoint + t.string :p256dh_key + t.string :auth_key + t.string :user_agent + t.timestamps + + t.index [ :user_id, :endpoint ], unique: true + t.index :endpoint + t.index :user_agent + t.index [ "endpoint", "p256dh_key", "auth_key" ] + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 03cc51766..7c5a63f08 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -307,6 +307,21 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_22_132226) do t.index ["user_id"], name: "index_pins_on_user_id" end + create_table "push_subscriptions", force: :cascade do |t| + t.string "auth_key" + t.datetime "created_at", null: false + t.string "endpoint" + t.string "p256dh_key" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["endpoint", "p256dh_key", "auth_key"], name: "idx_on_endpoint_p256dh_key_auth_key_7553014576" + t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint" + t.index ["user_agent"], name: "index_push_subscriptions_on_user_agent" + t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true + t.index ["user_id"], name: "index_push_subscriptions_on_user_id" + end + create_table "reactions", force: :cascade do |t| t.integer "comment_id", null: false t.string "content", limit: 16, null: false @@ -424,6 +439,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_22_132226) do add_foreign_key "notifications", "users", column: "creator_id" add_foreign_key "pins", "cards" add_foreign_key "pins", "users" + add_foreign_key "push_subscriptions", "users" add_foreign_key "search_queries", "users" add_foreign_key "sessions", "users" add_foreign_key "steps", "cards" diff --git a/db/schema_cache.yml b/db/schema_cache.yml index a90d62239..09bb886c7 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -532,11 +532,11 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: all_access - cast_type: &30 !ruby/object:ActiveModel::Type::Boolean + cast_type: &31 !ruby/object:ActiveModel::Type::Boolean precision: scale: limit: - sql_type_metadata: &31 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: boolean type: :boolean limit: @@ -858,6 +858,51 @@ columns: - *6 - *9 - *25 + push_subscriptions: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: auth_key + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: endpoint + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: p256dh_key + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *9 + - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: user_agent + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *25 reactions: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -956,24 +1001,15 @@ columns: collation: comment: - *9 - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: user_agent - cast_type: *7 - sql_type_metadata: *8 - 'null': true - default: - default_function: - collation: - comment: + - *30 - *25 steps: - *22 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: completed - cast_type: *30 - sql_type_metadata: *31 + cast_type: *31 + sql_type_metadata: *32 'null': false default: false default_function: @@ -998,8 +1034,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: active - cast_type: *30 - sql_type_metadata: *31 + cast_type: *31 + sql_type_metadata: *32 'null': false default: true default_function: @@ -1058,8 +1094,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: watching - cast_type: *30 - sql_type_metadata: *31 + cast_type: *31 + sql_type_metadata: *32 'null': false default: true default_function: @@ -1128,6 +1164,7 @@ primary_keys: mentions: id notifications: id pins: id + push_subscriptions: id reactions: id schema_migrations: version search_queries: id @@ -1173,6 +1210,7 @@ data_sources: mentions: true notifications: true pins: true + push_subscriptions: true reactions: true schema_migrations: true search_queries: true @@ -2269,6 +2307,90 @@ indexes: nulls_not_distinct: comment: valid: true + push_subscriptions: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: idx_on_endpoint_p256dh_key_auth_key_7553014576 + unique: false + columns: + - endpoint + - p256dh_key + - auth_key + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: index_push_subscriptions_on_endpoint + unique: false + columns: + - endpoint + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: index_push_subscriptions_on_user_agent + unique: false + columns: + - user_agent + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: index_push_subscriptions_on_user_id + unique: false + columns: + - user_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: index_push_subscriptions_on_user_id_and_endpoint + unique: true + columns: + - user_id + - endpoint + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true reactions: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: reactions diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb new file mode 100644 index 000000000..891f53799 --- /dev/null +++ b/lib/web_push/notification.rb @@ -0,0 +1,29 @@ +class WebPush::Notification + def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:) + @title, @body, @path, @badge = title, body, path, badge + @endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key + end + + def deliver(connection: nil) + WebPush.payload_send \ + message: encoded_message, + endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key, + vapid: vapid_identification, + connection: connection, + urgency: "high" + end + + private + def vapid_identification + { subject: "mailto:support@37signals.com" }.merge \ + Rails.configuration.x.vapid.symbolize_keys + end + + def encoded_message + JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } } + end + + def icon_path + "/apple-touch-icon.png" + end +end diff --git a/lib/web_push/pool.rb b/lib/web_push/pool.rb new file mode 100644 index 000000000..2a0533ae3 --- /dev/null +++ b/lib/web_push/pool.rb @@ -0,0 +1,56 @@ +# This is in lib so we can use it in a thread pool without the Rails executor +class WebPush::Pool + attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler + + def initialize(invalid_subscription_handler:) + @delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000) + @invalidation_pool = Concurrent::FixedThreadPool.new(1) + @connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150) + @invalid_subscription_handler = invalid_subscription_handler + end + + def queue(payload, subscriptions) + subscriptions.find_each do |subscription| + deliver_later(payload, subscription) + end + end + + def shutdown + connection.shutdown + shutdown_pool(delivery_pool) + shutdown_pool(invalidation_pool) + end + + private + def deliver_later(payload, subscription) + # Ensure any AR operations happen before we post to the thread pool + notification = subscription.notification(**payload) + subscription_id = subscription.id + + delivery_pool.post do + deliver(notification, subscription_id) + rescue Exception => e + Rails.logger.error "Error in WebPush::Pool.deliver: #{e.class} #{e.message}" + end + rescue Concurrent::RejectedExecutionError + end + + def deliver(notification, id) + notification.deliver(connection: connection) + rescue WebPush::ExpiredSubscription, OpenSSL::OpenSSLError => ex + invalidate_subscription_later(id) if invalid_subscription_handler + end + + def invalidate_subscription_later(id) + invalidation_pool.post do + invalid_subscription_handler.call(id) + rescue Exception => e + Rails.logger.error "Error in WebPush::Pool.invalid_subscription_handler: #{e.class} #{e.message}" + end + end + + def shutdown_pool(pool) + pool.shutdown + pool.kill unless pool.wait_for_termination(1) + end +end diff --git a/test/controllers/users/push_subscriptions_controller_test.rb b/test/controllers/users/push_subscriptions_controller_test.rb new file mode 100644 index 000000000..9c77dec9d --- /dev/null +++ b/test/controllers/users/push_subscriptions_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + end + + test "create new push subscription" do + subscription_params = { "endpoint" => "https://apple", "p256dh_key" => "123", "auth_key" => "456" } + + post user_push_subscriptions_path(users(:david)), + params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" } + + assert_response :ok + + assert_equal subscription_params, users(:david).push_subscriptions.last.attributes.slice("endpoint", "p256dh_key", "auth_key") + assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent + end + + test "touch existing subscription" do + existing_subscription = users(:david).push_subscriptions.create!( + endpoint: "https://apple", + p256dh_key: "123", + auth_key: "456" + ) + + assert_no_difference -> { users(:david).push_subscriptions.count } do + assert_changes -> { existing_subscription.reload.updated_at } do + post user_push_subscriptions_path(users(:david)), params: { + push_subscription: existing_subscription.attributes.slice("endpoint", "p256dh_key", "auth_key") + } + end + end + + assert_response :ok + end + + test "destroy a push subscription" do + subscription = users(:david).push_subscriptions.create!( + endpoint: "https://apple", + p256dh_key: "123", + auth_key: "456" + ) + + assert_difference -> { Push::Subscription.count }, -1 do + delete user_push_subscription_path(users(:david), subscription) + assert_redirected_to user_push_subscriptions_path(users(:david)) + end + end +end