0
0
Fork 0

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
This commit is contained in:
Sorin Davidoi 2017-07-13 22:15:32 +02:00 committed by Eugen Rochko
parent c2753fdfb4
commit 0c7c188c45
42 changed files with 890 additions and 14 deletions

View file

@ -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,
},
});
};
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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 () {

View file

@ -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()');
});
}

View file

@ -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,

View file

@ -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;
}
};

View file

@ -0,0 +1 @@
import './web_push_notifications';

View file

@ -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);

View file

@ -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.');
}
}