From 2e7a7384ab2e3415d76aeaaffa0ac4a7e25607fb Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 14 Jul 2025 17:22:07 -0500 Subject: [PATCH 01/35] Add gems for web push --- Gemfile | 2 ++ Gemfile.lock | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/Gemfile b/Gemfile index 61515a304..32596d5aa 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 bcd197099..90016d233 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -370,6 +370,8 @@ GEM mysql2 (0.5.6) 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 @@ -394,6 +396,7 @@ GEM racc (~> 1.4) nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) + openssl (3.3.0) ostruct (0.6.2) parallel (1.27.0) parser (3.3.8.0) @@ -558,6 +561,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) @@ -598,6 +604,7 @@ DEPENDENCIES mission_control-jobs mocha mysql2 + net-http-persistent platform_agent portfolio (>= 4.6)! propshaft @@ -625,6 +632,7 @@ DEPENDENCIES tiktoken_ruby turbo-rails vcr + web-push webmock BUNDLED WITH From e59a994a0c18172da70e4aa91e4e5148d6b4e941 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 14 Jul 2025 19:57:30 -0500 Subject: [PATCH 02/35] Add db table and models --- app/models/push.rb | 5 + app/models/push/subscription.rb | 7 + app/models/user.rb | 1 + app/views/layouts/shared/_head.html.erb | 1 + config/credentials/development.yml.enc | 2 +- config/initializers/vapid.rb | 4 + config/initializers/web_push.rb | 44 +++++ .../20250714222221_add_push_subscriptions.rb | 17 ++ db/schema.rb | 18 +- db/schema_cache.yml | 168 +++++++++++++++--- lib/web_push/notification.rb | 29 +++ lib/web_push/pool.rb | 56 ++++++ 12 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 app/models/push.rb create mode 100644 app/models/push/subscription.rb create mode 100644 config/initializers/vapid.rb create mode 100644 config/initializers/web_push.rb create mode 100644 db/migrate/20250714222221_add_push_subscriptions.rb create mode 100644 lib/web_push/notification.rb create mode 100644 lib/web_push/pool.rb diff --git a/app/models/push.rb b/app/models/push.rb new file mode 100644 index 000000000..d0ec04a37 --- /dev/null +++ b/app/models/push.rb @@ -0,0 +1,5 @@ +module Push + def self.table_name_prefix + "push_" + end +end \ No newline at end of file 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 4c1370619..dc7f13aad 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 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/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index ce2cc3c0f..1286f0d86 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 +kK/zGHEsgRGIoP27w3Pvm2UXQTyxJFDlPTgsX0OwazJ32RB118DfCJV7dM9OBLdTpPu9DowQsSjObX893BuQgplwYTAuq9zndq7p1qsxzwv/IMgGeo7Nv+h90lnQVWrp/vwfEBZ/BZ4n448Q+TndWLJzUIL1So5dkMj/swvB2ORE8X+2mkOSa9y2yIx51ro0UZaBOfphy+fZUUBeCK/yTDcqXkgPc4wXpZ3de5XGed5JjeMF2PaCoi7plF2SW8XBsThipmG7N92ojkfiEH7zX12ZUjd3UFTqo2xGgM5NAivbHIev9RcsIjXFuJu/T3I6PbFMEw4cSFCBFCesag4H+74DVkT+3bdWF9H+Ag1/ePYzFDygjWaQDehGDUbeXoz7XehYhLy8hErUWnmh4kbekt9in6EskayBZkyr/Mq2MVBydOywY4oVQuk1bEnM/Akwq/6sVYbtupXfIUIETHpSTM/IyQM0ht4c6cjNKZVM48Cr6+2hgdFN4oEjl5J0FDUNNaI3vfOebNtLv4lcF6f4O8kJ5KNe+iKbD376jSlXfeBcoj9O7kStpGGye54/d/dKH6UVbNQReBf9FbqNxoPKYIN2Uc+Bs0Kgo25FHISLFJstimQcgdJ71djRs6lpKCKX0QzKdt6QGpJJlsBZ7e8T89m71WggX3YSm2aW4aV7tnQmFlJODOcjXAAHaAB4kL1browf9/4tiPnoWFXoaISUqzmYn8n4Ao8xjW2G2bK75gEruUhDaSMBgXNLgG/8isN6Tdw4bSnluNdY6OHIcFDk7GPEbBvUs4C11P30uDLuztbEHTahbmXhkhZ4tpLLpy2y5dcYY37gvFIqsbHCXR5PTA2JrgrCAeUoF+f2sPn/USicJIFBNclppULoF7VnQJyRiwdUXN/T1ZG/CZH769GILEEG--XCcg7yRJcGpr4qyd--pE4nqwEeUck8ZAgJrR3B8A== \ No newline at end of file diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb new file mode 100644 index 000000000..72f4d3759 --- /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 \ No newline at end of file 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/db/migrate/20250714222221_add_push_subscriptions.rb b/db/migrate/20250714222221_add_push_subscriptions.rb new file mode 100644 index 000000000..5dbaa0dbc --- /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 \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index 7f5461614..8e8049180 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) do +ActiveRecord::Schema[8.1].define(version: 2025_07_14_222221) do create_table "accesses", force: :cascade do |t| t.datetime "accessed_at" t.integer "collection_id", null: false @@ -299,6 +299,21 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) 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 @@ -416,6 +431,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) 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 1f58a58a8..45893d5c7 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -451,7 +451,7 @@ columns: default_function: collation: comment: - - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: title cast_type: *7 @@ -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: @@ -756,7 +756,7 @@ columns: comment: filters_tags: - *19 - - &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: tag_id cast_type: *3 @@ -843,6 +843,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: @@ -941,24 +986,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: @@ -981,19 +1017,19 @@ columns: - *22 - *5 - *6 - - *32 + - *33 - *9 tags: - *5 - *6 - - *33 + - *34 - *9 users: - !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: @@ -1052,8 +1088,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: @@ -1121,6 +1157,7 @@ primary_keys: mentions: id notifications: id pins: id + push_subscriptions: id reactions: id schema_migrations: version search_queries: id @@ -1165,6 +1202,7 @@ data_sources: mentions: true notifications: true pins: true + push_subscriptions: true reactions: true schema_migrations: true search_queries: true @@ -2244,6 +2282,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 @@ -2516,4 +2638,4 @@ indexes: comment: valid: true workflows: [] -version: 20250703193928 +version: 20250714222221 diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb new file mode 100644 index 000000000..2ad156c1c --- /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 + Rails.application.routes.url_helpers.account_logo_path + 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 From 38d13f78a3829f05e43aa0ef7d430b4f839f3026 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 13:16:18 -0500 Subject: [PATCH 03/35] Add pusher models and job --- app/jobs/push_notification_job.rb | 7 ++ app/models/concerns/push_notifiable.rb | 12 +++ app/models/notification.rb | 2 + app/models/notification_pusher.rb | 118 +++++++++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 app/jobs/push_notification_job.rb create mode 100644 app/models/concerns/push_notifiable.rb create mode 100644 app/models/notification_pusher.rb diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb new file mode 100644 index 000000000..0dc01c56e --- /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 \ No newline at end of file diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb new file mode 100644 index 000000000..5885e77e7 --- /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 \ No newline at end of file diff --git a/app/models/notification.rb b/app/models/notification.rb index 73525f4ff..c1aa67e4d 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..213b142fa --- /dev/null +++ b/app/models/notification_pusher.rb @@ -0,0 +1,118 @@ +class NotificationPusher + 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 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 + + case event.action + when "comment_created" + { + title: "RE: #{card_notification_title(card)}", + body: comment_notification_body(event), + path: collection_card_path(card.collection, card) + } + when "card_assigned" + { + title: card_notification_title(card), + body: "Assigned to you by #{event.creator.name}", + path: collection_card_path(card.collection, card) + } + when "card_published" + { + title: card_notification_title(card), + body: "Added by #{event.creator.name}", + path: collection_card_path(card.collection, card) + } + when "card_closed" + { + title: card_notification_title(card), + body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}", + path: collection_card_path(card.collection, card) + } + when "card_reopened" + { + title: card_notification_title(card), + body: "Reopened by #{event.creator.name}", + path: collection_card_path(card.collection, card) + } + else + { + title: card_notification_title(card), + body: event.creator.name, + path: collection_card_path(card.collection, card) + } + 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: collection_card_path(card.collection, card) + } + end + + def build_default_payload + { + title: "New notification", + body: "You have a new notification", + path: 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 +end \ No newline at end of file From 8f47db3cede41a45563c1df2c4d518ea6b935f05 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 15:22:32 -0500 Subject: [PATCH 04/35] Add service worker and subscriptions controllers --- app/controllers/pwa_controller.rb | 12 ++ .../users/push_subscriptions_controller.rb | 30 +++++ app/javascript/controllers/notifications.js | 106 ++++++++++++++++++ app/javascript/helpers/turbo_helpers.js | 3 + app/views/pwa/service_worker.js | 30 +++++ app/views/users/show.html.erb | 7 ++ config/routes.rb | 4 +- 7 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 app/controllers/pwa_controller.rb create mode 100644 app/controllers/users/push_subscriptions_controller.rb create mode 100644 app/javascript/controllers/notifications.js create mode 100644 app/javascript/helpers/turbo_helpers.js create mode 100644 app/views/pwa/service_worker.js diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb new file mode 100644 index 000000000..841c8e921 --- /dev/null +++ b/app/controllers/pwa_controller.rb @@ -0,0 +1,12 @@ +class PwaController < ApplicationController + allow_unauthenticated_access + skip_forgery_protection + + # We need a stable URL at the root, so we can't use the regular asset path here. + def service_worker + end + + # Need ERB interpolation for paths, so can't use asset path here either. + def manifest + end +end \ No newline at end of file diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb new file mode 100644 index 000000000..bf5e5f87e --- /dev/null +++ b/app/controllers/users/push_subscriptions_controller.rb @@ -0,0 +1,30 @@ +class Users::PushSubscriptionsController < ApplicationController + before_action :set_push_subscriptions + + def index + end + + def create + if subscription = @push_subscriptions.find_by(push_subscription_params) + subscription.touch + else + @push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent) + end + + head :ok + end + + def destroy + @push_subscriptions.destroy_by(id: params[:id]) + redirect_to user_push_subscriptions_url + end + + private + def set_push_subscriptions + @push_subscriptions = Current.user.push_subscriptions + end + + def push_subscription_params + params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key) + end +end diff --git a/app/javascript/controllers/notifications.js b/app/javascript/controllers/notifications.js new file mode 100644 index 000000000..49fcc7f5a --- /dev/null +++ b/app/javascript/controllers/notifications.js @@ -0,0 +1,106 @@ +import { Controller } from "@hotwired/stimulus" +import { post } from "@rails/request.js" +import { pageIsTurboPreview } from "helpers/turbo_helpers" +import { onNextEventLoopTick } from "helpers/timing_helpers" + +console.log("Notifications controller module loaded") + +export default class extends Controller { + static values = { subscriptionsUrl: String } + + async connect() { + console.log("connected") + if (!pageIsTurboPreview()) { + if (window.notificationsPreviouslyReady) { + onNextEventLoopTick(() => this.dispatch("ready")) + } else { + const firstTimeReady = await this.isEnabled() + + if (firstTimeReady) { + onNextEventLoopTick(() => this.dispatch("ready")) + window.notificationsPreviouslyReady = true + } + } + } + } + + async attemptToSubscribe() { + console.log("attemptToSubscribe") + if (this.#allowed) { + const registration = await this.#serviceWorkerRegistration || await this.#registerServiceWorker() + + switch(Notification.permission) { + case "denied": { break } + case "granted": { this.#subscribe(registration); break } + case "default": { this.#requestPermissionAndSubscribe(registration) } + } + } + } + + async isEnabled() { + if (this.#allowed) { + const registration = await this.#serviceWorkerRegistration + const existingSubscription = await registration?.pushManager?.getSubscription() + + return Notification.permission == "granted" && registration && existingSubscription + } else { + return false + } + } + + get #allowed() { + return navigator.serviceWorker && window.Notification + } + + get #serviceWorkerRegistration() { + return navigator.serviceWorker.getRegistration(window.location.host) + } + + #registerServiceWorker() { + return navigator.serviceWorker.register("/service-worker.js") + } + + async #subscribe(registration) { + registration.pushManager + .subscribe({ userVisibleOnly: true, applicationServerKey: this.#vapidPublicKey }) + .then(subscription => { + this.#syncPushSubscription(subscription) + this.dispatch("ready") + }) + } + + async #syncPushSubscription(subscription) { + const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" }) + if (!response.ok) subscription.unsubscribe() + } + + async #requestPermissionAndSubscribe(registration) { + const permission = await Notification.requestPermission() + if (permission === "granted") this.#subscribe(registration) + } + + get #vapidPublicKey() { + const encodedVapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content + return this.#urlBase64ToUint8Array(encodedVapidPublicKey) + } + + #extractJsonPayloadAsString(subscription) { + const { endpoint, keys: { p256dh, auth } } = subscription.toJSON() + return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } }) + } + + // VAPID public key comes encoded as base64 but service worker registration needs it as a Uint8Array + #urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") + + const rawData = window.atob(base64) + const outputArray = new Uint8Array(rawData.length) + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + + return outputArray + } +} diff --git a/app/javascript/helpers/turbo_helpers.js b/app/javascript/helpers/turbo_helpers.js new file mode 100644 index 000000000..b54f37354 --- /dev/null +++ b/app/javascript/helpers/turbo_helpers.js @@ -0,0 +1,3 @@ +export function pageIsTurboPreview() { + return document.documentElement.hasAttribute("data-turbo-preview") +} diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js new file mode 100644 index 000000000..e27c062f9 --- /dev/null +++ b/app/views/pwa/service_worker.js @@ -0,0 +1,30 @@ +self.addEventListener("push", async (event) => { + const data = await event.data.json() + event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ])) +}) + +async function showNotification({ title, options }) { + return self.registration.showNotification(title, options) +} + +async function updateBadgeCount({ data: { badge } }) { + return self.navigator.setAppBadge?.(badge || 0) +} + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + const url = new URL(event.notification.data.path, self.location.origin).href + event.waitUntil(openURL(url)) +}) + +async function openURL(url) { + const clients = await self.clients.matchAll({ type: "window" }) + const focused = clients.find((client) => client.focused) + + if (focused) { + await focused.navigate(url) + } else { + await self.clients.openWindow(url) + } +} diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 5e60d6f9d..9ed623baf 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -8,6 +8,13 @@
<% if Current.user == @user %> + + + + <%= link_to edit_user_path(@user), class: "btn flex-item-justify-end" do %> <%= icon_tag "pencil" %> Edit diff --git a/config/routes.rb b/config/routes.rb index 07290777b..2813c8dea 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 @@ -169,7 +170,8 @@ Rails.application.routes.draw do end get "up", to: "rails/health#show", as: :rails_health_check - get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + get "manifest" => "pwa#manifest", as: :pwa_manifest + get "service-worker" => "pwa#service_worker" root "events#index" From 47324eb45ad9079e959fbe7ccb404477297ea242 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 16:42:47 -0500 Subject: [PATCH 05/35] Fix routes to manifest and service-worker, rename controller --- app/controllers/pwa_controller.rb | 8 ++------ .../{notifications.js => notifications_controller.js} | 3 ++- config/routes.rb | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) rename app/javascript/controllers/{notifications.js => notifications_controller.js} (97%) diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb index 841c8e921..bd6127182 100644 --- a/app/controllers/pwa_controller.rb +++ b/app/controllers/pwa_controller.rb @@ -1,12 +1,8 @@ class PwaController < ApplicationController - allow_unauthenticated_access + 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 - - # Need ERB interpolation for paths, so can't use asset path here either. - def manifest - end -end \ No newline at end of file +end diff --git a/app/javascript/controllers/notifications.js b/app/javascript/controllers/notifications_controller.js similarity index 97% rename from app/javascript/controllers/notifications.js rename to app/javascript/controllers/notifications_controller.js index 49fcc7f5a..87832326c 100644 --- a/app/javascript/controllers/notifications.js +++ b/app/javascript/controllers/notifications_controller.js @@ -57,7 +57,8 @@ export default class extends Controller { } #registerServiceWorker() { - return navigator.serviceWorker.register("/service-worker.js") + // Use absolute path to register from root, regardless of current account scope + return navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) } async #subscribe(registration) { diff --git a/config/routes.rb b/config/routes.rb index 2813c8dea..cc23438c4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -170,7 +170,7 @@ Rails.application.routes.draw do end get "up", to: "rails/health#show", as: :rails_health_check - get "manifest" => "pwa#manifest", as: :pwa_manifest + get "manifest" => "rails/pwa#manifest", as: :pwa_manifest get "service-worker" => "pwa#service_worker" root "events#index" From db9f670876b8ea094ac835add18717eed10b205b Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 17:03:28 -0500 Subject: [PATCH 06/35] Must include account slug to match manifest scope --- app/javascript/controllers/notifications_controller.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 87832326c..223d7cff0 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -53,12 +53,11 @@ export default class extends Controller { } get #serviceWorkerRegistration() { - return navigator.serviceWorker.getRegistration(window.location.host) + return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: window.location.pathname.split('/')[1]}) } #registerServiceWorker() { - // Use absolute path to register from root, regardless of current account scope - return navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) + return navigator.serviceWorker.register("/service-worker.js", { scope: window.location.pathname.split('/')[1]}) } async #subscribe(registration) { From c4d9918a828e816105bf8c784cb429b8e17bd1dd Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 17:58:57 -0500 Subject: [PATCH 07/35] Regenerate keys I think I accidentally switched the public and private keys the first time around --- config/credentials/development.yml.enc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index 1286f0d86..fbd189683 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -kK/zGHEsgRGIoP27w3Pvm2UXQTyxJFDlPTgsX0OwazJ32RB118DfCJV7dM9OBLdTpPu9DowQsSjObX893BuQgplwYTAuq9zndq7p1qsxzwv/IMgGeo7Nv+h90lnQVWrp/vwfEBZ/BZ4n448Q+TndWLJzUIL1So5dkMj/swvB2ORE8X+2mkOSa9y2yIx51ro0UZaBOfphy+fZUUBeCK/yTDcqXkgPc4wXpZ3de5XGed5JjeMF2PaCoi7plF2SW8XBsThipmG7N92ojkfiEH7zX12ZUjd3UFTqo2xGgM5NAivbHIev9RcsIjXFuJu/T3I6PbFMEw4cSFCBFCesag4H+74DVkT+3bdWF9H+Ag1/ePYzFDygjWaQDehGDUbeXoz7XehYhLy8hErUWnmh4kbekt9in6EskayBZkyr/Mq2MVBydOywY4oVQuk1bEnM/Akwq/6sVYbtupXfIUIETHpSTM/IyQM0ht4c6cjNKZVM48Cr6+2hgdFN4oEjl5J0FDUNNaI3vfOebNtLv4lcF6f4O8kJ5KNe+iKbD376jSlXfeBcoj9O7kStpGGye54/d/dKH6UVbNQReBf9FbqNxoPKYIN2Uc+Bs0Kgo25FHISLFJstimQcgdJ71djRs6lpKCKX0QzKdt6QGpJJlsBZ7e8T89m71WggX3YSm2aW4aV7tnQmFlJODOcjXAAHaAB4kL1browf9/4tiPnoWFXoaISUqzmYn8n4Ao8xjW2G2bK75gEruUhDaSMBgXNLgG/8isN6Tdw4bSnluNdY6OHIcFDk7GPEbBvUs4C11P30uDLuztbEHTahbmXhkhZ4tpLLpy2y5dcYY37gvFIqsbHCXR5PTA2JrgrCAeUoF+f2sPn/USicJIFBNclppULoF7VnQJyRiwdUXN/T1ZG/CZH769GILEEG--XCcg7yRJcGpr4qyd--pE4nqwEeUck8ZAgJrR3B8A== \ 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 From 291f4e2d14c57297f89c9ab8f7d458cd6a18a5c4 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 18:09:30 -0500 Subject: [PATCH 08/35] Now that the basics are working, update debug to catch real issues - I'll use these to update the UI later --- app/javascript/controllers/notifications_controller.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 223d7cff0..74ede838e 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -2,14 +2,10 @@ import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" import { pageIsTurboPreview } from "helpers/turbo_helpers" import { onNextEventLoopTick } from "helpers/timing_helpers" - -console.log("Notifications controller module loaded") - export default class extends Controller { static values = { subscriptionsUrl: String } async connect() { - console.log("connected") if (!pageIsTurboPreview()) { if (window.notificationsPreviouslyReady) { onNextEventLoopTick(() => this.dispatch("ready")) @@ -25,15 +21,16 @@ export default class extends Controller { } async attemptToSubscribe() { - console.log("attemptToSubscribe") if (this.#allowed) { const registration = await this.#serviceWorkerRegistration || await this.#registerServiceWorker() switch(Notification.permission) { - case "denied": { break } + case "denied": { console.log("Notification.permission: denied"); break } case "granted": { this.#subscribe(registration); break } case "default": { this.#requestPermissionAndSubscribe(registration) } } + } else { + console.log("Should display not-allowed notice") } } From 7dbb9c7a9e3a5317f3f2c802b5bb939d82da2b4d Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 18:47:44 -0500 Subject: [PATCH 09/35] Fix icon path --- lib/web_push/notification.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb index 2ad156c1c..25234ac35 100644 --- a/lib/web_push/notification.rb +++ b/lib/web_push/notification.rb @@ -24,6 +24,6 @@ class WebPush::Notification end def icon_path - Rails.application.routes.url_helpers.account_logo_path + "/app-icon.png" end end From c8eb1c616db172186b7ed9facdda24314d97590f Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 18:48:49 -0500 Subject: [PATCH 10/35] Need url helpers --- app/models/notification_pusher.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index 213b142fa..d54cba472 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -1,4 +1,6 @@ class NotificationPusher + include Rails.application.routes.url_helpers + attr_reader :notification def initialize(notification) @@ -34,7 +36,7 @@ class NotificationPusher def build_event_payload event = notification.source card = event.card - + case event.action when "comment_created" { @@ -78,7 +80,7 @@ class NotificationPusher def build_mention_payload mention = notification.source card = mention.card - + { title: "#{mention.mentioner.first_name} mentioned you", body: mention.source.mentionable_content.truncate(200), @@ -115,4 +117,4 @@ class NotificationPusher def strip_tags(text) ActionView::Base.full_sanitizer.sanitize(text) end -end \ No newline at end of file +end From 83f7bd4346f9e2f539bd554f400d611cea492048 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 15 Jul 2025 21:40:04 -0500 Subject: [PATCH 11/35] Use icon with background included --- lib/web_push/notification.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb index 25234ac35..891f53799 100644 --- a/lib/web_push/notification.rb +++ b/lib/web_push/notification.rb @@ -24,6 +24,6 @@ class WebPush::Notification end def icon_path - "/app-icon.png" + "/apple-touch-icon.png" end end From 3ddb5e536e561c0514a822c5aabbdf7c4b6df5bd Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 17:29:20 -0500 Subject: [PATCH 12/35] Use async method instead of getter, use root scope --- .../controllers/notifications_controller.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 74ede838e..ee3e1711e 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -22,7 +22,7 @@ export default class extends Controller { async attemptToSubscribe() { if (this.#allowed) { - const registration = await this.#serviceWorkerRegistration || await this.#registerServiceWorker() + const registration = await this.#getServiceWorkerRegistration() || await this.#registerServiceWorker() switch(Notification.permission) { case "denied": { console.log("Notification.permission: denied"); break } @@ -36,11 +36,12 @@ export default class extends Controller { async isEnabled() { if (this.#allowed) { - const registration = await this.#serviceWorkerRegistration + const registration = await this.#getServiceWorkerRegistration() const existingSubscription = await registration?.pushManager?.getSubscription() return Notification.permission == "granted" && registration && existingSubscription } else { + console.log("Not alllowed") return false } } @@ -49,12 +50,12 @@ export default class extends Controller { return navigator.serviceWorker && window.Notification } - get #serviceWorkerRegistration() { - return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: window.location.pathname.split('/')[1]}) + async #getServiceWorkerRegistration() { + return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" }) } #registerServiceWorker() { - return navigator.serviceWorker.register("/service-worker.js", { scope: window.location.pathname.split('/')[1]}) + return navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) } async #subscribe(registration) { From 04df81bc75ad202d74e1b06380ff627288c65bfb Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 17:49:07 -0500 Subject: [PATCH 13/35] Move to settings screen --- app/views/notifications/settings/show.html.erb | 12 +++++++++++- app/views/users/show.html.erb | 7 ------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index 89ee03f45..36288fa89 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -12,6 +12,16 @@ <% end %> -
+
+
+ + + +
+ +

Collections

<%= render partial: "notifications/settings/collection", collection: @collections, locals: { user: Current.user } %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 9ed623baf..5e60d6f9d 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -8,13 +8,6 @@
<% if Current.user == @user %> - - - - <%= link_to edit_user_path(@user), class: "btn flex-item-justify-end" do %> <%= icon_tag "pencil" %> Edit From 347e41037f2368a78a66edfaae6d70fa6f6c092d Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 17:54:23 -0500 Subject: [PATCH 14/35] Simplify connect method, stub a method that will display various states based on detection --- .../controllers/notifications_controller.js | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index ee3e1711e..9d1772ab2 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -6,18 +6,9 @@ export default class extends Controller { static values = { subscriptionsUrl: String } async connect() { - if (!pageIsTurboPreview()) { - if (window.notificationsPreviouslyReady) { - onNextEventLoopTick(() => this.dispatch("ready")) - } else { - const firstTimeReady = await this.isEnabled() - - if (firstTimeReady) { - onNextEventLoopTick(() => this.dispatch("ready")) - window.notificationsPreviouslyReady = true - } - } - } + const state = await this.getCurrentState() + + this.updateUI(state) } async attemptToSubscribe() { @@ -101,4 +92,35 @@ export default class extends Controller { return outputArray } + + updateUI(state) { + console.log(state) + } + + async getCurrentState() { + if (!this.#allowed) { + return { status: 'not-supported' } + } + + const registration = await this.#getServiceWorkerRegistration() + const subscription = await registration?.pushManager?.getSubscription() + + return { + permission: Notification.permission, + hasRegistration: !!registration, + hasSubscription: !!subscription, + status: this.#determineStatus(Notification.permission, registration, subscription) + } + } + + #determineStatus(permission, registration, subscription) { + if (permission === "denied") return "permission-denied" + if (permission === "default") return "permission-not-requested" + if (permission === "granted") { + if (!registration) return "service-worker-missing" + if (!subscription) return "subscription-missing" + return "enabled" + } + return "unknown" + } } From 1b52a860f3b0b2ecb6fc4c98d1d8207be6810b7b Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 17:55:12 -0500 Subject: [PATCH 15/35] No need to talk to other controllers right now --- app/javascript/controllers/notifications_controller.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 9d1772ab2..eff1f888d 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -54,7 +54,6 @@ export default class extends Controller { .subscribe({ userVisibleOnly: true, applicationServerKey: this.#vapidPublicKey }) .then(subscription => { this.#syncPushSubscription(subscription) - this.dispatch("ready") }) } From 3940d9d60f7018e9911688c5bf0d9e6c569213aa Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 19:30:30 -0500 Subject: [PATCH 16/35] Linux wet the bed on this, trim whitespace and ensure 8-bit values --- app/javascript/controllers/notifications_controller.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index eff1f888d..2d7dc65eb 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -79,6 +79,8 @@ export default class extends Controller { // VAPID public key comes encoded as base64 but service worker registration needs it as a Uint8Array #urlBase64ToUint8Array(base64String) { + base64String = base64String.trim() + const padding = "=".repeat((4 - base64String.length % 4) % 4) const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") @@ -86,7 +88,7 @@ export default class extends Controller { const outputArray = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i) + outputArray[i] = rawData.charCodeAt(i) & 0xff } return outputArray From 37e344d9f186588ff5cb46baeda42e8a656cb6e2 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 19:33:21 -0500 Subject: [PATCH 17/35] Add debug for Linux --- app/javascript/controllers/notifications_controller.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 2d7dc65eb..58164db56 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -69,7 +69,13 @@ export default class extends Controller { get #vapidPublicKey() { const encodedVapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content - return this.#urlBase64ToUint8Array(encodedVapidPublicKey) + console.log('Raw VAPID key:', encodedVapidPublicKey) + console.log('Key length:', encodedVapidPublicKey.length) + + const result = this.#urlBase64ToUint8Array(encodedVapidPublicKey) + console.log('Decoded key length:', result.length) + console.log('First few bytes:', Array.from(result.slice(0, 10))) + return result } #extractJsonPayloadAsString(subscription) { From b2334fc8bef8e08be5e152e78c8049f103288a3e Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 19:51:25 -0500 Subject: [PATCH 18/35] Revert "Add debug for Linux" This reverts commit 37e344d9f186588ff5cb46baeda42e8a656cb6e2. --- app/javascript/controllers/notifications_controller.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 58164db56..2d7dc65eb 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -69,13 +69,7 @@ export default class extends Controller { get #vapidPublicKey() { const encodedVapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content - console.log('Raw VAPID key:', encodedVapidPublicKey) - console.log('Key length:', encodedVapidPublicKey.length) - - const result = this.#urlBase64ToUint8Array(encodedVapidPublicKey) - console.log('Decoded key length:', result.length) - console.log('First few bytes:', Array.from(result.slice(0, 10))) - return result + return this.#urlBase64ToUint8Array(encodedVapidPublicKey) } #extractJsonPayloadAsString(subscription) { From 4ba3bbc4f123107a6103e26db757b434b56c9607 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 19:51:31 -0500 Subject: [PATCH 19/35] Revert "Linux wet the bed on this, trim whitespace and ensure 8-bit values" This reverts commit 3940d9d60f7018e9911688c5bf0d9e6c569213aa. --- app/javascript/controllers/notifications_controller.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 2d7dc65eb..eff1f888d 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -79,8 +79,6 @@ export default class extends Controller { // VAPID public key comes encoded as base64 but service worker registration needs it as a Uint8Array #urlBase64ToUint8Array(base64String) { - base64String = base64String.trim() - const padding = "=".repeat((4 - base64String.length % 4) % 4) const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") @@ -88,7 +86,7 @@ export default class extends Controller { const outputArray = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i) & 0xff + outputArray[i] = rawData.charCodeAt(i) } return outputArray From 74b5ac424490750b69a97cc7d65cd74a0fd3bece Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 19:54:29 -0500 Subject: [PATCH 20/35] Use urls --- app/models/notification_pusher.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index d54cba472..17be3149d 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -42,37 +42,37 @@ class NotificationPusher { title: "RE: #{card_notification_title(card)}", body: comment_notification_body(event), - path: collection_card_path(card.collection, card) + path: collection_card_url(card.collection, card) } when "card_assigned" { title: card_notification_title(card), body: "Assigned to you by #{event.creator.name}", - path: collection_card_path(card.collection, card) + path: collection_card_url(card.collection, card) } when "card_published" { title: card_notification_title(card), body: "Added by #{event.creator.name}", - path: collection_card_path(card.collection, card) + path: collection_card_url(card.collection, card) } when "card_closed" { title: card_notification_title(card), body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}", - path: collection_card_path(card.collection, card) + path: collection_card_url(card.collection, card) } when "card_reopened" { title: card_notification_title(card), body: "Reopened by #{event.creator.name}", - path: collection_card_path(card.collection, card) + path: collection_card_url(card.collection, card) } else { title: card_notification_title(card), body: event.creator.name, - path: collection_card_path(card.collection, card) + path: collection_card_url(card.collection, card) } end end @@ -84,7 +84,7 @@ class NotificationPusher { title: "#{mention.mentioner.first_name} mentioned you", body: mention.source.mentionable_content.truncate(200), - path: collection_card_path(card.collection, card) + path: collection_card_url(card.collection, card) } end @@ -92,7 +92,7 @@ class NotificationPusher { title: "New notification", body: "You have a new notification", - path: notifications_path + path: notifications_url } end From 045460cd60aed39ccd09455b07fd17dc2524f8e0 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 20:12:06 -0500 Subject: [PATCH 21/35] Revert "Use urls" This reverts commit 74b5ac424490750b69a97cc7d65cd74a0fd3bece. --- app/models/notification_pusher.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index 17be3149d..d54cba472 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -42,37 +42,37 @@ class NotificationPusher { title: "RE: #{card_notification_title(card)}", body: comment_notification_body(event), - path: collection_card_url(card.collection, card) + path: collection_card_path(card.collection, card) } when "card_assigned" { title: card_notification_title(card), body: "Assigned to you by #{event.creator.name}", - path: collection_card_url(card.collection, card) + path: collection_card_path(card.collection, card) } when "card_published" { title: card_notification_title(card), body: "Added by #{event.creator.name}", - path: collection_card_url(card.collection, card) + path: collection_card_path(card.collection, card) } when "card_closed" { title: card_notification_title(card), body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}", - path: collection_card_url(card.collection, card) + path: collection_card_path(card.collection, card) } when "card_reopened" { title: card_notification_title(card), body: "Reopened by #{event.creator.name}", - path: collection_card_url(card.collection, card) + path: collection_card_path(card.collection, card) } else { title: card_notification_title(card), body: event.creator.name, - path: collection_card_url(card.collection, card) + path: collection_card_path(card.collection, card) } end end @@ -84,7 +84,7 @@ class NotificationPusher { title: "#{mention.mentioner.first_name} mentioned you", body: mention.source.mentionable_content.truncate(200), - path: collection_card_url(card.collection, card) + path: collection_card_path(card.collection, card) } end @@ -92,7 +92,7 @@ class NotificationPusher { title: "New notification", body: "You have a new notification", - path: notifications_url + path: notifications_path } end From 4330cb4071099e31eddf4a1b9fbea528fc42674e Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 17 Jul 2025 22:20:16 -0500 Subject: [PATCH 22/35] Ensure notifications link to the correct account --- app/models/notification_pusher.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index d54cba472..1bf48ab23 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -42,37 +42,37 @@ class NotificationPusher { title: "RE: #{card_notification_title(card)}", body: comment_notification_body(event), - path: collection_card_path(card.collection, card) + path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" } when "card_assigned" { title: card_notification_title(card), body: "Assigned to you by #{event.creator.name}", - path: collection_card_path(card.collection, card) + path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" } when "card_published" { title: card_notification_title(card), body: "Added by #{event.creator.name}", - path: collection_card_path(card.collection, card) + path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" } when "card_closed" { title: card_notification_title(card), body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}", - path: collection_card_path(card.collection, card) + path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" } when "card_reopened" { title: card_notification_title(card), body: "Reopened by #{event.creator.name}", - path: collection_card_path(card.collection, card) + path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" } else { title: card_notification_title(card), body: event.creator.name, - path: collection_card_path(card.collection, card) + path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" } end end @@ -84,7 +84,7 @@ class NotificationPusher { title: "#{mention.mentioner.first_name} mentioned you", body: mention.source.mentionable_content.truncate(200), - path: collection_card_path(card.collection, card) + path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" } end @@ -92,7 +92,7 @@ class NotificationPusher { title: "New notification", body: "You have a new notification", - path: notifications_path + path: "/#{Account.first.queenbee_id}#{notifications_path}" } end From 5fb0353660ea4fbaeba3b274f1bcd90e4a98ef4b Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 11:05:41 -0500 Subject: [PATCH 23/35] Update Gemfile.lock --- Gemfile.lock | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f5e03b415..ab3b0af3a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -110,6 +110,13 @@ GIT marcel (~> 1.0) zeitwerk (~> 2) +GIT + remote: https://github.com/jeremy/mysql2.git + revision: b5766f5d296745d6aed014c8c0b7d82ef149ade1 + branch: force_latin1_to_utf8 + specs: + mysql2 (0.5.4.latin1utf8) + GIT remote: https://github.com/rails/rails.git revision: 78d5233b1f36b678662f2bc00c386071aaafa7da @@ -368,7 +375,6 @@ GEM msgpack (1.8.0) multi_json (1.15.0) multipart-post (2.4.1) - mysql2 (0.5.6) net-http (0.6.0) uri net-http-persistent (4.0.6) @@ -604,7 +610,7 @@ DEPENDENCIES kamal! mission_control-jobs mocha - mysql2 + mysql2! net-http-persistent platform_agent portfolio (>= 4.6)! From c3b946c961c41c0edc33b091a68d8c39d45f6cf6 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 18:27:31 -0500 Subject: [PATCH 24/35] Design flow for enabling push and the various status that go with it --- app/assets/images/gear.svg | 1 + app/assets/images/install-edge.svg | 1 + app/assets/images/install.svg | 1 + app/assets/images/lifebuoy.svg | 1 + app/assets/images/sliders.svg | 1 + app/assets/images/switch.svg | 1 + app/assets/stylesheets/icons.css | 7 ++ app/assets/stylesheets/notifications.css | 48 ++++++++ app/assets/stylesheets/pwa.css | 14 +++ app/assets/stylesheets/utilities.css | 12 ++ .../controllers/notifications_controller.js | 65 ++++------- app/models/application_platform.rb | 25 ++++ .../notifications/settings/_browser.html.erb | 109 ++++++++++++++++++ .../notifications/settings/_install.html.erb | 35 ++++++ .../notifications/settings/_system.html.erb | 57 +++++++++ .../notifications/settings/show.html.erb | 34 ++++-- 16 files changed, 361 insertions(+), 51 deletions(-) create mode 100644 app/assets/images/gear.svg create mode 100644 app/assets/images/install-edge.svg create mode 100644 app/assets/images/install.svg create mode 100644 app/assets/images/lifebuoy.svg create mode 100644 app/assets/images/sliders.svg create mode 100644 app/assets/images/switch.svg create mode 100644 app/assets/stylesheets/pwa.css create mode 100644 app/views/notifications/settings/_browser.html.erb create mode 100644 app/views/notifications/settings/_install.html.erb create mode 100644 app/views/notifications/settings/_system.html.erb 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/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index eff1f888d..11820ba86 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -1,14 +1,24 @@ import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" -import { pageIsTurboPreview } from "helpers/turbo_helpers" -import { onNextEventLoopTick } from "helpers/timing_helpers" export default class extends Controller { + static classes = [ "enabled" ] + static targets = [ "subscribeButton", "explainer" ] static values = { subscriptionsUrl: String } - - async connect() { - const state = await this.getCurrentState() - this.updateUI(state) + async connect() { + if (this.#allowed && Notification.permission == "default") { + this.subscribeButtonTarget.hidden = false + this.explainerTarget.hidden = true + } + + if (this.#allowed && Notification.permission == "granted") { + const registration = await this.#getServiceWorkerRegistration() + const subscription = await registration?.pushManager?.getSubscription() + + if (registration && subscription) { + this.element.classList.add(this.enabledClass) + } + } } async attemptToSubscribe() { @@ -20,8 +30,6 @@ export default class extends Controller { case "granted": { this.#subscribe(registration); break } case "default": { this.#requestPermissionAndSubscribe(registration) } } - } else { - console.log("Should display not-allowed notice") } } @@ -31,9 +39,6 @@ export default class extends Controller { const existingSubscription = await registration?.pushManager?.getSubscription() return Notification.permission == "granted" && registration && existingSubscription - } else { - console.log("Not alllowed") - return false } } @@ -59,7 +64,12 @@ export default class extends Controller { async #syncPushSubscription(subscription) { const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" }) - if (!response.ok) subscription.unsubscribe() + if (response.ok) { + this.element.classList.add(this.enabledClass) + this.subscribeButtonTarget.hidden = true + } else { + subscription.unsubscribe() + } } async #requestPermissionAndSubscribe(registration) { @@ -91,35 +101,4 @@ export default class extends Controller { return outputArray } - - updateUI(state) { - console.log(state) - } - - async getCurrentState() { - if (!this.#allowed) { - return { status: 'not-supported' } - } - - const registration = await this.#getServiceWorkerRegistration() - const subscription = await registration?.pushManager?.getSubscription() - - return { - permission: Notification.permission, - hasRegistration: !!registration, - hasSubscription: !!subscription, - status: this.#determineStatus(Notification.permission, registration, subscription) - } - } - - #determineStatus(permission, registration, subscription) { - if (permission === "denied") return "permission-denied" - if (permission === "default") return "permission-not-requested" - if (permission === "granted") { - if (!registration) return "service-worker-missing" - if (!subscription) return "subscription-missing" - return "enabled" - } - return "unknown" - } } 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/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 36288fa89..74dd03731 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -12,16 +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 } %>
From 9b6995194cda3e68dfba877b4c4a190e34658b74 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 18:49:18 -0500 Subject: [PATCH 25/35] Break sooner, use switch --- .../controllers/notifications_controller.js | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 11820ba86..f98f4eba0 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -6,18 +6,21 @@ export default class extends Controller { static values = { subscriptionsUrl: String } async connect() { - if (this.#allowed && Notification.permission == "default") { - this.subscribeButtonTarget.hidden = false - this.explainerTarget.hidden = true - } + if (!this.#allowed) return - if (this.#allowed && Notification.permission == "granted") { - const registration = await this.#getServiceWorkerRegistration() - const subscription = await registration?.pushManager?.getSubscription() + 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) - } + if (registration && subscription) { + this.element.classList.add(this.enabledClass) + } + break } } From 211403f584ae87b6641710e558169875c144c709 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 18:49:29 -0500 Subject: [PATCH 26/35] Remove debug --- app/javascript/controllers/notifications_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index f98f4eba0..3780e206d 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -29,7 +29,7 @@ export default class extends Controller { const registration = await this.#getServiceWorkerRegistration() || await this.#registerServiceWorker() switch(Notification.permission) { - case "denied": { console.log("Notification.permission: denied"); break } + case "denied": { break } case "granted": { this.#subscribe(registration); break } case "default": { this.#requestPermissionAndSubscribe(registration) } } From 97022be9294e4c75a9ec83bb07aa805d83bc7281 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 19:01:48 -0500 Subject: [PATCH 27/35] Clean up --- app/models/notification_pusher.rb | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index 1bf48ab23..2f3df0e49 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -16,6 +16,10 @@ class NotificationPusher end private + def account_prefix + "/#{Account.first.queenbee_id}" + end + def should_push? notification.user.push_subscriptions.any? && !notification.creator.system? && @@ -42,37 +46,37 @@ class NotificationPusher { title: "RE: #{card_notification_title(card)}", body: comment_notification_body(event), - path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" + path: "#{account_prefix}#{collection_card_path(card.collection, card)}" } when "card_assigned" { title: card_notification_title(card), body: "Assigned to you by #{event.creator.name}", - path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" + path: "#{account_prefix}#{collection_card_path(card.collection, card)}" } when "card_published" { title: card_notification_title(card), body: "Added by #{event.creator.name}", - path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" + path: "#{account_prefix}#{collection_card_path(card.collection, card)}" } when "card_closed" { title: card_notification_title(card), body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}", - path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" + path: "#{account_prefix}#{collection_card_path(card.collection, card)}" } when "card_reopened" { title: card_notification_title(card), body: "Reopened by #{event.creator.name}", - path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" + path: "#{account_prefix}#{collection_card_path(card.collection, card)}" } else { title: card_notification_title(card), body: event.creator.name, - path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" + path: "#{account_prefix}#{collection_card_path(card.collection, card)}" } end end @@ -84,7 +88,7 @@ class NotificationPusher { title: "#{mention.mentioner.first_name} mentioned you", body: mention.source.mentionable_content.truncate(200), - path: "/#{Account.first.queenbee_id}#{collection_card_path(card.collection, card)}" + path: "#{account_prefix}#{collection_card_path(card.collection, card)}" } end @@ -92,7 +96,7 @@ class NotificationPusher { title: "New notification", body: "You have a new notification", - path: "/#{Account.first.queenbee_id}#{notifications_path}" + path: "#{account_prefix}#{notifications_path}" } end From 2593fe2221f86636de114024fa09eedf3706d5d1 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 19:58:11 -0500 Subject: [PATCH 28/35] DRY --- app/models/notification_pusher.rb | 60 +++++++++++++++---------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index 2f3df0e49..ffba4af8b 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -41,43 +41,37 @@ class NotificationPusher event = notification.source card = event.card + base_payload = { + title: card_notification_title(card), + path: card_path(card) + } + case event.action when "comment_created" - { - title: "RE: #{card_notification_title(card)}", - body: comment_notification_body(event), - path: "#{account_prefix}#{collection_card_path(card.collection, card)}" - } + base_payload.merge( + title: "RE: #{base_payload[:title]}", + body: comment_notification_body(event) + ) when "card_assigned" - { - title: card_notification_title(card), - body: "Assigned to you by #{event.creator.name}", - path: "#{account_prefix}#{collection_card_path(card.collection, card)}" - } + base_payload.merge( + body: "Assigned to you by #{event.creator.name}" + ) when "card_published" - { - title: card_notification_title(card), - body: "Added by #{event.creator.name}", - path: "#{account_prefix}#{collection_card_path(card.collection, card)}" - } + base_payload.merge( + body: "Added by #{event.creator.name}" + ) when "card_closed" - { - title: card_notification_title(card), - body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}", - path: "#{account_prefix}#{collection_card_path(card.collection, card)}" - } + base_payload.merge( + body: card.closure ? "Closed as \"#{card.closure.reason}\" by #{event.creator.name}" : "Closed by #{event.creator.name}" + ) when "card_reopened" - { - title: card_notification_title(card), - body: "Reopened by #{event.creator.name}", - path: "#{account_prefix}#{collection_card_path(card.collection, card)}" - } + base_payload.merge( + body: "Reopened by #{event.creator.name}" + ) else - { - title: card_notification_title(card), - body: event.creator.name, - path: "#{account_prefix}#{collection_card_path(card.collection, card)}" - } + base_payload.merge( + body: event.creator.name + ) end end @@ -88,7 +82,7 @@ class NotificationPusher { title: "#{mention.mentioner.first_name} mentioned you", body: mention.source.mentionable_content.truncate(200), - path: "#{account_prefix}#{collection_card_path(card.collection, card)}" + path: card_path(card) } end @@ -121,4 +115,8 @@ class NotificationPusher 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 From f6d8d3021d44d8757209d69445f9675463c88231 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 20:06:41 -0500 Subject: [PATCH 29/35] Add controller test --- .../push_subscriptions_controller_test.rb | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/controllers/users/push_subscriptions_controller_test.rb 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 From 0afc738d199f359577790073244c5aff1bd557f8 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 20:11:03 -0500 Subject: [PATCH 30/35] Whitespace --- app/jobs/push_notification_job.rb | 2 +- app/models/concerns/push_notifiable.rb | 2 +- app/models/notification.rb | 2 +- app/models/push.rb | 2 +- config/initializers/vapid.rb | 2 +- db/migrate/20250714222221_add_push_subscriptions.rb | 8 ++++---- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb index 0dc01c56e..128cf6a18 100644 --- a/app/jobs/push_notification_job.rb +++ b/app/jobs/push_notification_job.rb @@ -4,4 +4,4 @@ class PushNotificationJob < ApplicationJob def perform(notification) NotificationPusher.new(notification).push end -end \ No newline at end of file +end diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb index 5885e77e7..e497bc3fe 100644 --- a/app/models/concerns/push_notifiable.rb +++ b/app/models/concerns/push_notifiable.rb @@ -9,4 +9,4 @@ module PushNotifiable def push_notification_later PushNotificationJob.perform_later(self) end -end \ No newline at end of file +end diff --git a/app/models/notification.rb b/app/models/notification.rb index c1aa67e4d..4eaf5786b 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,6 +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/push.rb b/app/models/push.rb index d0ec04a37..765323c0b 100644 --- a/app/models/push.rb +++ b/app/models/push.rb @@ -2,4 +2,4 @@ module Push def self.table_name_prefix "push_" end -end \ No newline at end of file +end diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb index 72f4d3759..2739f6ca1 100644 --- a/config/initializers/vapid.rb +++ b/config/initializers/vapid.rb @@ -1,4 +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 \ No newline at end of file +end diff --git a/db/migrate/20250714222221_add_push_subscriptions.rb b/db/migrate/20250714222221_add_push_subscriptions.rb index 5dbaa0dbc..486d975ea 100644 --- a/db/migrate/20250714222221_add_push_subscriptions.rb +++ b/db/migrate/20250714222221_add_push_subscriptions.rb @@ -7,11 +7,11 @@ class AddPushSubscriptions < ActiveRecord::Migration[8.1] t.string :auth_key t.string :user_agent t.timestamps - - t.index [:user_id, :endpoint], unique: true + + t.index [ :user_id, :endpoint ], unique: true t.index :endpoint t.index :user_agent - t.index ["endpoint", "p256dh_key", "auth_key"] + t.index [ "endpoint", "p256dh_key", "auth_key" ] end end -end \ No newline at end of file +end From 88dae3b0dbc3f593406bb5cb56d7b3e3c1af0890 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 21 Jul 2025 20:16:48 -0500 Subject: [PATCH 31/35] Didn't end up needing this --- app/javascript/helpers/turbo_helpers.js | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 app/javascript/helpers/turbo_helpers.js diff --git a/app/javascript/helpers/turbo_helpers.js b/app/javascript/helpers/turbo_helpers.js deleted file mode 100644 index b54f37354..000000000 --- a/app/javascript/helpers/turbo_helpers.js +++ /dev/null @@ -1,3 +0,0 @@ -export function pageIsTurboPreview() { - return document.documentElement.hasAttribute("data-turbo-preview") -} From 785de2f0cbecda8c8377beaa5958780601fa3540 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 22 Jul 2025 22:42:36 -0500 Subject: [PATCH 32/35] Whitespace --- app/javascript/controllers/notifications_controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 3780e206d..02fce86d7 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" + export default class extends Controller { static classes = [ "enabled" ] static targets = [ "subscribeButton", "explainer" ] From f413e19508ec7db72f48c48e2a32efae98f88d37 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 22 Jul 2025 22:45:23 -0500 Subject: [PATCH 33/35] Prefer `Account.sole` --- app/models/notification_pusher.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index ffba4af8b..8f68e4491 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -17,7 +17,7 @@ class NotificationPusher private def account_prefix - "/#{Account.first.queenbee_id}" + "/#{Account.sole.queenbee_id}" end def should_push? From 4619429db071b8e4ccc864008923494c8d389856 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 23 Jul 2025 11:53:10 -0500 Subject: [PATCH 34/35] Update Gemfile.lock --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c5955d651..76b2597a2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -529,11 +529,11 @@ GEM fugit (~> 1.11.0) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.7.3) - mini_portile2 (~> 2.8.0) 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) sshkit (1.24.0) base64 logger From 8f154b36c0f3a4090e1ca7524bbb391f8385cdd6 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 23 Jul 2025 12:31:23 -0500 Subject: [PATCH 35/35] Add vapid keys for remaining environments --- config/credentials/beta.yml.enc | 2 +- config/credentials/production.yml.enc | 2 +- config/credentials/staging.yml.enc | 2 +- config/credentials/test.yml.enc | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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