From 0c7c188c459117770ac1f74f70a9e65ed2be606f Mon Sep 17 00:00:00 2001 From: Sorin Davidoi Date: Thu, 13 Jul 2017 22:15:32 +0200 Subject: [PATCH] Web Push Notifications (#3243) * feat: Register push subscription * feat: Notify when mentioned * feat: Boost, favourite, reply, follow, follow request * feat: Notification interaction * feat: Handle change of public key * feat: Unsubscribe if things go wrong * feat: Do not send normal notifications if push is enabled * feat: Focus client if open * refactor: Move push logic to WebPushSubscription * feat: Better title and body * feat: Localize messages * chore: Fix lint errors * feat: Settings * refactor: Lazy load * fix: Check if push settings exist * feat: Device-based preferences * refactor: Simplify logic * refactor: Pull request feedback * refactor: Pull request feedback * refactor: Create /api/web/push_subscriptions endpoint * feat: Spec PushSubscriptionController * refactor: WebPushSubscription => Web::PushSubscription * feat: Spec Web::PushSubscription * feat: Display first media attachment * feat: Support direction * fix: Stuff broken while rebasing * refactor: Integration with session activations * refactor: Cleanup * refactor: Simplify implementation * feat: Set VAPID keys via environment * chore: Comments * fix: Crash when no alerts * fix: Set VAPID keys in testing environment * fix: Follow link * feat: Notification actions * fix: Delete previous subscription * chore: Temporary logs * refactor: Move migration to a later date * fix: Fetch the correct session activation and misc bugs * refactor: Move migration to a later date * fix: Remove follow request (no notifications) * feat: Send administrator contact to push service * feat: Set time-to-live * fix: Do not show sensitive images * fix: Reducer crash in error handling * feat: Add badge * chore: Fix lint error * fix: Checkbox label overlap * fix: Check for payload support * fix: Rename action "type" (crash in latest Chrome) * feat: Action to expand notification * fix: Lint errors * fix: Unescape notification body * fix: Do not allow boosting if the status is hidden * feat: Add VAPID keys to the production sample environment * fix: Strip HTML tags from status * refactor: Better error messages * refactor: Handle browser not implementing the VAPID protocol (Samsung Internet) * fix: Error when target_status is nil * fix: Handle lack of image * fix: Delete reference to invalid subscriptions * feat: Better error handling * fix: Unescape HTML characters after tags are striped * refactor: Simpify code * fix: Modify to work with #4091 * Sort strings alphabetically * i18n: Updated Polish translation it annoys me that it's not fully localized :P * refactor: Use current_session in PushSubscriptionController * fix: Rebase mistake * fix: Set cacheName to mastodon * refactor: Pull request feedback * refactor: Remove logging statements * chore(yarn): Fix conflicts with master * chore(yarn): Copy latest from master * chore(yarn): Readd offline-plugin * refactor: Use save! and update! * refactor: Send notifications async * fix: Allow retry when push fails * fix: Save track for failed pushes * fix: Minify sw.js * fix: Remove account_id from fabricator --- .env.production.sample | 11 + .gitignore | 1 + Gemfile | 1 + Gemfile.lock | 6 + .../api/web/push_subscriptions_controller.rb | 39 ++++ app/controllers/home_controller.rb | 1 + .../mastodon/actions/push_notifications.js | 52 +++++ .../components/column_settings.js | 23 ++- .../components/setting_toggle.js | 4 +- .../containers/column_settings_container.js | 9 +- app/javascript/mastodon/main.js | 8 + app/javascript/mastodon/reducers/index.js | 2 + .../mastodon/reducers/push_notifications.js | 51 +++++ .../mastodon/service_worker/entry.js | 1 + .../service_worker/web_push_notifications.js | 86 ++++++++ .../mastodon/web_push_subscription.js | 109 ++++++++++ app/javascript/styles/components.scss | 8 +- app/javascript/styles/rtl.scss | 4 + app/models/session_activation.rb | 12 ++ app/models/user.rb | 4 + app/models/web/push_subscription.rb | 190 ++++++++++++++++++ app/presenters/initial_state_presenter.rb | 2 +- app/serializers/initial_state_serializer.rb | 2 +- app/services/notify_service.rb | 5 + app/views/home/index.html.haml | 1 + app/workers/web_push_notification_worker.rb | 27 +++ config/environments/development.rb | 5 + config/environments/test.rb | 5 + config/initializers/vapid.rb | 17 ++ config/locales/en.yml | 15 ++ config/locales/pl.yml | 15 ++ config/routes.rb | 5 + config/webpack/production.js | 14 ++ ...713175513_create_web_push_subscriptions.rb | 12 ++ ...ush_subscription_to_session_activations.rb | 5 + db/schema.rb | 12 +- package.json | 1 + public/badge.png | Bin 0 -> 31156 bytes .../web/push_subscriptions_controller_spec.rb | 81 ++++++++ .../web_push_subscription_fabricator.rb | 5 + spec/models/web/push_subscription_spec.rb | 28 +++ yarn.lock | 25 ++- 42 files changed, 890 insertions(+), 14 deletions(-) create mode 100644 app/controllers/api/web/push_subscriptions_controller.rb create mode 100644 app/javascript/mastodon/actions/push_notifications.js create mode 100644 app/javascript/mastodon/reducers/push_notifications.js create mode 100644 app/javascript/mastodon/service_worker/entry.js create mode 100644 app/javascript/mastodon/service_worker/web_push_notifications.js create mode 100644 app/javascript/mastodon/web_push_subscription.js create mode 100644 app/models/web/push_subscription.rb create mode 100644 app/workers/web_push_notification_worker.rb create mode 100644 config/initializers/vapid.rb create mode 100644 db/migrate/20170713175513_create_web_push_subscriptions.rb create mode 100644 db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb create mode 100644 public/badge.png create mode 100644 spec/controllers/api/web/push_subscriptions_controller_spec.rb create mode 100644 spec/fabricators/web_push_subscription_fabricator.rb create mode 100644 spec/models/web/push_subscription_spec.rb diff --git a/.env.production.sample b/.env.production.sample index 394cdedfe..faefa2482 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -31,6 +31,17 @@ PAPERCLIP_SECRET= SECRET_KEY_BASE= OTP_SECRET= +# VAPID keys (used for push notifications +# You can generate the keys using the following command (first is the private key, second is the public one) +# You should only generate this once per instance. If you later decide to change it, all push subscription will +# be invalidated, requiring the users to access the website again to resubscribe. +# +# ruby -e "require 'webpush'; vapid_key = Webpush.generate_key; puts vapid_key.private_key; puts vapid_key.public_key;" +# +# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html +VAPID_PRIVATE_KEY= +VAPID_PUBLIC_KEY= + # Registrations # Single user mode will disable registrations and redirect frontpage to the first profile # SINGLE_USER_MODE=true diff --git a/.gitignore b/.gitignore index 38ebc934f..868a84368 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ public/system public/assets public/packs public/packs-test +public/sw.js .env .env.production node_modules/ diff --git a/Gemfile b/Gemfile index b52685cba..988b4d6b9 100644 --- a/Gemfile +++ b/Gemfile @@ -64,6 +64,7 @@ gem 'statsd-instrument', '~> 2.1' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 2.0' +gem 'webpush' group :development, :test do gem 'fabrication', '~> 2.16' diff --git a/Gemfile.lock b/Gemfile.lock index de0d6a107..5599e1db1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -181,6 +181,7 @@ GEM hashdiff (0.3.4) highline (1.7.8) hiredis (0.6.1) + hkdf (0.3.0) htmlentities (4.3.4) http (2.2.2) addressable (~> 2.3) @@ -209,6 +210,7 @@ GEM jmespath (1.3.1) json (2.1.0) jsonapi-renderer (0.1.2) + jwt (1.5.6) kaminari (1.0.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.0.1) @@ -475,6 +477,9 @@ GEM activesupport (>= 4.2) multi_json (~> 1.2) railties (>= 4.2) + webpush (0.3.2) + hkdf (~> 0.2) + jwt websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.2) @@ -573,6 +578,7 @@ DEPENDENCIES uglifier (~> 3.2) webmock (~> 3.0) webpacker (~> 2.0) + webpush RUBY VERSION ruby 2.4.1p111 diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb new file mode 100644 index 000000000..8425db7b4 --- /dev/null +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Api::Web::PushSubscriptionsController < Api::BaseController + respond_to :json + + before_action :require_user! + + def create + params.require(:data).require(:endpoint) + params.require(:data).require(:keys).require([:auth, :p256dh]) + + active_session = current_session + + unless active_session.web_push_subscription.nil? + active_session.web_push_subscription.destroy! + active_session.update!(web_push_subscription: nil) + end + + web_subscription = ::Web::PushSubscription.create!( + endpoint: params[:data][:endpoint], + key_p256dh: params[:data][:keys][:p256dh], + key_auth: params[:data][:keys][:auth] + ) + + active_session.update!(web_push_subscription: web_subscription) + + render json: web_subscription.as_payload + end + + def update + params.require([:id, :data]) + + web_subscription = ::Web::PushSubscription.find(params[:id]) + + web_subscription.update!(data: params[:data]) + + render json: web_subscription.as_payload + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 8a8b9ec76..1585bc810 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -22,6 +22,7 @@ class HomeController < ApplicationController def initial_state_params { settings: Web::Setting.find_by(user: current_user)&.data || {}, + push_subscription: current_account.user.web_push_subscription(current_session), current_account: current_account, token: current_session.token, admin: Account.find_local(Setting.site_contact_username), diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js new file mode 100644 index 000000000..55661d2b0 --- /dev/null +++ b/app/javascript/mastodon/actions/push_notifications.js @@ -0,0 +1,52 @@ +import axios from 'axios'; + +export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; +export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; +export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE'; + +export function setBrowserSupport (value) { + return { + type: SET_BROWSER_SUPPORT, + value, + }; +} + +export function setSubscription (subscription) { + return { + type: SET_SUBSCRIPTION, + subscription, + }; +} + +export function clearSubscription () { + return { + type: CLEAR_SUBSCRIPTION, + }; +} + +export function changeAlerts(key, value) { + return dispatch => { + dispatch({ + type: ALERTS_CHANGE, + key, + value, + }); + + dispatch(saveSettings()); + }; +} + +export function saveSettings() { + return (_, getState) => { + const state = getState().get('push_notifications'); + const subscription = state.get('subscription'); + const alerts = state.get('alerts'); + + axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, { + data: { + alerts, + }, + }); + }; +} diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 260594894..31cac5bc7 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -9,18 +9,27 @@ export default class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, + pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, }; + onPushChange = (key, checked) => { + this.props.onChange(['push', ...key], checked); + } + render () { - const { settings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear } = this.props; const alertStr = ; const showStr = ; const soundStr = ; + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && ; + const pushMeta = showPushSettings && ; + return (
@@ -30,7 +39,8 @@ export default class ColumnSettings extends React.PureComponent {
- + + {showPushSettings && }
@@ -38,7 +48,8 @@ export default class ColumnSettings extends React.PureComponent {
- + + {showPushSettings && }
@@ -46,7 +57,8 @@ export default class ColumnSettings extends React.PureComponent {
- + + {showPushSettings && }
@@ -54,7 +66,8 @@ export default class ColumnSettings extends React.PureComponent {
- + + {showPushSettings && }
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index 510820358..be1ff91d6 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -10,6 +10,7 @@ export default class SettingToggle extends React.PureComponent { settings: ImmutablePropTypes.map.isRequired, settingKey: PropTypes.array.isRequired, label: PropTypes.node.isRequired, + meta: PropTypes.node, onChange: PropTypes.func.isRequired, } @@ -18,13 +19,14 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingKey, label } = this.props; + const { prefix, settings, settingKey, label, meta } = this.props; const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); return (
+ {meta && {meta}}
); } diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index b139d4615..d4ead7881 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting, saveSettings } from '../../../actions/settings'; import { clearNotifications } from '../../../actions/notifications'; +import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; const messages = defineMessages({ @@ -12,16 +13,22 @@ const messages = defineMessages({ const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (key, checked) { - dispatch(changeSetting(['notifications', ...key], checked)); + if (key[0] === 'push') { + dispatch(changePushNotifications(key.slice(1), checked)); + } else { + dispatch(changeSetting(['notifications', ...key], checked)); + } }, onSave () { dispatch(saveSettings()); + dispatch(savePushNotificationSettings()); }, onClear () { diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index d7ffa8ea6..d2c9d1c94 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -29,6 +29,14 @@ function main() { const props = JSON.parse(mountNode.getAttribute('data-props')); ReactDOM.render(, mountNode); + if (process.env.NODE_ENV === 'production') { + // avoid offline in dev mode because it's harder to debug + const OfflinePluginRuntime = require('offline-plugin/runtime'); + const WebPushSubscription = require('./web_push_subscription'); + + OfflinePluginRuntime.install(); + WebPushSubscription.register(); + } perf.stop('main()'); }); } diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 919345f16..3aaf259c2 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -10,6 +10,7 @@ import accounts_counters from './accounts_counters'; import statuses from './statuses'; import relationships from './relationships'; import settings from './settings'; +import push_notifications from './push_notifications'; import status_lists from './status_lists'; import cards from './cards'; import reports from './reports'; @@ -32,6 +33,7 @@ const reducers = { statuses, relationships, settings, + push_notifications, cards, reports, contexts, diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js new file mode 100644 index 000000000..31a40d246 --- /dev/null +++ b/app/javascript/mastodon/reducers/push_notifications.js @@ -0,0 +1,51 @@ +import { STORE_HYDRATE } from '../actions/store'; +import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from '../actions/push_notifications'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + subscription: null, + alerts: new Immutable.Map({ + follow: false, + favourite: false, + reblog: false, + mention: false, + }), + isSubscribed: false, + browserSupport: false, +}); + +export default function push_subscriptions(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: { + const push_subscription = action.state.get('push_subscription'); + + if (push_subscription) { + return state + .set('subscription', new Immutable.Map({ + id: push_subscription.get('id'), + endpoint: push_subscription.get('endpoint'), + })) + .set('alerts', push_subscription.get('alerts') || initialState.get('alerts')) + .set('isSubscribed', true); + } + + return state; + } + case SET_SUBSCRIPTION: + return state + .set('subscription', new Immutable.Map({ + id: action.subscription.id, + endpoint: action.subscription.endpoint, + })) + .set('alerts', new Immutable.Map(action.subscription.alerts)) + .set('isSubscribed', true); + case SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case CLEAR_SUBSCRIPTION: + return initialState; + case ALERTS_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js new file mode 100644 index 000000000..364b67066 --- /dev/null +++ b/app/javascript/mastodon/service_worker/entry.js @@ -0,0 +1 @@ +import './web_push_notifications'; diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js new file mode 100644 index 000000000..1708aa9f7 --- /dev/null +++ b/app/javascript/mastodon/service_worker/web_push_notifications.js @@ -0,0 +1,86 @@ +const handlePush = (event) => { + const options = event.data.json(); + + options.body = options.data.nsfw || options.data.content; + options.image = options.image || undefined; // Null results in a network request (404) + options.timestamp = options.timestamp && new Date(options.timestamp); + + const expandAction = options.data.actions.find(action => action.todo === 'expand'); + + if (expandAction) { + options.actions = [expandAction]; + options.hiddenActions = options.data.actions.filter(action => action !== expandAction); + + options.data.hiddenImage = options.image; + options.image = undefined; + } else { + options.actions = options.data.actions; + } + + event.waitUntil(self.registration.showNotification(options.title, options)); +}; + +const cloneNotification = (notification) => { + const clone = { }; + + for(var k in notification) { + clone[k] = notification[k]; + } + + return clone; +}; + +const expandNotification = (notification) => { + const nextNotification = cloneNotification(notification); + + nextNotification.body = notification.data.content; + nextNotification.image = notification.data.hiddenImage; + nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand'); + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const makeRequest = (notification, action) => + fetch(action.action, { + headers: { + 'Authorization': `Bearer ${notification.data.access_token}`, + 'Content-Type': 'application/json', + }, + method: action.method, + credentials: 'include', + }); + +const removeActionFromNotification = (notification, action) => { + const actions = notification.actions.filter(act => act.action !== action.action); + + const nextNotification = cloneNotification(notification); + + nextNotification.actions = actions; + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const handleNotificationClick = (event) => { + const reactToNotificationClick = new Promise((resolve, reject) => { + if (event.action) { + const action = event.notification.data.actions.find(({ action }) => action === event.action); + + if (action.todo === 'expand') { + resolve(expandNotification(event.notification)); + } else if (action.todo === 'request') { + resolve(makeRequest(event.notification, action) + .then(() => removeActionFromNotification(event.notification, action))); + } else { + reject(`Unknown action: ${action.todo}`); + } + } else { + event.notification.close(); + resolve(self.clients.openWindow(event.notification.data.url)); + } + }); + + event.waitUntil(reactToNotificationClick); +}; + +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js new file mode 100644 index 000000000..391d3bcec --- /dev/null +++ b/app/javascript/mastodon/web_push_subscription.js @@ -0,0 +1,109 @@ +import axios from 'axios'; +import { store } from './containers/mastodon'; +import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications'; + +// Taken from https://www.npmjs.com/package/web-push +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; + +const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); + +const getRegistration = () => navigator.serviceWorker.ready; + +const getPushSubscription = (registration) => + registration.pushManager.getSubscription() + .then(subscription => ({ registration, subscription })); + +const subscribe = (registration) => + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()), + }); + +const unsubscribe = ({ registration, subscription }) => + subscription ? subscription.unsubscribe().then(() => registration) : registration; + +const sendSubscriptionToBackend = (subscription) => + axios.post('/api/web/push_subscriptions', { + data: subscription, + }).then(response => response.data); + +// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); + +export function register () { + store.dispatch(setBrowserSupport(supportsPushNotifications)); + + if (supportsPushNotifications) { + if (!getApplicationServerKey()) { + // eslint-disable-next-line no-console + console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); + return; + } + + getRegistration() + .then(getPushSubscription) + .then(({ registration, subscription }) => { + if (subscription !== null) { + // We have a subscription, check if it is still valid + const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString(); + const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString(); + const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']); + + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid + if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { + return subscription; + } else { + // Something went wrong, try to subscribe again + return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend); + } + } + + // No subscription, try to subscribe + return subscribe(registration).then(sendSubscriptionToBackend); + }) + .then(subscription => { + // If we got a PushSubscription (and not a subscription object from the backend) + // it means that the backend subscription is valid (and was set during hydration) + if (!(subscription instanceof PushSubscription)) { + store.dispatch(setSubscription(subscription)); + } + }) + .catch(error => { + if (error.code === 20 && error.name === 'AbortError') { + // eslint-disable-next-line no-console + console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); + } else if (error.code === 5 && error.name === 'InvalidCharacterError') { + // eslint-disable-next-line no-console + console.error('The VAPID public key seems to be invalid:', getApplicationServerKey()); + } + + // Clear alerts and hide UI settings + store.dispatch(clearSubscription()); + + try { + getRegistration() + .then(getPushSubscription) + .then(unsubscribe); + } catch (e) { + + } + }); + } else { + // eslint-disable-next-line no-console + console.warn('Your browser does not support Web Push Notifications.'); + } +} diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 45dd9f914..02602afa4 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -2352,7 +2352,8 @@ button.icon-button.active i.fa-retweet { line-height: 24px; } -.setting-toggle__label { +.setting-toggle__label, +.setting-meta__label { color: $ui-primary-color; display: inline-block; margin-bottom: 14px; @@ -2360,6 +2361,11 @@ button.icon-button.active i.fa-retweet { vertical-align: middle; } +.setting-meta__label { + color: $ui-primary-color; + float: right; +} + .empty-column-indicator, .error-column { color: lighten($ui-base-color, 20%); diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss index a91d0d72a..4966fbc21 100644 --- a/app/javascript/styles/rtl.scss +++ b/app/javascript/styles/rtl.scss @@ -45,6 +45,10 @@ body.rtl { margin-right: 8px; } + .setting-meta__label { + float: left; + } + .status__avatar { left: auto; right: 10px; diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index 887e3e3bd..7eb16af8f 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -3,6 +3,17 @@ # # Table name: session_activations # +# id :integer not null, primary key +# user_id :integer not null +# session_id :string not null +# created_at :datetime not null +# updated_at :datetime not null +# user_agent :string default(""), not null +# ip :inet +# access_token_id :integer +# web_push_subscription_id :integer +# + # id :integer not null, primary key # user_id :integer not null # session_id :string not null @@ -15,6 +26,7 @@ class SessionActivation < ApplicationRecord belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy + belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy delegate :token, to: :access_token, diff --git a/app/models/user.rb b/app/models/user.rb index 86e578225..a63b1da7f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -113,6 +113,10 @@ class User < ApplicationRecord session_activations.active? id end + def web_push_subscription(session) + session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload + end + protected def send_devise_notification(notification, *args) diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb new file mode 100644 index 000000000..4440706a6 --- /dev/null +++ b/app/models/web/push_subscription.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: web_push_subscriptions +# +# id :integer not null, primary key +# endpoint :string not null +# key_p256dh :string not null +# key_auth :string not null +# data :json +# created_at :datetime not null +# updated_at :datetime not null +# + +class Web::PushSubscription < ApplicationRecord + include RoutingHelper + include StreamEntriesHelper + include ActionView::Helpers::TranslationHelper + include ActionView::Helpers::SanitizeHelper + + has_one :session_activation + + before_create :send_welcome_notification + + def push(notification) + return unless pushable? notification + + name = display_name notification.from_account + title = title_str(name, notification) + body = body_str notification + dir = dir_str body + url = url_str notification + image = image_str notification + actions = actions_arr notification + + access_token = actions.empty? ? nil : find_or_create_access_token(notification).token + nsfw = notification.target_status.nil? || notification.target_status.spoiler_text.empty? ? nil : notification.target_status.spoiler_text + + # TODO: Make sure that the payload does not exceed 4KB - Webpush::PayloadTooLarge + # TODO: Queue the requests - Webpush::TooManyRequests + Webpush.payload_send( + message: JSON.generate( + title: title, + dir: dir, + image: image, + badge: full_asset_url('badge.png'), + tag: notification.id, + timestamp: notification.created_at, + icon: notification.from_account.avatar_static_url, + data: { + content: decoder.decode(strip_tags(body)), + nsfw: nsfw.nil? ? nil : decoder.decode(strip_tags(nsfw)), + url: url, + actions: actions, + access_token: access_token, + } + ), + endpoint: endpoint, + p256dh: key_p256dh, + auth: key_auth, + vapid: { + # subject: "mailto:#{Setting.site_contact_email}", + private_key: Rails.configuration.x.vapid_private_key, + public_key: Rails.configuration.x.vapid_public_key, + }, + ttl: 40 * 60 * 60 # 48 hours + ) + end + + def as_payload + payload = { + id: id, + endpoint: endpoint, + } + + payload[:alerts] = data['alerts'] if data && data.key?('alerts') + + payload + end + + private + + def title_str(name, notification) + case notification.type + when :mention then translate('push_notifications.mention.title', name: name) + when :follow then translate('push_notifications.follow.title', name: name) + when :favourite then translate('push_notifications.favourite.title', name: name) + when :reblog then translate('push_notifications.reblog.title', name: name) + end + end + + def body_str(notification) + case notification.type + when :mention then notification.target_status.text + when :follow then notification.from_account.note + when :favourite then notification.target_status.text + when :reblog then notification.target_status.text + end + end + + def url_str(notification) + case notification.type + when :mention then web_url("statuses/#{notification.target_status.id}") + when :follow then web_url("accounts/#{notification.from_account.id}") + when :favourite then web_url("statuses/#{notification.target_status.id}") + when :reblog then web_url("statuses/#{notification.target_status.id}") + end + end + + def actions_arr(notification) + actions = + case notification.type + when :mention then [ + { + title: translate('push_notifications.mention.action_favourite'), + icon: full_asset_url('emoji/2764.png'), + todo: 'request', + method: 'POST', + action: "/api/v1/statuses/#{notification.target_status.id}/favourite", + }, + ] + else [] + end + + should_hide = notification.type.equal?(:mention) && !notification.target_status.nil? && (notification.target_status.sensitive || !notification.target_status.spoiler_text.empty?) + can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden? + + if should_hide + actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('emoji/1f441.png'), todo: 'expand', action: 'expand') + end + + if can_boost + actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('emoji/1f504.png'), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" } + end + + actions + end + + def image_str(notification) + return nil if notification.target_status.nil? || notification.target_status.media_attachments.empty? + + full_asset_url(notification.target_status.media_attachments.first.file.url(:small)) + end + + def dir_str(body) + rtl?(body) ? 'rtl' : 'ltr' + end + + def pushable?(notification) + data && data.key?('alerts') && data['alerts'][notification.type.to_s] + end + + def send_welcome_notification + Webpush.payload_send( + message: JSON.generate( + title: translate('push_notifications.subscribed.title'), + icon: full_asset_url('android-chrome-192x192.png'), + badge: full_asset_url('badge.png'), + data: { + content: translate('push_notifications.subscribed.body'), + actions: [], + url: web_url('notifications'), + } + ), + endpoint: endpoint, + p256dh: key_p256dh, + auth: key_auth, + vapid: { + # subject: "mailto:#{Setting.site_contact_email}", + private_key: Rails.configuration.x.vapid_private_key, + public_key: Rails.configuration.x.vapid_public_key, + }, + ttl: 5 * 60 # 5 minutes + ) + end + + def find_or_create_access_token(notification) + Doorkeeper::AccessToken.find_or_create_for( + Doorkeeper::Application.find_by(superapp: true), + notification.account.user.id, + Doorkeeper::OAuth::Scopes.from_string('read write follow'), + Doorkeeper.configuration.access_token_expires_in, + Doorkeeper.configuration.refresh_token_enabled? + ) + end + + def decoder + @decoder ||= HTMLEntities.new + end +end diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb index 75fef28a8..9507aad4a 100644 --- a/app/presenters/initial_state_presenter.rb +++ b/app/presenters/initial_state_presenter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class InitialStatePresenter < ActiveModelSerializers::Model - attributes :settings, :token, :current_account, :admin + attributes :settings, :push_subscription, :token, :current_account, :admin end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 6751c9411..704d29a57 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -2,7 +2,7 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, - :media_attachments, :settings + :media_attachments, :settings, :push_subscription def meta store = { diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 407d385ea..0ab61b634 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -61,6 +61,11 @@ class NotifyService < BaseService @notification.save! return unless @notification.browserable? Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification))) + send_push_notifications + end + + def send_push_notifications + WebPushNotificationWorker.perform_async(@recipient.id, @notification.id) end def send_email diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 71dcb54c6..13ca9ea79 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,4 +1,5 @@ - content_for :header_tags do + %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key} %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) = javascript_pack_tag 'application', integrity: true, crossorigin: 'anonymous' diff --git a/app/workers/web_push_notification_worker.rb b/app/workers/web_push_notification_worker.rb new file mode 100644 index 000000000..0568a3e02 --- /dev/null +++ b/app/workers/web_push_notification_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class WebPushNotificationWorker + include Sidekiq::Worker + + sidekiq_options backtrace: true + + def perform(recipient_id, notification_id) + recipient = Account.find(recipient_id) + notification = Notification.find(notification_id) + + sessions_with_subscriptions = recipient.user.session_activations.reject { |session| session.web_push_subscription.nil? } + + sessions_with_subscriptions.each do |session| + begin + session.web_push_subscription.push(notification) + rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription + # Subscription expiration is not currently implemented in any browser + session.web_push_subscription.destroy! + session.web_push_subscription = nil + session.save! + rescue Webpush::PayloadTooLarge => e + Rails.logger.error(e) + end + end + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index 406fa970b..4c60965c8 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -31,6 +31,11 @@ Rails.application.configure do config.logger = ActiveSupport::TaggedLogging.new(logger) end + # Generate random VAPID keys + vapid_key = Webpush.generate_key + config.x.vapid_private_key = vapid_key.private_key + config.x.vapid_public_key = vapid_key.public_key + # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false diff --git a/config/environments/test.rb b/config/environments/test.rb index bde69eba1..e68cb156d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -40,6 +40,11 @@ Rails.application.configure do # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + # Generate random VAPID keys + vapid_key = Webpush.generate_key + config.x.vapid_private_key = vapid_key.private_key + config.x.vapid_public_key = vapid_key.public_key + # Raises error for missing translations # config.action_view.raise_on_missing_translations = true end diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb new file mode 100644 index 000000000..74e07377c --- /dev/null +++ b/config/initializers/vapid.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Rails.application.configure do + + # You can generate the keys using the following command (first is the private key, second is the public one) + # You should only generate this once per instance. If you later decide to change it, all push subscription will + # be invalidated, requiring the users to access the website again to resubscribe. + # + # ruby -e "require 'webpush'; vapid_key = Webpush.generate_key; puts vapid_key.private_key; puts vapid_key.public_key;" + # + # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html + + if Rails.env.production? + config.x.vapid_private_key = ENV['VAPID_PRIVATE_KEY'] + config.x.vapid_public_key = ENV['VAPID_PUBLIC_KEY'] + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index c9b5d9ab8..79efddfad 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -335,6 +335,21 @@ en: next: Next prev: Prev truncate: "…" + push_notifications: + favourite: + title: "%{name} favourited your status" + follow: + title: "%{name} is now following you" + mention: + action_boost: 'Boost' + action_expand: 'Show more' + action_favourite: 'Favourite' + title: "%{name} mentioned you" + reblog: + title: "%{name} boosted your status" + subscribed: + body: "You can now receive push notifications." + title: "Subscription registered!" remote_follow: acct: Enter your username@domain you want to follow from missing_resource: Could not find the required redirect URL for your account diff --git a/config/locales/pl.yml b/config/locales/pl.yml index dc5aa716b..f9d69745f 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -339,6 +339,21 @@ pl: next: Następna prev: Poprzednia truncate: "…" + push_notifications: + favourite: + title: "%{name} dodał Twój status do ulubionych" + follow: + title: "%{name} zaczął Cię śledzić" + mention: + action_boost: 'Podbij' + action_expand: 'Pokaż więcej' + action_favourite: 'Dodaj do ulubionych' + title: "%{name} wspomniał o Tobie" + reblog: + title: "%{name} podbił Twój status" + subscribed: + body: "Otrzymujesz teraz powiadomienia push." + title: "Zarejestrowano subskrypcję!" remote_follow: acct: Podaj swój adres (nazwa@domena), z którego chcesz śledzić missing_resource: Nie udało się znaleźć adresu przekierowania z Twojej domeny diff --git a/config/routes.rb b/config/routes.rb index 963fedcb4..9171d02d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -206,6 +206,11 @@ Rails.application.routes.draw do namespace :web do resource :settings, only: [:update] + resources :push_subscriptions, only: [:create] do + member do + put :update + end + end end end diff --git a/config/webpack/production.js b/config/webpack/production.js index 303fca81b..4592db89e 100644 --- a/config/webpack/production.js +++ b/config/webpack/production.js @@ -5,6 +5,9 @@ const merge = require('webpack-merge'); const CompressionPlugin = require('compression-webpack-plugin'); const sharedConfig = require('./shared.js'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const OfflinePlugin = require('offline-plugin'); +const { publicPath } = require('./configuration.js'); +const path = require('path'); module.exports = merge(sharedConfig, { output: { filename: '[name]-[chunkhash].js' }, @@ -39,5 +42,16 @@ module.exports = merge(sharedConfig, { openAnalyzer: false, logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout }), + new OfflinePlugin({ + publicPath: publicPath, // sw.js must be served from the root to avoid scope issues + caches: { }, // do not cache things, we only use it for push notifications for now + ServiceWorker: { + entry: path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'), + cacheName: 'mastodon', + output: '../sw.js', + publicPath: '/sw.js', + minify: true, + }, + }), ], }); diff --git a/db/migrate/20170713175513_create_web_push_subscriptions.rb b/db/migrate/20170713175513_create_web_push_subscriptions.rb new file mode 100644 index 000000000..4e5c2ba00 --- /dev/null +++ b/db/migrate/20170713175513_create_web_push_subscriptions.rb @@ -0,0 +1,12 @@ +class CreateWebPushSubscriptions < ActiveRecord::Migration[5.1] + def change + create_table :web_push_subscriptions do |t| + t.string :endpoint, null: false + t.string :key_p256dh, null: false + t.string :key_auth, null: false + t.json :data + + t.timestamps + end + end +end diff --git a/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb b/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb new file mode 100644 index 000000000..d69cdfa50 --- /dev/null +++ b/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb @@ -0,0 +1,5 @@ +class AddWebPushSubscriptionToSessionActivations < ActiveRecord::Migration[5.1] + def change + add_column :session_activations, :web_push_subscription_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index d6e572703..b2c59a0f6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170713112503) do +ActiveRecord::Schema.define(version: 20170713190709) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -258,6 +258,7 @@ ActiveRecord::Schema.define(version: 20170713112503) do t.string "user_agent", default: "", null: false t.inet "ip" t.integer "access_token_id" + t.integer "web_push_subscription_id" t.index ["session_id"], name: "index_session_activations_on_session_id", unique: true t.index ["user_id"], name: "index_session_activations_on_user_id" end @@ -371,6 +372,15 @@ ActiveRecord::Schema.define(version: 20170713112503) do t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + create_table "web_push_subscriptions", force: :cascade do |t| + t.string "endpoint", null: false + t.string "key_p256dh", null: false + t.string "key_auth", null: false + t.json "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "web_settings", id: :serial, force: :cascade do |t| t.integer "user_id" t.json "data" diff --git a/package.json b/package.json index 004c4d1f5..1aaa243c8 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "node-sass": "^4.5.2", "npmlog": "^4.1.2", "object-assign": "^4.1.1", + "offline-plugin": "^4.8.3", "path-complete-extname": "^0.1.0", "pg": "^6.4.0", "postcss-loader": "^2.0.6", diff --git a/public/badge.png b/public/badge.png new file mode 100644 index 0000000000000000000000000000000000000000..fc1f42dca135ab028fbf3194a74329eb7c5103ee GIT binary patch literal 31156 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 literal 0 HcmV?d00001 diff --git a/spec/controllers/api/web/push_subscriptions_controller_spec.rb b/spec/controllers/api/web/push_subscriptions_controller_spec.rb new file mode 100644 index 000000000..871176a07 --- /dev/null +++ b/spec/controllers/api/web/push_subscriptions_controller_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::Web::PushSubscriptionsController do + render_views + + let(:user) { Fabricate(:user) } + + let(:create_payload) do + { + data: { + endpoint: 'https://fcm.googleapis.com/fcm/send/fiuH06a27qE:APA91bHnSiGcLwdaxdyqVXNDR9w1NlztsHb6lyt5WDKOC_Z_Q8BlFxQoR8tWFSXUIDdkyw0EdvxTu63iqamSaqVSevW5LfoFwojws8XYDXv_NRRLH6vo2CdgiN4jgHv5VLt2A8ah6lUX', + keys: { + p256dh: 'BEm_a0bdPDhf0SOsrnB2-ategf1hHoCnpXgQsFj5JCkcoMrMt2WHoPfEYOYPzOIs9mZE8ZUaD7VA5vouy0kEkr8=', + auth: 'eH_C8rq2raXqlcBVDa1gLg==', + }, + } + } + end + + let(:alerts_payload) do + { + data: { + alerts: { + follow: true, + favourite: false, + reblog: true, + mention: false, + } + } + } + end + + describe 'POST #create' do + it 'saves push subscriptions' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + + user.reload + + push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]) + + expect(push_subscription['endpoint']).to eq(create_payload[:data][:endpoint]) + expect(push_subscription['key_p256dh']).to eq(create_payload[:data][:keys][:p256dh]) + expect(push_subscription['key_auth']).to eq(create_payload[:data][:keys][:auth]) + end + + it 'sends welcome notification' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + end + end + + describe 'PUT #update' do + it 'changes alert settings' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + + alerts_payload[:id] = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]).id + + put :update, format: :json, params: alerts_payload + + push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]) + + expect(push_subscription.data['follow']).to eq(alerts_payload[:data][:follow]) + expect(push_subscription.data['favourite']).to eq(alerts_payload[:data][:favourite]) + expect(push_subscription.data['reblog']).to eq(alerts_payload[:data][:reblog]) + expect(push_subscription.data['mention']).to eq(alerts_payload[:data][:mention]) + end + end +end diff --git a/spec/fabricators/web_push_subscription_fabricator.rb b/spec/fabricators/web_push_subscription_fabricator.rb new file mode 100644 index 000000000..72d11b77c --- /dev/null +++ b/spec/fabricators/web_push_subscription_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:web_push_subscription) do + endpoint Faker::Internet.url + key_p256dh Faker::Internet.password + key_auth Faker::Internet.password +end diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb new file mode 100644 index 000000000..574da55ac --- /dev/null +++ b/spec/models/web/push_subscription_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe Web::PushSubscription, type: :model do + let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } } + let(:payload_no_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd').as_payload } + let(:payload_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd', data: { alerts: alerts }).as_payload } + let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) } + + describe '#as_payload' do + it 'only returns id and endpoint' do + expect(payload_no_alerts.keys).to eq [:id, :endpoint] + end + + it 'returns alerts if set' do + expect(payload_alerts.keys).to eq [:id, :endpoint, :alerts] + end + end + + describe '#pushable?' do + it 'obeys alert settings' do + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true + end + end +end diff --git a/yarn.lock b/yarn.lock index 13c3f4951..812a0721a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2209,7 +2209,7 @@ deep-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" -deep-extend@~0.4.0: +deep-extend@^0.4.0, deep-extend@~0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f" @@ -2416,7 +2416,7 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" -ejs@^2.5.6: +ejs@^2.3.4, ejs@^2.5.6: version "2.5.6" resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88" @@ -4059,6 +4059,15 @@ loader-runner@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" +loader-utils@0.2.x: + version "0.2.17" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348" + dependencies: + big.js "^3.1.3" + emojis-list "^2.0.0" + json5 "^0.5.0" + object-assign "^4.0.1" + loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" @@ -4419,7 +4428,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" -minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2: +minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" dependencies: @@ -4760,6 +4769,16 @@ obuf@^1.0.0, obuf@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e" +offline-plugin@^4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/offline-plugin/-/offline-plugin-4.8.3.tgz#9e95bd342ea2ac836b001b81f204c40638694d6c" + dependencies: + deep-extend "^0.4.0" + ejs "^2.3.4" + loader-utils "0.2.x" + minimatch "^3.0.3" + slash "^1.0.0" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"