Merge pull request #678 from Retrospring/feature/webpush

WebPush support
This commit is contained in:
Karina Kwiatek 2023-01-02 11:57:34 +00:00 committed by GitHub
commit cc1c262256
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1706 additions and 26 deletions

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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$/);

View File

@ -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 },

View File

@ -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');
}
}

View File

@ -0,0 +1,7 @@
export function dismissHandler (event: Event): void {
event.preventDefault();
const sender: HTMLButtonElement = event.target as HTMLButtonElement;
sender.closest<HTMLDivElement>('.push-settings').classList.add('d-none');
localStorage.setItem('dismiss-push-settings-prompt', 'true');
}

View File

@ -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<HTMLButtonElement>('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<ServiceWorkerRegistration> {
return navigator.serviceWorker.register("/service_worker.js", { scope: "/" });
}
async function getServerKey(): Promise<Buffer> {
const response = await get("/ajax/webpush/key");
const data = await response.json;
return Buffer.from(data.key, 'base64');
}
async function subscribe(registration: ServiceWorkerRegistration): Promise<PushSubscription> {
const key = await getServerKey();
return await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key
});
}

View File

@ -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
},
]);
}

View File

@ -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<PushSubscription|null> {
subscription?.unsubscribe().then(success => {
if (!success) {
throw new Error("Failed to unsubscribe.");
}
});
return subscription;
}
async function unsubscribeServer(subscription?: PushSubscription): Promise<void> {
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<HTMLButtonElement>('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<HTMLButtonElement>('button[data-action="push-enable"]')?.classList.remove('d-none');
}
} else {
showErrorNotification(I18n.translate("frontend.push_notifications.unsubscribe.fail"));
}
})
}

View File

@ -108,6 +108,7 @@
"components/mobile-nav",
"components/notifications",
"components/profile",
"components/push-settings",
"components/question",
"components/smiles",
"components/themes",

View File

@ -0,0 +1,7 @@
.push-notifications {
&-unavailable {
body.cap-service-worker.cap-notification & {
display: none;
}
}
}

View File

@ -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

View File

@ -0,0 +1,4 @@
# frozen_string_literal: true
class Notification::PushSubscriptionError < Notification
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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))

View File

@ -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')

View File

@ -0,0 +1,4 @@
= render "settings/push_notifications", subscriptions: @subscriptions
- provide(:title, generate_title(t(".title")))
- parent_layout "user/settings"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
end

View File

@ -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]

View File

@ -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"

View File

@ -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!"

View File

@ -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}?"

View File

@ -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:

View File

@ -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]

View File

@ -12,4 +12,5 @@ production:
- mailers
- question
- export
- push_notification

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

25
public/service_worker.js Normal file
View File

@ -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}`);
}
});

View File

@ -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

View File

@ -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

View File

@ -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