about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript')
-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
12 files changed, 349 insertions, 8 deletions
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;