summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSorin Davidoi <sorin.davidoi@gmail.com>2017-07-13 22:15:32 +0200
committerEugen Rochko <eugen@zeonfederated.com>2017-07-13 22:15:32 +0200
commit0c7c188c459117770ac1f74f70a9e65ed2be606f (patch)
tree58d79950391d56b9742b448c2d0c83e46bbb9046
parentc2753fdfb471209fe7f2cdb8844e049207af8ba3 (diff)
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
-rw-r--r--.env.production.sample11
-rw-r--r--.gitignore1
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock6
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb39
-rw-r--r--app/controllers/home_controller.rb1
-rw-r--r--app/javascript/mastodon/actions/push_notifications.js52
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js23
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js4
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js9
-rw-r--r--app/javascript/mastodon/main.js8
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/push_notifications.js51
-rw-r--r--app/javascript/mastodon/service_worker/entry.js1
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js86
-rw-r--r--app/javascript/mastodon/web_push_subscription.js109
-rw-r--r--app/javascript/styles/components.scss8
-rw-r--r--app/javascript/styles/rtl.scss4
-rw-r--r--app/models/session_activation.rb12
-rw-r--r--app/models/user.rb4
-rw-r--r--app/models/web/push_subscription.rb190
-rw-r--r--app/presenters/initial_state_presenter.rb2
-rw-r--r--app/serializers/initial_state_serializer.rb2
-rw-r--r--app/services/notify_service.rb5
-rw-r--r--app/views/home/index.html.haml1
-rw-r--r--app/workers/web_push_notification_worker.rb27
-rw-r--r--config/environments/development.rb5
-rw-r--r--config/environments/test.rb5
-rw-r--r--config/initializers/vapid.rb17
-rw-r--r--config/locales/en.yml15
-rw-r--r--config/locales/pl.yml15
-rw-r--r--config/routes.rb5
-rw-r--r--config/webpack/production.js14
-rw-r--r--db/migrate/20170713175513_create_web_push_subscriptions.rb12
-rw-r--r--db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb5
-rw-r--r--db/schema.rb12
-rw-r--r--package.json1
-rw-r--r--public/badge.pngbin0 -> 31156 bytes
-rw-r--r--spec/controllers/api/web/push_subscriptions_controller_spec.rb81
-rw-r--r--spec/fabricators/web_push_subscription_fabricator.rb5
-rw-r--r--spec/models/web/push_subscription_spec.rb28
-rw-r--r--yarn.lock25
42 files changed, 890 insertions, 14 deletions
diff --git a/.env.production.sample b/.env.production.sample
index 394cdedfef8..faefa24829e 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 38ebc934f28..868a843682f 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 b52685cba98..988b4d6b981 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 de0d6a10723..5599e1db160 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 00000000000..8425db7b45e
--- /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 8a8b9ec76ba..1585bc81052 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 00000000000..55661d2b090
--- /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 26059489477..31cac5bc7a8 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 = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
+ const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+ const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
+ const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
+
return (
<div>
<div className='column-settings__row'>
@@ -30,7 +39,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'>
- <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+ <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+ {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div>
@@ -38,7 +48,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div className='column-settings__row'>
- <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+ <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+ {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div>
@@ -46,7 +57,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div className='column-settings__row'>
- <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+ <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+ {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div>
@@ -54,7 +66,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div className='column-settings__row'>
- <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+ <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+ {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div>
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
index 5108203587f..be1ff91d666 100644
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.j