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 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 = <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 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 (
       <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 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(<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 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: "&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 dc5aa716b..f9d69745f 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 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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
XmIvNiJ9t$G00000NkvXXu0mjfv<>6{

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"