diff --git a/Gemfile b/Gemfile index dec8be79..e4e5d085 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,7 @@ gem "haml", "~> 6.1" gem "hcaptcha", "~> 7.0" gem "mini_magick" gem "oj" +gem "rpush" gem "rqrcode" gem "rolify", "~> 6.0" diff --git a/Gemfile.lock b/Gemfile.lock index 48a91b8f..db901422 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -197,6 +197,8 @@ GEM hashie (5.0.0) hcaptcha (7.1.0) json + hkdf (0.3.0) + http-2 (0.11.0) http (4.4.1) addressable (~> 2.3) http-cookie (~> 1.0) @@ -268,6 +270,10 @@ GEM multipart-post (2.1.1) naught (1.1.0) nested_form (0.3.2) + net-http-persistent (4.0.1) + connection_pool (~> 2.2) + net-http2 (0.18.4) + http-2 (~> 0.11) net-imap (0.3.4) date net-protocol @@ -370,6 +376,16 @@ GEM rexml (3.2.5) rolify (6.0.0) rotp (6.2.0) + rpush (7.0.1) + activesupport (>= 5.2) + jwt (>= 1.5.6) + multi_json (~> 1.0) + net-http-persistent + net-http2 (~> 0.18, >= 0.18.3) + railties + rainbow + thor (>= 0.18.1, < 2.0) + webpush (~> 1.0) rqrcode (2.1.2) chunky_png (~> 1.0) rqrcode_core (~> 1.0) @@ -506,6 +522,9 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) + webpush (1.1.0) + hkdf (~> 0.2) + jwt (~> 2.0) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -567,6 +586,7 @@ DEPENDENCIES redcarpet redis rolify (~> 6.0) + rpush rqrcode rspec-its (~> 1.3) rspec-mocks diff --git a/app/controllers/ajax/web_push_controller.rb b/app/controllers/ajax/web_push_controller.rb new file mode 100644 index 00000000..e9723c54 --- /dev/null +++ b/app/controllers/ajax/web_push_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class Ajax::WebPushController < AjaxController + before_action :authenticate_user! + + def key + certificate = Rpush::Webpush::App.find_by(name: "webpush").certificate + + @response[:status] = :okay + @response[:success] = true + @response[:key] = JSON.parse(certificate)["public_key"] + end + + def check + params.permit(:endpoint) + + found = current_user.web_push_subscriptions.where("subscription ->> 'endpoint' = ?", params[:endpoint]).first + + @response[:status] = if found + if found.failures >= 3 + :failed + else + :subscribed + end + else + :unsubscribed + end + @response[:success] = true + end + + def subscribe + WebPushSubscription.create!( + user: current_user, + subscription: params[:subscription] + ) + + @response[:status] = :okay + @response[:success] = true + @response[:message] = t(".subscription_count", count: current_user.web_push_subscriptions.count) + end + + def unsubscribe # rubocop:disable Metrics/AbcSize + params.permit(:endpoint) + + removed = if params.key?(:endpoint) + current_user.web_push_subscriptions.where("subscription ->> 'endpoint' = ?", params[:endpoint]).destroy_all + else + current_user.web_push_subscriptions.destroy_all + end + + count = current_user.web_push_subscriptions.count + + @response[:status] = removed.any? ? :okay : :err + @response[:success] = removed.any? + @response[:message] = t(".subscription_count", count:) + @response[:count] = count + end +end diff --git a/app/controllers/settings/push_notifications_controller.rb b/app/controllers/settings/push_notifications_controller.rb new file mode 100644 index 00000000..f207cb32 --- /dev/null +++ b/app/controllers/settings/push_notifications_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::PushNotificationsController < ApplicationController + before_action :authenticate_user! + + def index + @subscriptions = current_user.web_push_subscriptions.active + end +end diff --git a/app/javascript/packs/application.ts b/app/javascript/packs/application.ts index e6a85b3e..c071d6d4 100644 --- a/app/javascript/packs/application.ts +++ b/app/javascript/packs/application.ts @@ -5,6 +5,7 @@ import { definitionsFromContext } from '@hotwired/stimulus-webpack-helpers'; import start from 'retrospring/common'; import initAnswerbox from 'retrospring/features/answerbox/index'; +import initCapabilities from 'retrospring/features/capabilities'; import initInbox from 'retrospring/features/inbox/index'; import initUser from 'retrospring/features/user'; import initSettings from 'retrospring/features/settings/index'; @@ -15,8 +16,10 @@ import initModeration from 'retrospring/features/moderation'; import initMemes from 'retrospring/features/memes'; import initLocales from 'retrospring/features/locales'; import initFront from 'retrospring/features/front'; +import initWebpush from 'retrospring/features/webpush'; start(); +document.addEventListener('turbo:load', initCapabilities); document.addEventListener('DOMContentLoaded', initAnswerbox); document.addEventListener('DOMContentLoaded', initInbox); document.addEventListener('DOMContentLoaded', initUser); @@ -28,6 +31,7 @@ document.addEventListener('DOMContentLoaded', initModeration); document.addEventListener('DOMContentLoaded', initMemes); document.addEventListener('turbo:load', initLocales); document.addEventListener('turbo:load', initFront); +document.addEventListener('turbo:load', initWebpush); window['Stimulus'] = Application.start(); const context = require.context('../retrospring/controllers', true, /\.ts$/); diff --git a/app/javascript/retrospring/features/answerbox/index.ts b/app/javascript/retrospring/features/answerbox/index.ts index c67de9e7..b0a3a897 100644 --- a/app/javascript/retrospring/features/answerbox/index.ts +++ b/app/javascript/retrospring/features/answerbox/index.ts @@ -7,10 +7,6 @@ import { answerboxSmileHandler } from './smile'; import { answerboxSubscribeHandler } from './subscribe'; export default (): void => { - if ('share' in navigator) { - document.body.classList.add('cap-web-share'); - } - registerEvents([ { type: 'click', target: '[name=ab-share]', handler: shareEventHandler, global: true }, { type: 'click', target: '[data-action=ab-submarine]', handler: answerboxSubscribeHandler, global: true }, diff --git a/app/javascript/retrospring/features/capabilities/index.ts b/app/javascript/retrospring/features/capabilities/index.ts new file mode 100644 index 00000000..bc0c29f8 --- /dev/null +++ b/app/javascript/retrospring/features/capabilities/index.ts @@ -0,0 +1,13 @@ +export default (): void => { + if ('share' in navigator) { + document.body.classList.add('cap-web-share'); + } + + if ('serviceWorker' in navigator) { + document.body.classList.add('cap-service-worker'); + } + + if ('Notification' in window) { + document.body.classList.add('cap-notification'); + } +} diff --git a/app/javascript/retrospring/features/webpush/dismiss.ts b/app/javascript/retrospring/features/webpush/dismiss.ts new file mode 100644 index 00000000..48e17885 --- /dev/null +++ b/app/javascript/retrospring/features/webpush/dismiss.ts @@ -0,0 +1,7 @@ +export function dismissHandler (event: Event): void { + event.preventDefault(); + + const sender: HTMLButtonElement = event.target as HTMLButtonElement; + sender.closest('.push-settings').classList.add('d-none'); + localStorage.setItem('dismiss-push-settings-prompt', 'true'); +} diff --git a/app/javascript/retrospring/features/webpush/enable.ts b/app/javascript/retrospring/features/webpush/enable.ts new file mode 100644 index 00000000..56088c35 --- /dev/null +++ b/app/javascript/retrospring/features/webpush/enable.ts @@ -0,0 +1,67 @@ +import { get, post } from '@rails/request.js'; +import I18n from "retrospring/i18n"; +import { showNotification } from "utilities/notifications"; + +export function enableHandler (event: Event): void { + event.preventDefault(); + const sender = event.target as HTMLButtonElement; + + try { + installServiceWorker() + .then(subscribe) + .then(async subscription => { + return Notification.requestPermission().then(permission => { + if (permission != "granted") { + return; + } + + post('/ajax/webpush', { + body: { + subscription + }, + contentType: 'application/json' + }).then(async response => { + const data = await response.json; + + if (data.success) { + new Notification(I18n.translate("frontend.push_notifications.subscribe.success.title"), { + body: I18n.translate("frontend.push_notifications.subscribe.success.body") + }); + + document.querySelectorAll('button[data-action="push-disable"], button[data-action="push-remove-all"]') + .forEach(button => button.classList.remove('d-none')); + + sender.classList.add('d-none'); + + document.getElementById('subscription-count').textContent = data.message; + } else { + new Notification(I18n.translate("frontend.push_notifications.fail.title"), { + body: I18n.translate("frontend.push_notifications.fail.body") + }); + } + }); + }); + }); + } catch (error) { + console.error("Failed to set up push notifications", error); + showNotification(I18n.translate("frontend.push_notifications.setup_fail")); + } +} + +async function installServiceWorker(): Promise { + return navigator.serviceWorker.register("/service_worker.js", { scope: "/" }); +} + +async function getServerKey(): Promise { + const response = await get("/ajax/webpush/key"); + const data = await response.json; + return Buffer.from(data.key, 'base64'); +} + +async function subscribe(registration: ServiceWorkerRegistration): Promise { + const key = await getServerKey(); + return await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key + }); +} diff --git a/app/javascript/retrospring/features/webpush/index.ts b/app/javascript/retrospring/features/webpush/index.ts new file mode 100644 index 00000000..bb5a154d --- /dev/null +++ b/app/javascript/retrospring/features/webpush/index.ts @@ -0,0 +1,46 @@ +import registerEvents from 'retrospring/utilities/registerEvents'; +import { enableHandler } from './enable'; +import { dismissHandler } from "./dismiss"; +import { unsubscribeHandler, checkSubscription } from "retrospring/features/webpush/unsubscribe"; + +let subscriptionChecked = false; + +export default (): void => { + const swCapable = 'serviceWorker' in navigator; + const notificationCapable = 'Notification' in window; + + if (swCapable && notificationCapable) { + const enableBtn = document.querySelector('button[data-action="push-enable"]'); + + navigator.serviceWorker.getRegistration().then(registration => + registration?.pushManager.getSubscription().then(subscription => { + if (subscription) { + document.querySelector('button[data-action="push-enable"]')?.classList.add('d-none'); + document.querySelector('[data-action="push-disable"]')?.classList.remove('d-none'); + + if (!subscriptionChecked) { + checkSubscription(subscription); + subscriptionChecked = true; + } + } else { + enableBtn?.classList.remove('d-none'); + + if (localStorage.getItem('dismiss-push-settings-prompt') == null) { + document.querySelector('.push-settings')?.classList.remove('d-none'); + } + } + })); + } + + registerEvents([ + {type: 'click', target: '[data-action="push-enable"]', handler: enableHandler, global: true}, + {type: 'click', target: '[data-action="push-dismiss"]', handler: dismissHandler, global: true}, + {type: 'click', target: '[data-action="push-disable"]', handler: unsubscribeHandler, global: true}, + { + type: 'click', + target: '[data-action="push-remove-all"]', + handler: unsubscribeHandler, + global: true + }, + ]); +} diff --git a/app/javascript/retrospring/features/webpush/unsubscribe.ts b/app/javascript/retrospring/features/webpush/unsubscribe.ts new file mode 100644 index 00000000..0b388634 --- /dev/null +++ b/app/javascript/retrospring/features/webpush/unsubscribe.ts @@ -0,0 +1,67 @@ +import { post, destroy } from '@rails/request.js'; +import { showErrorNotification, showNotification } from "utilities/notifications"; +import I18n from "retrospring/i18n"; + +export function unsubscribeHandler(): void { + navigator.serviceWorker.getRegistration() + .then(registration => registration.pushManager.getSubscription()) + .then(subscription => unsubscribeClient(subscription)) + .then(subscription => unsubscribeServer(subscription)) + .catch(error => { + showErrorNotification(I18n.translate("frontend.push_notifications.unsubscribe.error")); + console.error(error); + }); +} + +export function checkSubscription(subscription: PushSubscription): void { + post('/ajax/webpush/check', { + body: { + endpoint: subscription.endpoint + }, + contentType: 'application/json' + }).then(async response => { + const data = await response.json(); + + if (data.status == 'subscribed') return; + if (data.status == 'failed') await unsubscribeServer(subscription); + await unsubscribeClient(subscription); + }) +} + +async function unsubscribeClient(subscription?: PushSubscription): Promise { + subscription?.unsubscribe().then(success => { + if (!success) { + throw new Error("Failed to unsubscribe."); + } + }); + + return subscription; +} + +async function unsubscribeServer(subscription?: PushSubscription): Promise { + const body = subscription != null ? { endpoint: subscription.endpoint } : undefined; + + return destroy('/ajax/webpush', { + body, + contentType: 'application/json', + }).then(async response => { + const data = await response.json; + + if (data.success) { + showNotification(I18n.translate("frontend.push_notifications.unsubscribe.success")); + + document.getElementById('subscription-count').textContent = data.message; + + if (data.count == 0) { + document.querySelectorAll('button[data-action="push-disable"], button[data-action="push-remove-all"]') + .forEach(button => button.classList.add('d-none')); + } + + if (document.body.classList.contains('cap-service-worker') && document.body.classList.contains('cap-notification')) { + document.querySelector('button[data-action="push-enable"]')?.classList.remove('d-none'); + } + } else { + showErrorNotification(I18n.translate("frontend.push_notifications.unsubscribe.fail")); + } + }) +} diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index b5765b62..3500dd56 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -108,6 +108,7 @@ "components/mobile-nav", "components/notifications", "components/profile", +"components/push-settings", "components/question", "components/smiles", "components/themes", diff --git a/app/javascript/styles/components/_push-settings.scss b/app/javascript/styles/components/_push-settings.scss new file mode 100644 index 00000000..c5d38d94 --- /dev/null +++ b/app/javascript/styles/components/_push-settings.scss @@ -0,0 +1,7 @@ +.push-notifications { + &-unavailable { + body.cap-service-worker.cap-notification & { + display: none; + } + } +} diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 5c84a055..fbc631d6 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Inbox < ApplicationRecord belongs_to :user belongs_to :question @@ -24,4 +26,20 @@ class Inbox < ApplicationRecord self.question.destroy if self.question.can_be_removed? self.destroy end + + def as_push_notification + { + type: :inbox, + title: I18n.t( + "frontend.push_notifications.inbox.title", + user: if question.author_is_anonymous + user.profile.anon_display_name || APP_CONFIG["anonymous_name"] + else + question.user.profile.safe_name + end + ), + icon: question.author_is_anonymous ? "/icons/maskable_icon_x128.png" : question.user.profile_picture.url(:small), + body: question.content + } + end end diff --git a/app/models/notification/push_subscription_error.rb b/app/models/notification/push_subscription_error.rb new file mode 100644 index 00000000..2e6be2c7 --- /dev/null +++ b/app/models/notification/push_subscription_error.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Notification::PushSubscriptionError < Notification +end diff --git a/app/models/user.rb b/app/models/user.rb index 3ec6b9c7..a56bb514 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class User < ApplicationRecord +class User < ApplicationRecord # rubocop:disable Metrics/ClassLength include User::Relationship include User::Relationship::Follow include User::Relationship::Block @@ -9,6 +9,7 @@ class User < ApplicationRecord include User::BanMethods include User::InboxMethods include User::QuestionMethods + include User::PushNotificationMethods include User::ReactionMethods include User::RelationshipMethods include User::TimelineMethods @@ -42,6 +43,7 @@ class User < ApplicationRecord has_many :subscriptions, dependent: :destroy_async has_many :totp_recovery_codes, dependent: :destroy_async + has_many :web_push_subscriptions, dependent: :destroy_async has_one :profile, dependent: :destroy has_one :theme, dependent: :destroy diff --git a/app/models/user/push_notification_methods.rb b/app/models/user/push_notification_methods.rb new file mode 100644 index 00000000..01138633 --- /dev/null +++ b/app/models/user/push_notification_methods.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module User::PushNotificationMethods + def push_notification(app, resource) + raise ArgumentError("Resource must respond to `as_push_notification`") unless resource.respond_to? :as_push_notification + + web_push_subscriptions.active.find_each do |s| + n = Rpush::Webpush::Notification.new + n.app = app + n.registration_ids = [s.subscription.symbolize_keys] + n.data = { + message: resource.as_push_notification.to_json + } + n.save! + + PushNotificationWorker.perform_async(n.id) + end + end +end diff --git a/app/models/web_push_subscription.rb b/app/models/web_push_subscription.rb new file mode 100644 index 00000000..6821d10b --- /dev/null +++ b/app/models/web_push_subscription.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class WebPushSubscription < ApplicationRecord + belongs_to :user + + scope :active, -> { where(failures: ...3) } + scope :failed, -> { where(failures: 3..) } +end diff --git a/app/views/inbox/_push_settings.haml b/app/views/inbox/_push_settings.haml new file mode 100644 index 00000000..65c9e0db --- /dev/null +++ b/app/views/inbox/_push_settings.haml @@ -0,0 +1,5 @@ +.card.push-settings.d-none + .card-body + = t(".description") + %button.btn.btn-primary{ data: { action: "push-enable" } }= t("voc.y") + %button.btn{ data: { action: "push-dismiss" } }= t("voc.n") diff --git a/app/views/layouts/inbox.html.haml b/app/views/layouts/inbox.html.haml index e87d6cbb..b54b82b5 100644 --- a/app/views/layouts/inbox.html.haml +++ b/app/views/layouts/inbox.html.haml @@ -2,6 +2,7 @@ .row .col-sm-10.col-md-10.col-lg-9.mx-auto = render 'inbox/actions', delete_id: @delete_id, disabled: @disabled, inbox_count: @inbox_count + = render 'inbox/push_settings' = render 'layouts/messages' = yield diff --git a/app/views/notifications/type/_webpushsubscription.html.haml b/app/views/notifications/type/_webpushsubscription.html.haml new file mode 100644 index 00000000..ce9b4a80 --- /dev/null +++ b/app/views/notifications/type/_webpushsubscription.html.haml @@ -0,0 +1,10 @@ +.media.notification + .notification__icon + %span.fa-stack + %i.fa.fa-2x.fa-fw.fa-bell + %i.fa.fa-stack-1x.fa-fw.fa-exclamation-triangle.text-danger.pl-2 + .media-body + %h6.media-heading.notification__user + = t(".heading") + .notification__text + = t(".text_html", settings_push: link_to(t(".settings_push"), settings_push_notifications_path)) diff --git a/app/views/settings/_push_notifications.haml b/app/views/settings/_push_notifications.haml new file mode 100644 index 00000000..12438a32 --- /dev/null +++ b/app/views/settings/_push_notifications.haml @@ -0,0 +1,17 @@ +.card.push-notifications-settings + .card-body + %p= t('.description') + %p#subscription-count= t('.subscription_count', count: subscriptions.count) + + .push-notifications-unavailable.text-danger + %i.fa.fa-warning + = t('.unsupported') + + .push-notifications-current-target.d-none.text-success + %i.fa.fa-check + = t('.current_target') + + .button-group{ role: 'group' } + %button.btn.btn-primary{ data: { action: 'push-enable' }, class: 'd-none' }= t('.subscribe') + %button.btn.btn-primary{ data: { action: 'push-disable' }, class: 'd-none' }= t('.unsubscribe_current') + %button.btn.btn-danger{ data: { action: 'push-remove-all' }, class: subscriptions.empty? ? 'd-none' : '' }= t('.unsubscribe_all') diff --git a/app/views/settings/push_notifications/index.haml b/app/views/settings/push_notifications/index.haml new file mode 100644 index 00000000..9985eeff --- /dev/null +++ b/app/views/settings/push_notifications/index.haml @@ -0,0 +1,4 @@ += render "settings/push_notifications", subscriptions: @subscriptions + +- provide(:title, generate_title(t(".title"))) +- parent_layout "user/settings" diff --git a/app/views/tabs/_settings.html.haml b/app/views/tabs/_settings.html.haml index 49da5ae5..366aafc1 100644 --- a/app/views/tabs/_settings.html.haml +++ b/app/views/tabs/_settings.html.haml @@ -8,6 +8,7 @@ = list_group_item t(".mutes"), settings_muted_path = list_group_item t(".blocks"), settings_blocks_path = list_group_item t(".theme"), edit_settings_theme_path + = list_group_item t(".push_notifications"), settings_push_notifications_path = list_group_item t(".data"), settings_data_path = list_group_item t(".export"), settings_export_path diff --git a/app/workers/push_notification_worker.rb b/app/workers/push_notification_worker.rb new file mode 100644 index 00000000..7bc08cd4 --- /dev/null +++ b/app/workers/push_notification_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rpush/daemon" + +class PushNotificationWorker + include Sidekiq::Worker + + sidekiq_options queue: :push_notification, retry: 0 + + def perform(notification_id) + Rpush::Daemon::AppRunner.enqueue(Rpush::Client::ActiveRecord::Notification.where(id: notification_id)) + end +end diff --git a/app/workers/question_worker.rb b/app/workers/question_worker.rb index 9e7a3e2b..c30c98d4 100644 --- a/app/workers/question_worker.rb +++ b/app/workers/question_worker.rb @@ -10,17 +10,31 @@ class QuestionWorker def perform(user_id, question_id) user = User.find(user_id) question = Question.find(question_id) + webpush_app = Rpush::App.find_by(name: "webpush") user.followers.each do |f| - next if f.inbox_locked? - next if f.banned? - next if MuteRule.where(user: f).any? { |rule| rule.applies_to? question } - next if user.muting?(question.user) + next if skip_inbox?(f, question, user) - Inbox.create(user_id: f.id, question_id: question_id, new: true) + inbox = Inbox.create(user_id: f.id, question_id:, new: true) + f.push_notification(webpush_app, inbox) if webpush_app end rescue StandardError => e logger.info "failed to ask question: #{e.message}" Sentry.capture_exception(e) end + + private + + def skip_inbox?(follower, question, user) + return true if follower.inbox_locked? + return true if follower.banned? + return true if muted?(follower, question) + return true if user.muting?(question.user) + + false + end + + def muted?(user, question) + MuteRule.where(user:).any? { |rule| rule.applies_to? question } + end end diff --git a/config/initializers/15_sidekiq.rb b/config/initializers/15_sidekiq.rb index f0985236..d2045cf4 100644 --- a/config/initializers/15_sidekiq.rb +++ b/config/initializers/15_sidekiq.rb @@ -1,9 +1,21 @@ +require "rpush/daemon" +require "rpush/daemon/store/active_record" +require "rpush/client/active_record" + redis_url = ENV.fetch("REDIS_URL") { APP_CONFIG["redis_url"] } Sidekiq.configure_server do |config| config.redis = { url: redis_url } + Rpush.config.push = true + Rpush::Daemon.store = Rpush::Daemon::Store::ActiveRecord.new + Rpush::Daemon.common_init + Rpush::Daemon::Synchronizer.sync + + at_exit do + Rpush::Daemon::AppRunner.stop + end end Sidekiq.configure_client do |config| config.redis = { url: redis_url } -end \ No newline at end of file +end diff --git a/config/initializers/rpush.rb b/config/initializers/rpush.rb new file mode 100644 index 00000000..6c1175e6 --- /dev/null +++ b/config/initializers/rpush.rb @@ -0,0 +1,152 @@ +Rpush.configure do |config| + + # Supported clients are :active_record and :redis + config.client = :active_record + + # Options passed to Redis.new + # config.redis_options = {} + + # Frequency in seconds to check for new notifications. + config.push_poll = 2 + + # The maximum number of notifications to load from the store every `push_poll` seconds. + # If some notifications are still enqueued internally, Rpush will load the batch_size less + # the number enqueued. An exception to this is if the service is able to receive multiple + # notification payloads over the connection with a single write, such as APNs. + config.batch_size = 100 + + # Path to write PID file. Relative to current directory unless absolute. + config.pid_file = 'tmp/rpush.pid' + + # Path to log file. Relative to current directory unless absolute. + config.log_file = 'log/rpush.log' + + config.log_level = (defined?(Rails) && Rails.logger) ? Rails.logger.level : ::Logger::Severity::INFO + + # Define a custom logger. + # config.logger = MyLogger.new + + # By default in foreground mode logs are directed both to the logger and to stdout. + # If the logger goes to stdout, you can disable foreground logging to avoid duplication. + # config.foreground_logging = false + + # config.apns.feedback_receiver.enabled = true + # config.apns.feedback_receiver.frequency = 60 + +end + +Rpush.reflect do |on| + + # Called with a Rpush::Apns::Feedback instance when feedback is received + # from the APNs that a notification has failed to be delivered. + # Further notifications should not be sent to the device. + # on.apns_feedback do |feedback| + # end + + # Called when a notification is queued internally for delivery. + # The internal queue for each app runner can be inspected: + # + # Rpush::Daemon::AppRunner.status + # + # on.notification_enqueued do |notification| + # end + + # Called when a notification is successfully delivered. + # on.notification_delivered do |notification| + # end + + # Called when notification delivery failed. + # Call 'error_code' and 'error_description' on the notification for the cause. + on.notification_failed do |notification| + # See: https://developer.apple.com/documentation/usernotifications/sending_web_push_notifications_in_safari_and_other_browsers#3994594 + if %i[403 410].include? notification.error_code + subscription = WebPushSubscription::where("subscription ->> 'endpoint' = ?", notification.registration_ids.first[:endpoint]) + subscription.increment :failures + subscription.save + + if subscription.failures > 3 + Notification::PushSubscriptionError.create( + target: subscription, + recipient: subscription.user, + new: true + ) + end + end + end + + # Called when the notification delivery failed and only the notification ID + # is present in memory. + # on.notification_id_failed do |app, notification_id, error_code, error_description| + # end + + # Called when a notification will be retried at a later date. + # Call 'deliver_after' on the notification for the next delivery date + # and 'retries' for the number of times this notification has been retried. + # on.notification_will_retry do |notification| + # end + + # Called when a notification will be retried and only the notification ID + # is present in memory. + # on.notification_id_will_retry do |app, notification_id, retry_after| + # end + + # Called when a TCP connection is lost and will be reconnected. + # on.tcp_connection_lost do |app, error| + # end + + # Called for each recipient which successfully receives a notification. This + # can occur more than once for the same notification when there are multiple + # recipients. + # on.gcm_delivered_to_recipient do |notification, registration_id| + # end + + # Called for each recipient which fails to receive a notification. This + # can occur more than once for the same notification when there are multiple + # recipients. (do not handle invalid registration IDs here) + # on.gcm_failed_to_recipient do |notification, error, registration_id| + # end + + # Called when the GCM returns a canonical registration ID. + # You will need to replace old_id with canonical_id in your records. + # on.gcm_canonical_id do |old_id, canonical_id| + # end + + # Called when the GCM returns a failure that indicates an invalid registration id. + # You will need to delete the registration_id from your records. + # on.gcm_invalid_registration_id do |app, error, registration_id| + # end + + # Called when an SSL certificate will expire within 1 month. + # Implement on.error to catch errors raised when the certificate expires. + # on.ssl_certificate_will_expire do |app, expiration_time| + # end + + # Called when an SSL certificate has been revoked. + # on.ssl_certificate_revoked do |app, error| + # end + + # Called when the ADM returns a canonical registration ID. + # You will need to replace old_id with canonical_id in your records. + # on.adm_canonical_id do |old_id, canonical_id| + # end + + # Called when Failed to deliver to ADM. Check the 'reason' string for further + # explanations. + # + # If the reason is the string 'Unregistered', you should remove + # this registration id from your records. + # on.adm_failed_to_recipient do |notification, registration_id, reason| + # end + + # Called when Failed to deliver to WNS. Check the 'reason' string for further + # explanations. + # You should remove this uri from your records + # on.wns_invalid_channel do |notification, uri, reason| + # end + + # Called when an exception is raised. + # on.error do |error| + # end +end + +Rails.application.config.active_record.yaml_column_permitted_classes = [Symbol, Hash] diff --git a/config/justask.yml.example b/config/justask.yml.example index 023f6b85..1d627249 100644 --- a/config/justask.yml.example +++ b/config/justask.yml.example @@ -8,6 +8,8 @@ hostname: "justask.rrerr.net" https: true email_from: "noreply@justask.rrerr.net" +# Required by WebPush spec in case of problems with notifications +contact_email: "contact@justask.rrerr.net" # Name of the "Anonymous" user. (e.g. "Anonymous Coward", "Arno Nym", "Mr. X", ...) anonymous_name: "Anonymous" diff --git a/config/locales/controllers.en.yml b/config/locales/controllers.en.yml index 32a548be..1373b154 100644 --- a/config/locales/controllers.en.yml +++ b/config/locales/controllers.en.yml @@ -139,6 +139,11 @@ en: destroy_comment: success: "Successfully unsmiled comment." error: "You have not smiled that comment." + web_push: + subscription_count: + zero: "You are not currently subscribed to push notifications on any devices." + one: "You are currently receiving push notifications on one device." + other: "You are currently receiving push notifications on %{count} devices." inbox: author: info: "No questions from @%{author} found, showing entries from all users instead!" diff --git a/config/locales/frontend.en.yml b/config/locales/frontend.en.yml index 0e734fd6..96228659 100644 --- a/config/locales/frontend.en.yml +++ b/config/locales/frontend.en.yml @@ -49,6 +49,20 @@ en: confirm: title: "Are you sure?" text: "This will mute this user for everyone." + push_notifications: + subscribe: + success: + title: Push notifications enabled! + body: You will now receive push notifications for new questions on this device. + fail: + title: Failed to subscribe to push notifications + body: Please try again later + unsubscribe: + success: Push notifications disabled successfully. + fail: Failed to disable push notifications. + setup_fail: Failed to set up push notifications. Please try again later. + inbox: + title: New question from %{user} report: confirm: title: "Are you sure you want to report this %{type}?" diff --git a/config/locales/views.en.yml b/config/locales/views.en.yml index 7d427b81..e52b74ff 100644 --- a/config/locales/views.en.yml +++ b/config/locales/views.en.yml @@ -231,6 +231,8 @@ en: share: heading: "Share" button: "Share on %{service}" + push_settings: + description: "Want to receive push notifications for new questions on your device?" layouts: feedback: heading: "Feedback" @@ -352,6 +354,10 @@ en: link_text: "your comment" follow: heading_html: "followed you %{time} ago" + webpushsubscription: + heading: "Push notifications are failing to send to one of your devices." + text_html: "Please check the %{settings_push} if you still want to be notified." + settings_push: "push notification settings" expiredtwitterserviceconnection: heading: "Twitter connection expired" text_html: "If you would like to continue automatically sharing your answers to Twitter, head to %{settings_sharing} and re-connect your account." @@ -529,6 +535,19 @@ en: body: "Raised content includes all the different boxes and panels you can see across the site." accent: example: "Some text on top of a accented area on a raised element!" + push_notifications: + index: + title: "Push Notifications" + subscription_count: + zero: "You are not currently subscribed to push notifications on any devices." + one: "You are currently receiving push notifications on one device." + other: "You are currently receiving push notifications on %{count} devices." + unsupported: "This browser does not support push notifications." + current_target: "You are currently receiving push notifications on this device." + subscribe: "Enable on this device" + unsubscribe_current: "Disable on this device" + unsubscribe_all: "Disable on all devices" + description: "Here you can set up or disable push notifications for new questions in your inbox." shared: links: about: "About" @@ -592,6 +611,8 @@ en: theme: "Theme" data: "Your Data" export: "Export" + blocks: "Blocks" + push_notifications: "Push Notifications" moderation: inbox: header: diff --git a/config/routes.rb b/config/routes.rb index 8a753e5b..0929f60c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,8 @@ Rails.application.routes.draw do get :data, to: "data#index" + resources :push_notifications, only: %i[index] + namespace :two_factor_authentication do get :otp_authentication, to: "otp_authentication#index" patch :otp_authentication, to: "otp_authentication#update" @@ -132,6 +134,10 @@ Rails.application.routes.draw do post "/list_membership", to: "list#membership", as: :list_membership post "/subscribe", to: "subscription#subscribe", as: :subscribe_answer post "/unsubscribe", to: "subscription#unsubscribe", as: :unsubscribe_answer + get "/webpush/key", to: "web_push#key", as: :webpush_key + post "/webpush/check", to: "web_push#check", as: :webpush_check + post "/webpush", to: "web_push#subscribe", as: :webpush_subscribe + delete "/webpush", to: "web_push#unsubscribe", as: :webpush_unsubscribe end resource :anonymous_block, controller: :anonymous_block, only: %i[create destroy] diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 2236bdc5..b9c163be 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -12,4 +12,5 @@ production: - mailers - question - export + - push_notification diff --git a/db/migrate/20220909161542_add_rpush.rb b/db/migrate/20220909161542_add_rpush.rb new file mode 100644 index 00000000..9b8cba01 --- /dev/null +++ b/db/migrate/20220909161542_add_rpush.rb @@ -0,0 +1,341 @@ +# frozen_string_literal: true + +# NOTE: TO THE CURIOUS. +# +# Congratulations on being a diligent developer and vetting the migrations +# added to your project! +# +# You're probably thinking "This migration is huge!". It is, but that doesn't +# mean it'll take a long time to run, or that the reason for it being +# this size is because of lousy developers. +# +# Rpush used to be known as Rapns. In an effort to reduce clutter in db/migrate +# for new users of Rpush, what you see below is a concatenation of the +# migrations added to Rapns over its lifetime. +# +# The reason for concatenating old migrations - instead of producing a new +# one that attempts to recreate their accumulative state - is that I don't +# want to introduce any bugs by writing a new migration. +# +# So while this looks like a scary amount of code, it is in fact the safest +# approach. The constituent parts of this migration have been executed +# many times, by many people! + +class AddRpush < ActiveRecord::Migration[5.0] + # rubocop:disable all + def self.migrations + [CreateRapnsNotifications, CreateRapnsFeedback, + AddAlertIsJsonToRapnsNotifications, AddAppToRapns, + CreateRapnsApps, AddGcm, AddWpns, AddAdm, RenameRapnsToRpush, + AddFailAfterToRpushNotifications] + end + + def self.up + migrations.map(&:up) + end + + def self.down + migrations.reverse.each do |m| + m.down + rescue ActiveRecord::StatementInvalid => e + Rails.logger.debug e + end + end + + class CreateRapnsNotifications < ActiveRecord::Migration[5.0] + def self.up + create_table :rapns_notifications do |t| + t.integer :badge, null: true + t.string :device_token, null: false, limit: 64 + t.string :sound, null: true, default: "1.aiff" + t.string :alert, null: true + t.text :attributes_for_device, null: true + t.integer :expiry, null: false, default: 1.day.to_i + t.boolean :delivered, null: false, default: false + t.timestamp :delivered_at, null: true + t.boolean :failed, null: false, default: false + t.timestamp :failed_at, null: true + t.integer :error_code, null: true + t.string :error_description, null: true + t.timestamp :deliver_after, null: true + t.timestamps + end + + add_index :rapns_notifications, %i[delivered failed deliver_after], name: "index_rapns_notifications_multi" + end + + def self.down + remove_index :rapns_notifications, name: "index_rapns_notifications_multi" if index_name_exists?(:rapns_notifications, "index_rapns_notifications_multi") + + drop_table :rapns_notifications + end + end + + class CreateRapnsFeedback < ActiveRecord::Migration[5.0] + def self.up + create_table :rapns_feedback do |t| + t.string :device_token, null: false, limit: 64 + t.timestamp :failed_at, null: false + t.timestamps + end + + add_index :rapns_feedback, :device_token + end + + def self.down + remove_index :rapns_feedback, name: :index_rapns_feedback_on_device_token if index_name_exists?(:rapns_feedback, :index_rapns_feedback_on_device_token) + + drop_table :rapns_feedback + end + end + + class AddAlertIsJsonToRapnsNotifications < ActiveRecord::Migration[5.0] + def self.up + add_column :rapns_notifications, :alert_is_json, :boolean, null: true, default: false + end + + def self.down + remove_column :rapns_notifications, :alert_is_json + end + end + + class AddAppToRapns < ActiveRecord::Migration[5.0] + def self.up + add_column :rapns_notifications, :app, :string, null: true + add_column :rapns_feedback, :app, :string, null: true + end + + def self.down + remove_column :rapns_notifications, :app + remove_column :rapns_feedback, :app + end + end + + class CreateRapnsApps < ActiveRecord::Migration[5.0] + def self.up + create_table :rapns_apps do |t| + t.string :key, null: false + t.string :environment, null: false + t.text :certificate, null: false + t.string :password, null: true + t.integer :connections, null: false, default: 1 + t.timestamps + end + end + + def self.down + drop_table :rapns_apps + end + end + + class AddGcm < ActiveRecord::Migration[5.0] + module Rapns + class App < ActiveRecord::Base + self.table_name = 'rapns_apps' + end + + class Notification < ActiveRecord::Base + belongs_to :app + self.table_name = "rapns_notifications" + end + end + + def self.up + add_column :rapns_notifications, :type, :string, null: true + add_column :rapns_apps, :type, :string, null: true + + AddGcm::Rapns::Notification.update_all type: "Rapns::Apns::Notification" + AddGcm::Rapns::App.update_all type: "Rapns::Apns::App" + + change_column :rapns_notifications, :type, :string, null: false + change_column :rapns_apps, :type, :string, null: false + change_column :rapns_notifications, :device_token, :string, null: true, limit: 64 + change_column :rapns_notifications, :expiry, :integer, null: true, default: 1.day.to_i + change_column :rapns_apps, :environment, :string, null: true + change_column :rapns_apps, :certificate, :text, null: true, default: nil + + change_column :rapns_notifications, :error_description, :text, null: true, default: nil + change_column :rapns_notifications, :sound, :string, default: "default" + + rename_column :rapns_notifications, :attributes_for_device, :data + rename_column :rapns_apps, :key, :name + + add_column :rapns_apps, :auth_key, :string, null: true + + add_column :rapns_notifications, :collapse_key, :string, null: true + add_column :rapns_notifications, :delay_while_idle, :boolean, null: false, default: false + + reg_ids_type = ActiveRecord::Base.connection.adapter_name.include?("Mysql") ? :mediumtext : :text + add_column :rapns_notifications, :registration_ids, reg_ids_type, null: true + add_column :rapns_notifications, :app_id, :integer, null: true + add_column :rapns_notifications, :retries, :integer, null: true, default: 0 + + AddGcm::Rapns::Notification.reset_column_information + AddGcm::Rapns::App.reset_column_information + + AddGcm::Rapns::App.all.each do |app| + AddGcm::Rapns::Notification.where(app: app.name).update_all(app_id: app.id) + end + + change_column :rapns_notifications, :app_id, :integer, null: false + remove_column :rapns_notifications, :app + + if index_name_exists?(:rapns_notifications, "index_rapns_notifications_multi") + remove_index :rapns_notifications, name: "index_rapns_notifications_multi" + elsif index_name_exists?(:rapns_notifications, "index_rapns_notifications_on_delivered_failed_deliver_after") + remove_index :rapns_notifications, name: "index_rapns_notifications_on_delivered_failed_deliver_after" + end + + add_index :rapns_notifications, %i[app_id delivered failed deliver_after], name: "index_rapns_notifications_multi" + end + + def self.down + AddGcm::Rapns::Notification.where(type: "Rapns::Gcm::Notification").delete_all + + remove_column :rapns_notifications, :type + remove_column :rapns_apps, :type + + change_column :rapns_notifications, :device_token, :string, null: false, limit: 64 + change_column :rapns_notifications, :expiry, :integer, null: false, default: 1.day.to_i + change_column :rapns_apps, :environment, :string, null: false + change_column :rapns_apps, :certificate, :text, null: false + + change_column :rapns_notifications, :error_description, :string, null: true, default: nil + change_column :rapns_notifications, :sound, :string, default: "1.aiff" + + rename_column :rapns_notifications, :data, :attributes_for_device + rename_column :rapns_apps, :name, :key + + remove_column :rapns_apps, :auth_key + + remove_column :rapns_notifications, :collapse_key + remove_column :rapns_notifications, :delay_while_idle + remove_column :rapns_notifications, :registration_ids + remove_column :rapns_notifications, :retries + + add_column :rapns_notifications, :app, :string, null: true + + AddGcm::Rapns::Notification.reset_column_information + AddGcm::Rapns::App.reset_column_information + + AddGcm::Rapns::App.all.each do |app| + AddGcm::Rapns::Notification.where(app_id: app.id).update_all(app: app.key) + end + + remove_index :rapns_notifications, name: :index_rapns_notifications_multi if index_name_exists?(:rapns_notifications, :index_rapns_notifications_multi) + + remove_column :rapns_notifications, :app_id + + add_index :rapns_notifications, %i[delivered failed deliver_after], name: :index_rapns_notifications_multi + end + end + + class AddWpns < ActiveRecord::Migration[5.0] + module Rapns + class Notification < ActiveRecord::Base + self.table_name = 'rapns_notifications' + end + end + + def self.up + add_column :rapns_notifications, :uri, :string, null: true + end + + def self.down + AddWpns::Rapns::Notification.where(type: "Rapns::Wpns::Notification").delete_all + remove_column :rapns_notifications, :uri + end + end + + class AddAdm < ActiveRecord::Migration[5.0] + module Rapns + class Notification < ActiveRecord::Base + self.table_name = 'rapns_notifications' + end + end + + def self.up + add_column :rapns_apps, :client_id, :string, null: true + add_column :rapns_apps, :client_secret, :string, null: true + add_column :rapns_apps, :access_token, :string, null: true + add_column :rapns_apps, :access_token_expiration, :datetime, null: true + end + + def self.down + AddAdm::Rapns::Notification.where(type: "Rapns::Adm::Notification").delete_all + + remove_column :rapns_apps, :client_id + remove_column :rapns_apps, :client_secret + remove_column :rapns_apps, :access_token + remove_column :rapns_apps, :access_token_expiration + end + end + + class RenameRapnsToRpush < ActiveRecord::Migration[5.0] + module Rpush + class App < ActiveRecord::Base + self.table_name = 'rpush_apps' + end + + class Notification < ActiveRecord::Base + self.table_name = 'rpush_notifications' + end + end + + def self.update_type(model, from, to) + model.where(type: from).update_all(type: to) + end + + def self.up + rename_table :rapns_notifications, :rpush_notifications + rename_table :rapns_apps, :rpush_apps + rename_table :rapns_feedback, :rpush_feedback + + rename_index :rpush_notifications, :index_rapns_notifications_multi, :index_rpush_notifications_multi if index_name_exists?(:rpush_notifications, :index_rapns_notifications_multi) + + rename_index :rpush_feedback, :index_rapns_feedback_on_device_token, :index_rpush_feedback_on_device_token if index_name_exists?(:rpush_feedback, :index_rapns_feedback_on_device_token) + + update_type(RenameRapnsToRpush::Rpush::Notification, "Rapns::Apns::Notification", "Rpush::Apns::Notification") + update_type(RenameRapnsToRpush::Rpush::Notification, "Rapns::Gcm::Notification", "Rpush::Gcm::Notification") + update_type(RenameRapnsToRpush::Rpush::Notification, "Rapns::Adm::Notification", "Rpush::Adm::Notification") + update_type(RenameRapnsToRpush::Rpush::Notification, "Rapns::Wpns::Notification", "Rpush::Wpns::Notification") + + update_type(RenameRapnsToRpush::Rpush::App, "Rapns::Apns::App", "Rpush::Apns::App") + update_type(RenameRapnsToRpush::Rpush::App, "Rapns::Gcm::App", "Rpush::Gcm::App") + update_type(RenameRapnsToRpush::Rpush::App, "Rapns::Adm::App", "Rpush::Adm::App") + update_type(RenameRapnsToRpush::Rpush::App, "Rapns::Wpns::App", "Rpush::Wpns::App") + end + + def self.down + update_type(RenameRapnsToRpush::Rpush::Notification, "Rpush::Apns::Notification", "Rapns::Apns::Notification") + update_type(RenameRapnsToRpush::Rpush::Notification, "Rpush::Gcm::Notification", "Rapns::Gcm::Notification") + update_type(RenameRapnsToRpush::Rpush::Notification, "Rpush::Adm::Notification", "Rapns::Adm::Notification") + update_type(RenameRapnsToRpush::Rpush::Notification, "Rpush::Wpns::Notification", "Rapns::Wpns::Notification") + + update_type(RenameRapnsToRpush::Rpush::App, "Rpush::Apns::App", "Rapns::Apns::App") + update_type(RenameRapnsToRpush::Rpush::App, "Rpush::Gcm::App", "Rapns::Gcm::App") + update_type(RenameRapnsToRpush::Rpush::App, "Rpush::Adm::App", "Rapns::Adm::App") + update_type(RenameRapnsToRpush::Rpush::App, "Rpush::Wpns::App", "Rapns::Wpns::App") + + rename_index :rpush_notifications, :index_rpush_notifications_multi, :index_rapns_notifications_multi if index_name_exists?(:rpush_notifications, :index_rpush_notifications_multi) + + rename_index :rpush_feedback, :index_rpush_feedback_on_device_token, :index_rapns_feedback_on_device_token if index_name_exists?(:rpush_feedback, :index_rpush_feedback_on_device_token) + + rename_table :rpush_notifications, :rapns_notifications + rename_table :rpush_apps, :rapns_apps + rename_table :rpush_feedback, :rapns_feedback + end + end + + class AddFailAfterToRpushNotifications < ActiveRecord::Migration[5.0] + def self.up + add_column :rpush_notifications, :fail_after, :timestamp, null: true + end + + def self.down + remove_column :rpush_notifications, :fail_after + end + end + + # rubocop:enable all +end diff --git a/db/migrate/20220909161543_rpush_2_0_0_updates.rb b/db/migrate/20220909161543_rpush_2_0_0_updates.rb new file mode 100644 index 00000000..505b74a0 --- /dev/null +++ b/db/migrate/20220909161543_rpush_2_0_0_updates.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class Rpush200Updates < ActiveRecord::Migration[5.0] + module Rpush + class App < ApplicationRecord + self.table_name = "rpush_apps" + end + + class Notification < ApplicationRecord + self.table_name = "rpush_notifications" + end + end + + def self.update_type(model, from, to) + model.where(type: from).update_all(type: to) # rubocop:disable Rails/SkipsModelValidations + end + + def self.up + add_column :rpush_notifications, :processing, :boolean, null: false, default: false + add_column :rpush_notifications, :priority, :integer, null: true + + remove_index :rpush_notifications, name: :index_rpush_notifications_multi if index_name_exists?(:rpush_notifications, :index_rpush_notifications_multi) + + add_index :rpush_notifications, %i[delivered failed], name: "index_rpush_notifications_multi", where: "NOT delivered AND NOT failed" + + rename_column :rpush_feedback, :app, :app_id + + if postgresql? + execute("ALTER TABLE rpush_feedback ALTER COLUMN app_id TYPE integer USING (trim(app_id)::integer)") + else + change_column :rpush_feedback, :app_id, :integer + end + + %i[Apns Gcm Wpns Adm].each do |service| + update_type(Rpush200Updates::Rpush::App, "Rpush::#{service}::App", "Rpush::Client::ActiveRecord::#{service}::App") + update_type(Rpush200Updates::Rpush::Notification, "Rpush::#{service}::Notification", "Rpush::Client::ActiveRecord::#{service}::Notification") + end + end + + def self.down + %i[Apns Gcm Wpns Adm].each do |service| + update_type(Rpush200Updates::Rpush::App, "Rpush::Client::ActiveRecord::#{service}::App", "Rpush::#{service}::App") + update_type(Rpush200Updates::Rpush::Notification, "Rpush::Client::ActiveRecord::#{service}::Notification", "Rpush::#{service}::Notification") + end + + change_column :rpush_feedback, :app_id, :string + rename_column :rpush_feedback, :app_id, :app + + remove_index :rpush_notifications, name: :index_rpush_notifications_multi if index_name_exists?(:rpush_notifications, :index_rpush_notifications_multi) + + add_index :rpush_notifications, %i[app_id delivered failed deliver_after], name: "index_rpush_notifications_multi" + + remove_column :rpush_notifications, :priority + remove_column :rpush_notifications, :processing + end + + def self.adapter_name + env = defined?(Rails) && Rails.env ? Rails.env : "development" + if ActiveRecord::VERSION::MAJOR > 6 + ActiveRecord::Base.configurations.configs_for(env_name: env).first.configuration_hash[:adapter] + else + ActiveRecord::Base.configurations[env].to_h { |k, v| [k.to_sym, v] }[:adapter] + end + end + + def self.postgresql? + adapter_name =~ /postgresql|postgis/ + end +end diff --git a/db/migrate/20220909161544_rpush_2_1_0_updates.rb b/db/migrate/20220909161544_rpush_2_1_0_updates.rb new file mode 100644 index 00000000..75ee3c48 --- /dev/null +++ b/db/migrate/20220909161544_rpush_2_1_0_updates.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Rpush210Updates < ActiveRecord::Migration[5.0] + def self.up + add_column :rpush_notifications, :url_args, :text, null: true + add_column :rpush_notifications, :category, :string, null: true + end + + def self.down + remove_column :rpush_notifications, :url_args + remove_column :rpush_notifications, :category + end +end diff --git a/db/migrate/20220909161545_rpush_2_6_0_updates.rb b/db/migrate/20220909161545_rpush_2_6_0_updates.rb new file mode 100644 index 00000000..3545a88c --- /dev/null +++ b/db/migrate/20220909161545_rpush_2_6_0_updates.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Rpush260Updates < ActiveRecord::Migration[5.0] + def self.up + add_column :rpush_notifications, :content_available, :boolean, default: false + end + + def self.down + remove_column :rpush_notifications, :content_available + end +end diff --git a/db/migrate/20220909161546_rpush_2_7_0_updates.rb b/db/migrate/20220909161546_rpush_2_7_0_updates.rb new file mode 100644 index 00000000..bc8dda54 --- /dev/null +++ b/db/migrate/20220909161546_rpush_2_7_0_updates.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Rpush270Updates < ActiveRecord::Migration[5.0] + def self.up + change_column :rpush_notifications, :alert, :text + add_column :rpush_notifications, :notification, :text + end + + def self.down + change_column :rpush_notifications, :alert, :string + remove_column :rpush_notifications, :notification + end +end diff --git a/db/migrate/20220909161547_rpush_3_0_0_updates.rb b/db/migrate/20220909161547_rpush_3_0_0_updates.rb new file mode 100644 index 00000000..d1767ab2 --- /dev/null +++ b/db/migrate/20220909161547_rpush_3_0_0_updates.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Rpush300Updates < ActiveRecord::Migration[5.0] + def self.up + add_column :rpush_notifications, :mutable_content, :boolean, default: false + change_column :rpush_notifications, :sound, :string, default: nil + end + + def self.down + remove_column :rpush_notifications, :mutable_content + change_column :rpush_notifications, :sound, :string, default: "default" + end +end diff --git a/db/migrate/20220909161548_rpush_3_0_1_updates.rb b/db/migrate/20220909161548_rpush_3_0_1_updates.rb new file mode 100644 index 00000000..501d84ef --- /dev/null +++ b/db/migrate/20220909161548_rpush_3_0_1_updates.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Rpush301Updates < ActiveRecord::Migration[5.0] + def self.up + change_column_null :rpush_notifications, :mutable_content, false + change_column_null :rpush_notifications, :content_available, false + change_column_null :rpush_notifications, :alert_is_json, false + end + + def self.down + change_column_null :rpush_notifications, :mutable_content, true + change_column_null :rpush_notifications, :content_available, true + change_column_null :rpush_notifications, :alert_is_json, true + end +end diff --git a/db/migrate/20220909161549_rpush_3_1_0_add_pushy.rb b/db/migrate/20220909161549_rpush_3_1_0_add_pushy.rb new file mode 100644 index 00000000..fc47b223 --- /dev/null +++ b/db/migrate/20220909161549_rpush_3_1_0_add_pushy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Rpush310AddPushy < ActiveRecord::Migration[5.0] + def self.up + add_column :rpush_notifications, :external_device_id, :string, null: true + end + + def self.down + remove_column :rpush_notifications, :external_device_id + end +end diff --git a/db/migrate/20220909161550_rpush_3_1_1_updates.rb b/db/migrate/20220909161550_rpush_3_1_1_updates.rb new file mode 100644 index 00000000..e5643a64 --- /dev/null +++ b/db/migrate/20220909161550_rpush_3_1_1_updates.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Rpush311Updates < ActiveRecord::Migration[5.0] + def self.up + change_table :rpush_notifications do |t| + t.remove_index name: "index_rpush_notifications_multi" + t.index %i[delivered failed processing deliver_after created_at], name: "index_rpush_notifications_multi", where: "NOT delivered AND NOT failed" + end + end + + def self.down + change_table :rpush_notifications do |t| + t.remove_index name: "index_rpush_notifications_multi" + t.index %i[delivered failed], name: "index_rpush_notifications_multi", where: "NOT delivered AND NOT failed" + end + end +end diff --git a/db/migrate/20220909161551_rpush_3_2_0_add_apns_p8.rb b/db/migrate/20220909161551_rpush_3_2_0_add_apns_p8.rb new file mode 100644 index 00000000..51325e49 --- /dev/null +++ b/db/migrate/20220909161551_rpush_3_2_0_add_apns_p8.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Rpush320AddApnsP8 < ActiveRecord::Migration[5.0] + def self.up + add_column :rpush_apps, :apn_key, :string, null: true + add_column :rpush_apps, :apn_key_id, :string, null: true + add_column :rpush_apps, :team_id, :string, null: true + add_column :rpush_apps, :bundle_id, :string, null: true + end + + def self.down + remove_column :rpush_apps, :apn_key + remove_column :rpush_apps, :apn_key_id + remove_column :rpush_apps, :team_id + remove_column :rpush_apps, :bundle_id + end +end diff --git a/db/migrate/20220909161552_rpush_3_2_4_updates.rb b/db/migrate/20220909161552_rpush_3_2_4_updates.rb new file mode 100644 index 00000000..f29319b7 --- /dev/null +++ b/db/migrate/20220909161552_rpush_3_2_4_updates.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Rpush324Updates < ActiveRecord::Migration[5.0] + def self.up + change_column :rpush_apps, :apn_key, :text, null: true + end + + def self.down + change_column :rpush_apps, :apn_key, :string, null: true + end +end diff --git a/db/migrate/20220909161553_rpush_3_3_0_updates.rb b/db/migrate/20220909161553_rpush_3_3_0_updates.rb new file mode 100644 index 00000000..d5ffce23 --- /dev/null +++ b/db/migrate/20220909161553_rpush_3_3_0_updates.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Rpush330Updates < ActiveRecord::Migration[5.0] + def self.up + add_column :rpush_notifications, :thread_id, :string, null: true + end + + def self.down + remove_column :rpush_notifications, :thread_id + end +end diff --git a/db/migrate/20220909161554_rpush_3_3_1_updates.rb b/db/migrate/20220909161554_rpush_3_3_1_updates.rb new file mode 100644 index 00000000..659f6ed5 --- /dev/null +++ b/db/migrate/20220909161554_rpush_3_3_1_updates.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Rpush331Updates < ActiveRecord::Migration[5.0] + def self.up + change_column :rpush_notifications, :device_token, :string, null: true + change_column :rpush_feedback, :device_token, :string, null: true + end + + def self.down + change_column :rpush_notifications, :device_token, :string, null: true, limit: 64 + change_column :rpush_feedback, :device_token, :string, null: true, limit: 64 + end +end diff --git a/db/migrate/20220909161555_rpush_4_1_0_updates.rb b/db/migrate/20220909161555_rpush_4_1_0_updates.rb new file mode 100644 index 00000000..7c3fca19 --- /dev/null +++ b/db/migrate/20220909161555_rpush_4_1_0_updates.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Rpush410Updates < ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"] + def self.up + add_column :rpush_notifications, :dry_run, :boolean, null: false, default: false + end + + def self.down + remove_column :rpush_notifications, :dry_run + end +end diff --git a/db/migrate/20220909161556_rpush_4_1_1_updates.rb b/db/migrate/20220909161556_rpush_4_1_1_updates.rb new file mode 100644 index 00000000..5db49e66 --- /dev/null +++ b/db/migrate/20220909161556_rpush_4_1_1_updates.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Rpush411Updates < ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"] + def self.up + add_column :rpush_apps, :feedback_enabled, :boolean, default: true + end + + def self.down + remove_column :rpush_apps, :feedback_enabled + end +end diff --git a/db/migrate/20220909161557_rpush_4_2_0_updates.rb b/db/migrate/20220909161557_rpush_4_2_0_updates.rb new file mode 100644 index 00000000..7418ff39 --- /dev/null +++ b/db/migrate/20220909161557_rpush_4_2_0_updates.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Rpush420Updates < ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"] + def self.up + add_column :rpush_notifications, :sound_is_json, :boolean, null: true, default: false + end + + def self.down + remove_column :rpush_notifications, :sound_is_json + end +end diff --git a/db/migrate/20220909220449_add_webpush_app.rb b/db/migrate/20220909220449_add_webpush_app.rb new file mode 100644 index 00000000..91b1d32d --- /dev/null +++ b/db/migrate/20220909220449_add_webpush_app.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "webpush" + +class AddWebpushApp < ActiveRecord::Migration[6.1] + def up + vapid_keypair = Webpush.generate_key.to_hash + app = Rpush::Webpush::App.new + app.name = "webpush" + app.certificate = vapid_keypair.merge(subject: APP_CONFIG.fetch("contact_email")).to_json + app.connections = 1 + app.save! + end + + def down + Rpush::Webpush::App.find_by(name: "webpush").destroy! + end +end diff --git a/db/migrate/20220910000514_create_web_push_subscriptions.rb b/db/migrate/20220910000514_create_web_push_subscriptions.rb new file mode 100644 index 00000000..3da83671 --- /dev/null +++ b/db/migrate/20220910000514_create_web_push_subscriptions.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateWebPushSubscriptions < ActiveRecord::Migration[6.1] + def change + create_table :web_push_subscriptions do |t| + t.bigint :user_id, null: false + t.json :subscription + t.timestamps + end + + add_index :web_push_subscriptions, :user_id + end +end diff --git a/db/migrate/20221226101907_add_failures_to_web_push_subscriptions.rb b/db/migrate/20221226101907_add_failures_to_web_push_subscriptions.rb new file mode 100644 index 00000000..e52b50e7 --- /dev/null +++ b/db/migrate/20221226101907_add_failures_to_web_push_subscriptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddFailuresToWebPushSubscriptions < ActiveRecord::Migration[6.1] + def change + add_column :web_push_subscriptions, :failures, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 094c992c..05514255 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -183,6 +183,75 @@ ActiveRecord::Schema.define(version: 2022_12_27_065923) do t.index ["resource_type", "resource_id"], name: "index_roles_on_resource" end + create_table "rpush_apps", force: :cascade do |t| + t.string "name", null: false + t.string "environment" + t.text "certificate" + t.string "password" + t.integer "connections", default: 1, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "type", null: false + t.string "auth_key" + t.string "client_id" + t.string "client_secret" + t.string "access_token" + t.datetime "access_token_expiration" + t.text "apn_key" + t.string "apn_key_id" + t.string "team_id" + t.string "bundle_id" + t.boolean "feedback_enabled", default: true + end + + create_table "rpush_feedback", force: :cascade do |t| + t.string "device_token" + t.datetime "failed_at", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.integer "app_id" + t.index ["device_token"], name: "index_rpush_feedback_on_device_token" + end + + create_table "rpush_notifications", force: :cascade do |t| + t.integer "badge" + t.string "device_token" + t.string "sound" + t.text "alert" + t.text "data" + t.integer "expiry", default: 86400 + t.boolean "delivered", default: false, null: false + t.datetime "delivered_at" + t.boolean "failed", default: false, null: false + t.datetime "failed_at" + t.integer "error_code" + t.text "error_description" + t.datetime "deliver_after" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.boolean "alert_is_json", default: false, null: false + t.string "type", null: false + t.string "collapse_key" + t.boolean "delay_while_idle", default: false, null: false + t.text "registration_ids" + t.integer "app_id", null: false + t.integer "retries", default: 0 + t.string "uri" + t.datetime "fail_after" + t.boolean "processing", default: false, null: false + t.integer "priority" + t.text "url_args" + t.string "category" + t.boolean "content_available", default: false, null: false + t.text "notification" + t.boolean "mutable_content", default: false, null: false + t.string "external_device_id" + t.string "thread_id" + t.boolean "dry_run", default: false, null: false + t.boolean "sound_is_json", default: false + t.index ["delivered", "failed", "processing", "deliver_after", "created_at"], name: "index_rpush_notifications_multi", where: "((NOT delivered) AND (NOT failed))" + end + create_table "services", id: :serial, force: :cascade do |t| t.string "type", null: false t.bigint "user_id", null: false @@ -313,5 +382,14 @@ ActiveRecord::Schema.define(version: 2022_12_27_065923) do t.index ["user_id"], name: "index_users_roles_on_user_id" end + create_table "web_push_subscriptions", force: :cascade do |t| + t.bigint "user_id", null: false + t.json "subscription" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.integer "failures", default: 0 + t.index ["user_id"], name: "index_web_push_subscriptions_on_user_id" + end + add_foreign_key "profiles", "users" end diff --git a/lib/use_case/question/create.rb b/lib/use_case/question/create.rb index 36ef0205..857e1b14 100644 --- a/lib/use_case/question/create.rb +++ b/lib/use_case/question/create.rb @@ -11,24 +11,16 @@ module UseCase option :direct, type: Types::Params::Bool, default: proc { true } def call - check_user - check_lock - check_anonymous_rules - check_blocks + do_checks! - question = ::Question.create!( - content: content, - author_is_anonymous: anonymous, - author_identifier: author_identifier, - user: source_user_id.nil? ? nil : source_user, - direct: direct - ) + question = create_question return if filtered?(question) increment_asked_count - inbox = ::Inbox.create!(user: target_user, question: question, new: true) + inbox = ::Inbox.create!(user: target_user, question:, new: true) + notify { status: 201, @@ -41,6 +33,13 @@ module UseCase private + def do_checks! + check_user + check_lock + check_anonymous_rules + check_blocks + end + def check_lock raise Errors::InboxLocked if target_user.inbox_locked? end @@ -66,6 +65,21 @@ module UseCase raise Errors::NotAuthorized if target_user.privacy_require_user && !source_user_id end + def create_question + ::Question.create!( + content:, + author_is_anonymous: anonymous, + author_identifier:, + user: source_user_id.nil? ? nil : source_user, + direct: + ) + end + + def notify + webpush_app = ::Rpush::App.find_by(name: "webpush") + target_user.push_notification(webpush_app, inbox) if webpush_app + end + def increment_asked_count unless source_user_id && !anonymous && !direct # Only increment the asked count of the source user if the question diff --git a/public/service_worker.js b/public/service_worker.js new file mode 100644 index 00000000..1f13f0f8 --- /dev/null +++ b/public/service_worker.js @@ -0,0 +1,25 @@ +self.addEventListener('push', function (event) { + if (event.data) { + const notification = event.data.json(); + + event.waitUntil(self.registration.showNotification(notification.title, { + body: notification.body, + tag: notification.type, + icon: notification.icon, + })); + } else { + console.error("Push event received, but it didn't contain any data.", event); + } +}); + +self.addEventListener('notificationclick', async event => { + if (event.notification.tag === 'inbox') { + event.preventDefault(); + return clients.openWindow("/inbox", "_blank").then(result => { + event.notification.close(); + return result; + }); + } else { + console.warn(`Unhandled notification tag: ${event.notification.tag}`); + } +}); diff --git a/spec/controllers/ajax/web_push_controller_spec.rb b/spec/controllers/ajax/web_push_controller_spec.rb new file mode 100644 index 00000000..f82000b1 --- /dev/null +++ b/spec/controllers/ajax/web_push_controller_spec.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Ajax::WebPushController, :ajax_controller, type: :controller do + before do + Rpush::Webpush::App.create( + name: "webpush", + certificate: { public_key: "AAAA", private_key: "BBBB", subject: "" }.to_json, + connections: 1 + ) + end + + describe "#key" do + subject { get :key, format: :json } + + let(:expected_response) do + { + "message" => "", + "status" => "okay", + "success" => true, + "key" => "AAAA" + } + end + + context "user signed in" do + let(:user) { FactoryBot.create(:user) } + + before { sign_in user } + + include_examples "returns the expected response" + end + end + + describe "#subscribe" do + subject { post :subscribe, params: } + + context "user signed in" do + let(:user) { FactoryBot.create(:user) } + + before { sign_in user } + + context "given a valid subscription" do + let(:params) do + { + subscription: { + endpoint: "https://some.webpush/endpoint", + keys: {} + } + } + end + let(:expected_response) do + { + "message" => I18n.t("settings.push_notifications.subscription_count.one"), + "status" => "okay", + "success" => true + } + end + + it "stores the subscription" do + expect { subject } + .to( + change { WebPushSubscription.count } + .by(1) + ) + end + + include_examples "returns the expected response" + end + end + + context "given no subscription param" do + let(:params) do + {} + end + end + end + + describe "#unsubscribe" do + subject { delete :unsubscribe, params: } + + shared_examples_for "does not remove any subscriptions" do + it "does not remove any subscriptions" do + expect { subject }.not_to(change { WebPushSubscription.count }) + end + end + + context "user signed in" do + let(:user) { FactoryBot.create(:user) } + + before { sign_in user } + + context "valid subscription" do + let(:endpoint) { "some endpoint" } + let!(:subscription) do + WebPushSubscription.create( + user:, + subscription: { endpoint:, keys: {} } + ) + end + let!(:other_subscription) do + WebPushSubscription.create( + user:, + subscription: { endpoint: "other endpoint", keys: {} } + ) + end + let(:params) do + { endpoint: } + end + let(:expected_response) do + { + "status" => "okay", + "success" => true, + "message" => I18n.t("ajax.web_push.subscription_count.one"), + "count" => 1 + } + end + + it "removes the subscription" do + subject + expect { subscription.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { other_subscription.reload }.not_to raise_error(ActiveRecord::RecordNotFound) + end + + include_examples "returns the expected response" + end + + context "invalid subscription" do + let!(:subscription) do + WebPushSubscription.create( + user:, + subscription: { endpoint: "other endpoint", keys: {} } + ) + end + let(:params) do + { endpoint: "some endpoint" } + end + let(:expected_response) do + { + "status" => "err", + "success" => false, + "message" => I18n.t("ajax.web_push.subscription_count.one"), + "count" => 1 + } + end + + include_examples "does not remove any subscriptions" + include_examples "returns the expected response" + end + + context "someone else's subscription" do + let(:endpoint) { "some endpoint" } + let(:other_user) { FactoryBot.create(:user) } + let!(:subscription) do + WebPushSubscription.create( + user: other_user, + subscription: { endpoint:, keys: {} } + ) + end + let(:params) do + { endpoint: } + end + let(:expected_response) do + { + "status" => "err", + "success" => false, + "message" => I18n.t("ajax.web_push.subscription_count.zero"), + "count" => 0 + } + end + + include_examples "does not remove any subscriptions" + include_examples "returns the expected response" + end + + context "no subscription provided" do + let(:other_user) { FactoryBot.create(:user) } + let(:params) { {} } + + before do + 4.times do |i| + WebPushSubscription.create( + user: i.zero? ? other_user : user, + subscription: { endpoint: i, keys: {} } + ) + end + end + + it "removes all own subscriptions" do + expect { subject } + .to( + change { WebPushSubscription.count } + .from(4) + .to(1) + ) + end + end + end + end + + describe "#check" do + subject { post :check, params: } + + context "user signed in" do + let(:user) { FactoryBot.create(:user) } + let(:endpoint) { "https://some.domain/some/webpush/endpoint" } + let!(:subscription) do + WebPushSubscription.create( + user:, + subscription: { endpoint:, keys: {} }, + failures: + ) + end + let(:expected_response) do + { + "message" => "", + "status" => expected_status, + "success" => true + } + end + + before { sign_in user } + + context "subscription exists" do + let(:params) do + { endpoint: } + end + + context "without failures" do + let(:failures) { 0 } + let(:expected_status) { "subscribed" } + + it_behaves_like "returns the expected response" + end + + context "with 2 failures" do + let(:failures) { 2 } + let(:expected_status) { "subscribed" } + + it_behaves_like "returns the expected response" + end + + context "with 3 failures" do + let(:failures) { 3 } + let(:expected_status) { "failed" } + + it_behaves_like "returns the expected response" + end + end + + context "subscription doesn't exist" do + let(:params) do + { endpoint: "https;//some.domain/some/other/endpoint" } + end + + let(:failures) { 0 } + let(:expected_status) { "unsubscribed" } + + it_behaves_like "returns the expected response" + end + end + end +end diff --git a/spec/controllers/settings/push_notifications_controller_spec.rb b/spec/controllers/settings/push_notifications_controller_spec.rb new file mode 100644 index 00000000..d78b7350 --- /dev/null +++ b/spec/controllers/settings/push_notifications_controller_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Settings::PushNotificationsController, type: :controller do + describe "#index" do + subject { get :index } + + context "user signed in" do + let(:user) { FactoryBot.create(:user) } + + before { sign_in user } + + it "renders the index template" do + subject + expect(response).to render_template(:index) + end + end + end +end diff --git a/spec/workers/question_worker_spec.rb b/spec/workers/question_worker_spec.rb index ab1e2914..cf022124 100644 --- a/spec/workers/question_worker_spec.rb +++ b/spec/workers/question_worker_spec.rb @@ -43,8 +43,7 @@ describe QuestionWorker do it "respects inbox locks" do user.followers.first.update(privacy_lock_inbox: true) - - + expect { subject } .to( change { Inbox.where(user_id: user.followers.ids, question_id:, new: true).count } @@ -52,7 +51,7 @@ describe QuestionWorker do .to(4) ) end - + it "does not send questions to banned users" do user.followers.first.ban @@ -63,5 +62,35 @@ describe QuestionWorker do .to(4) ) end + + context "receiver has push notifications enabled" do + let(:receiver) { FactoryBot.create(:user) } + + before do + Rpush::Webpush::App.create( + name: "webpush", + certificate: { public_key: "AAAA", private_key: "AAAA", subject: "" }.to_json, + connections: 1 + ) + + WebPushSubscription.create!( + user: receiver, + subscription: { + endpoint: "This will not be used", + keys: {} + } + ) + receiver.follow(user) + end + + it "sends notifications" do + expect { subject } + .to( + change { Rpush::Webpush::Notification.count } + .from(0) + .to(1) + ) + end + end end end