summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.env.production.sample11
-rw-r--r--.gitignore1
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock6
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb39
-rw-r--r--app/controllers/home_controller.rb1
-rw-r--r--app/javascript/mastodon/actions/push_notifications.js52
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js23
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js4
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js9
-rw-r--r--app/javascript/mastodon/main.js8
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/push_notifications.js51
-rw-r--r--app/javascript/mastodon/service_worker/entry.js1
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js86
-rw-r--r--app/javascript/mastodon/web_push_subscription.js109
-rw-r--r--app/javascript/styles/components.scss8
-rw-r--r--app/javascript/styles/rtl.scss4
-rw-r--r--app/models/session_activation.rb12
-rw-r--r--app/models/user.rb4
-rw-r--r--app/models/web/push_subscription.rb190
-rw-r--r--app/presenters/initial_state_presenter.rb2
-rw-r--r--app/serializers/initial_state_serializer.rb2
-rw-r--r--app/services/notify_service.rb5
-rw-r--r--app/views/home/index.html.haml1
-rw-r--r--app/workers/web_push_notification_worker.rb27
-rw-r--r--config/environments/development.rb5
-rw-r--r--config/environments/test.rb5
-rw-r--r--config/initializers/vapid.rb17
-rw-r--r--config/locales/en.yml15
-rw-r--r--config/locales/pl.yml15
-rw-r--r--config/routes.rb5
-rw-r--r--config/webpack/production.js14
-rw-r--r--db/migrate/20170713175513_create_web_push_subscriptions.rb12
-rw-r--r--db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb5
-rw-r--r--db/schema.rb12
-rw-r--r--package.json1
-rw-r--r--public/badge.pngbin0 -> 31156 bytes
-rw-r--r--spec/controllers/api/web/push_subscriptions_controller_spec.rb81
-rw-r--r--spec/fabricators/web_push_subscription_fabricator.rb5
-rw-r--r--spec/models/web/push_subscription_spec.rb28
-rw-r--r--yarn.lock25
42 files changed, 890 insertions, 14 deletions
diff --git a/.env.production.sample b/.env.production.sample
index 394cdedfef8..faefa24829e 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -31,6 +31,17 @@ PAPERCLIP_SECRET=
SECRET_KEY_BASE=
OTP_SECRET=
+# VAPID keys (used for push notifications
+# You can generate the keys using the following command (first is the private key, second is the public one)
+# You should only generate this once per instance. If you later decide to change it, all push subscription will
+# be invalidated, requiring the users to access the website again to resubscribe.
+#
+# ruby -e "require 'webpush'; vapid_key = Webpush.generate_key; puts vapid_key.private_key; puts vapid_key.public_key;"
+#
+# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
+VAPID_PRIVATE_KEY=
+VAPID_PUBLIC_KEY=
+
# Registrations
# Single user mode will disable registrations and redirect frontpage to the first profile
# SINGLE_USER_MODE=true
diff --git a/.gitignore b/.gitignore
index 38ebc934f28..868a843682f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ public/system
public/assets
public/packs
public/packs-test
+public/sw.js
.env
.env.production
node_modules/
diff --git a/Gemfile b/Gemfile
index b52685cba98..988b4d6b981 100644
--- a/Gemfile
+++ b/Gemfile
@@ -64,6 +64,7 @@ gem 'statsd-instrument', '~> 2.1'
gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 2.0'
+gem 'webpush'
group :development, :test do
gem 'fabrication', '~> 2.16'
diff --git a/Gemfile.lock b/Gemfile.lock
index de0d6a10723..5599e1db160 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -181,6 +181,7 @@ GEM
hashdiff (0.3.4)
highline (1.7.8)
hiredis (0.6.1)
+ hkdf (0.3.0)
htmlentities (4.3.4)
http (2.2.2)
addressable (~> 2.3)
@@ -209,6 +210,7 @@ GEM
jmespath (1.3.1)
json (2.1.0)
jsonapi-renderer (0.1.2)
+ jwt (1.5.6)
kaminari (1.0.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.0.1)
@@ -475,6 +477,9 @@ GEM
activesupport (>= 4.2)
multi_json (~> 1.2)
railties (>= 4.2)
+ webpush (0.3.2)
+ hkdf (~> 0.2)
+ jwt
websocket-driver (0.6.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2)
@@ -573,6 +578,7 @@ DEPENDENCIES
uglifier (~> 3.2)
webmock (~> 3.0)
webpacker (~> 2.0)
+ webpush
RUBY VERSION
ruby 2.4.1p111
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
new file mode 100644
index 00000000000..8425db7b45e
--- /dev/null
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class Api::Web::PushSubscriptionsController < Api::BaseController
+ respond_to :json
+
+ before_action :require_user!
+
+ def create
+ params.require(:data).require(:endpoint)
+ params.require(:data).require(:keys).require([:auth, :p256dh])
+
+ active_session = current_session
+
+ unless active_session.web_push_subscription.nil?
+ active_session.web_push_subscription.destroy!
+ active_session.update!(web_push_subscription: nil)
+ end
+
+ web_subscription = ::Web::PushSubscription.create!(
+ endpoint: params[:data][:endpoint],
+ key_p256dh: params[:data][:keys][:p256dh],
+ key_auth: params[:data][:keys][:auth]
+ )
+
+ active_session.update!(web_push_subscription: web_subscription)
+
+ render json: web_subscription.as_payload
+ end
+
+ def update
+ params.require([:id, :data])
+
+ web_subscription = ::Web::PushSubscription.find(params[:id])
+
+ web_subscription.update!(data: params[:data])
+
+ render json: web_subscription.as_payload
+ end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index 8a8b9ec76ba..1585bc81052 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -22,6 +22,7 @@ class HomeController < ApplicationController
def initial_state_params
{
settings: Web::Setting.find_by(user: current_user)&.data || {},
+ push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account,
token: current_session.token,
admin: Account.find_local(Setting.site_contact_username),
diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js
new file mode 100644
index 00000000000..55661d2b090
--- /dev/null
+++ b/app/javascript/mastodon/actions/push_notifications.js
@@ -0,0 +1,52 @@
+import axios from 'axios';
+
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
+
+export function setBrowserSupport (value) {
+ return {
+ type: SET_BROWSER_SUPPORT,
+ value,
+ };
+}
+
+export function setSubscription (subscription) {
+ return {
+ type: SET_SUBSCRIPTION,
+ subscription,
+ };
+}
+
+export function clearSubscription () {
+ return {
+ type: CLEAR_SUBSCRIPTION,
+ };
+}
+
+export function changeAlerts(key, value) {
+ return dispatch => {
+ dispatch({
+ type: ALERTS_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveSettings());
+ };
+}
+
+export function saveSettings() {
+ return (_, getState) => {
+ const state = getState().get('push_notifications');
+ const subscription = state.get('subscription');
+ const alerts = state.get('alerts');
+
+ axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+ data: {
+ alerts,
+ },
+ });
+ };
+}
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 26059489477..31cac5bc7a8 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -9,18 +9,27 @@ export default class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
+ pushSettings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
+ onPushChange = (key, checked) => {
+ this.props.onChange(['push', ...key], checked);
+ }
+
render () {
- const { settings, onChange, onClear } = this.props;
+ const { settings, pushSettings, onChange, onClear } = this.props;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
+ const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+ const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
+ const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
+
return (
<div>
<div className='column-settings__row'>
@@ -30,7 +39,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'>
- <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+ <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+ {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div>
@@ -38,7 +48,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div className='column-settings__row'>
- <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+ <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+ {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div>
@@ -46,7 +57,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div className='column-settings__row'>
- <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+ <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+ {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div>
@@ -54,7 +66,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div className='column-settings__row'>
- <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+ <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+ {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div>
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
index 5108203587f..be1ff91d666 100644
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.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 b139d4615c4..d4ead7881b5 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 d7ffa8ea6bf..d2c9d1c94ea 100644
--- a/app/javas