From 0c7c188c459117770ac1f74f70a9e65ed2be606f Mon Sep 17 00:00:00 2001
From: Sorin Davidoi <sorin.davidoi@gmail.com>
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 394cdedfef..faefa24829 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 38ebc934f2..868a843682 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 b52685cba9..988b4d6b98 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 de0d6a1072..5599e1db16 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 0000000000..8425db7b45
--- /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 8a8b9ec76b..1585bc8105 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 0000000000..55661d2b09
--- /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 2605948947..31cac5bc7a 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 5108203587..be1ff91d66 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 (
       <div className='setting-toggle'>
         <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
         <label htmlFor={id} className='setting-toggle__label'>{label}</label>
+        {meta && <span className='setting-meta__label'>{meta}</span>}
       </div>
     );
   }
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 b139d4615c..d4ead7881b 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 d7ffa8ea6b..d2c9d1c94e 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(<Mastodon {...props} />, 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 919345f165..3aaf259c2a 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 0000000000..31a40d2461
--- /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 0000000000..364b670660
--- /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 0000000000..1708aa9f77
--- /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 0000000000..391d3bcec4
--- /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 45dd9f9142..02602afa4f 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 a91d0d72a6..4966fbc216 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 887e3e3bd4..7eb16af8f4 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 86e5782258..a63b1da7f1 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 0000000000..4440706a69
--- /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 75fef28a85..9507aad4ab 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 6751c94118..704d29a574 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 407d385ea2..0ab61b634c 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 71dcb54c63..13ca9ea79e 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 0000000000..0568a3e028
--- /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 406fa970b2..4c60965c89 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 bde69eba15..e68cb156dd 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 0000000000..74e07377c6
--- /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 c9b5d9ab8f..79efddfadb 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -335,6 +335,21 @@ en:
     next: Next
     prev: Prev
     truncate: "&hellip;"
+  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 dc5aa716bd..f9d69745fa 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -339,6 +339,21 @@ pl:
     next: Następna
     prev: Poprzednia
     truncate: "&hellip;"
+  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 963fedcb42..9171d02d4a 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 303fca81b2..4592db89e6 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 0000000000..4e5c2ba001
--- /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 0000000000..d69cdfa508
--- /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 d6e572703d..b2c59a0f66 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 004c4d1f5b..1aaa243c8b 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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
U))k@b3=9kmp00i_>zopr0JII{00000

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 0000000000..871176a07a
--- /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 0000000000..72d11b77cc
--- /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 0000000000..574da55ac2
--- /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 13c3f49518..812a0721a5 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"
-- 
GitLab