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:
parent
c2753fdfb4
commit
0c7c188c45
42 changed files with 890 additions and 14 deletions
39
app/controllers/api/web/push_subscriptions_controller.rb
Normal file
39
app/controllers/api/web/push_subscriptions_controller.rb
Normal file
|
@ -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
|
|
@ -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),
|
||||
|
|
52
app/javascript/mastodon/actions/push_notifications.js
Normal file
52
app/javascript/mastodon/actions/push_notifications.js
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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()');
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
51
app/javascript/mastodon/reducers/push_notifications.js
Normal file
51
app/javascript/mastodon/reducers/push_notifications.js
Normal 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;
|
||||
}
|
||||
};
|
1
app/javascript/mastodon/service_worker/entry.js
Normal file
1
app/javascript/mastodon/service_worker/entry.js
Normal file
|
@ -0,0 +1 @@
|
|||
import './web_push_notifications';
|
|
@ -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);
|
109
app/javascript/mastodon/web_push_subscription.js
Normal file
109
app/javascript/mastodon/web_push_subscription.js
Normal 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.');
|
||||
}
|
||||
}
|
|
@ -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%);
|
||||
|
|
|
@ -45,6 +45,10 @@ body.rtl {
|
|||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.setting-meta__label {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.status__avatar {
|
||||
left: auto;
|
||||
right: 10px;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
190
app/models/web/push_subscription.rb
Normal file
190
app/models/web/push_subscription.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
class InitialStateSerializer < ActiveModel::Serializer
|
||||
attributes :meta, :compose, :accounts,
|
||||
:media_attachments, :settings
|
||||
:media_attachments, :settings, :push_subscription
|
||||
|
||||
def meta
|
||||
store = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
27
app/workers/web_push_notification_worker.rb
Normal file
27
app/workers/web_push_notification_worker.rb
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue