Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web Push Notifications #3243

Merged
merged 85 commits into from Jul 13, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
eebfcd4
feat: Register push subscription
sorin-davidoi May 23, 2017
e2de68b
feat: Notify when mentioned
sorin-davidoi May 23, 2017
ea121eb
feat: Boost, favourite, reply, follow, follow request
sorin-davidoi May 24, 2017
1574e0a
feat: Notification interaction
sorin-davidoi May 25, 2017
10cc5c0
feat: Handle change of public key
sorin-davidoi May 25, 2017
3d8a853
feat: Unsubscribe if things go wrong
sorin-davidoi May 25, 2017
d54e077
feat: Do not send normal notifications if push is enabled
sorin-davidoi May 25, 2017
fc066d8
feat: Focus client if open
sorin-davidoi May 25, 2017
5439e88
refactor: Move push logic to WebPushSubscription
sorin-davidoi May 25, 2017
5e1aca8
feat: Better title and body
sorin-davidoi May 25, 2017
78e469c
feat: Localize messages
sorin-davidoi May 25, 2017
28680db
chore: Fix lint errors
sorin-davidoi May 25, 2017
20a6590
feat: Settings
sorin-davidoi May 25, 2017
31ecfe6
refactor: Lazy load
sorin-davidoi May 25, 2017
0e96635
fix: Check if push settings exist
sorin-davidoi May 26, 2017
48c9a48
feat: Device-based preferences
sorin-davidoi May 26, 2017
ca4bd93
refactor: Simplify logic
sorin-davidoi May 26, 2017
cb80ab7
refactor: Pull request feedback
sorin-davidoi May 28, 2017
e62a088
refactor: Pull request feedback
sorin-davidoi May 29, 2017
ab3ebba
refactor: Create /api/web/push_subscriptions endpoint
sorin-davidoi May 29, 2017
2869304
feat: Spec PushSubscriptionController
sorin-davidoi May 29, 2017
1a9e58f
refactor: WebPushSubscription => Web::PushSubscription
sorin-davidoi May 29, 2017
551294d
feat: Spec Web::PushSubscription
sorin-davidoi May 29, 2017
74b623a
feat: Display first media attachment
sorin-davidoi May 30, 2017
709783b
feat: Support direction
sorin-davidoi May 30, 2017
2e2f374
fix: Stuff broken while rebasing
sorin-davidoi Jun 23, 2017
eab8055
refactor: Integration with session activations
sorin-davidoi Jun 23, 2017
aa1fbfb
refactor: Cleanup
sorin-davidoi Jun 24, 2017
2def72c
refactor: Simplify implementation
sorin-davidoi Jun 24, 2017
e854402
feat: Set VAPID keys via environment
sorin-davidoi Jun 24, 2017
85063dc
chore: Comments
sorin-davidoi Jun 24, 2017
1178d66
fix: Crash when no alerts
sorin-davidoi Jun 24, 2017
cd32e56
fix: Set VAPID keys in testing environment
sorin-davidoi Jun 24, 2017
1654ec4
fix: Follow link
sorin-davidoi Jun 24, 2017
f994b6c
feat: Notification actions
sorin-davidoi Jun 25, 2017
7faf799
fix: Delete previous subscription
sorin-davidoi Jun 25, 2017
b040b03
chore: Temporary logs
sorin-davidoi Jun 25, 2017
74655b1
refactor: Move migration to a later date
sorin-davidoi Jun 25, 2017
b2a77fd
fix: Fetch the correct session activation and misc bugs
sorin-davidoi Jun 25, 2017
2b12685
refactor: Move migration to a later date
sorin-davidoi Jun 25, 2017
02643dc
fix: Remove follow request (no notifications)
sorin-davidoi Jun 26, 2017
2fe1fff
feat: Send administrator contact to push service
sorin-davidoi Jun 26, 2017
7a73ab7
feat: Set time-to-live
sorin-davidoi Jun 26, 2017
9d0718f
fix: Do not show sensitive images
sorin-davidoi Jun 26, 2017
aefab25
fix: Reducer crash in error handling
sorin-davidoi Jun 26, 2017
c94097b
feat: Add badge
sorin-davidoi Jun 26, 2017
7397140
chore: Fix lint error
sorin-davidoi Jun 27, 2017
34be2b4
fix: Checkbox label overlap
sorin-davidoi Jun 27, 2017
42bb7ee
fix: Check for payload support
sorin-davidoi Jun 27, 2017
7856c72
fix: Rename action "type" (crash in latest Chrome)
sorin-davidoi Jun 27, 2017
896d46b
feat: Action to expand notification
sorin-davidoi Jun 27, 2017
7510c90
fix: Lint errors
sorin-davidoi Jun 28, 2017
8956a9d
fix: Unescape notification body
sorin-davidoi Jun 28, 2017
f2edbe4
fix: Do not allow boosting if the status is hidden
sorin-davidoi Jun 28, 2017
0b7a014
feat: Add VAPID keys to the production sample environment
sorin-davidoi Jun 29, 2017
1075327
fix: Strip HTML tags from status
sorin-davidoi Jun 29, 2017
44c5780
refactor: Better error messages
sorin-davidoi Jun 29, 2017
53ef817
refactor: Handle browser not implementing the VAPID protocol (Samsung…
sorin-davidoi Jun 29, 2017
4cce3b7
fix: Error when target_status is nil
sorin-davidoi Jun 30, 2017
af5aea3
fix: Handle lack of image
sorin-davidoi Jul 1, 2017
d57696f
fix: Delete reference to invalid subscriptions
sorin-davidoi Jul 2, 2017
7583c0f
feat: Better error handling
sorin-davidoi Jul 2, 2017
b9a6113
fix: Unescape HTML characters after tags are striped
sorin-davidoi Jul 4, 2017
e0d9ee5
refactor: Simpify code
sorin-davidoi Jul 4, 2017
7c3442b
fix: Modify to work with #4091
sorin-davidoi Jul 7, 2017
79eba27
Sort strings alphabetically
Jul 7, 2017
8bcb9b4
i18n: Updated Polish translation
Jul 7, 2017
9c3c5b2
refactor: Use current_session in PushSubscriptionController
sorin-davidoi Jul 7, 2017
3969c85
fix: Rebase mistake
sorin-davidoi Jul 8, 2017
52189e3
fix: Set cacheName to mastodon
sorin-davidoi Jul 9, 2017
b40dced
refactor: Fix conflicts with master
sorin-davidoi Jul 11, 2017
a847236
refactor: Pull request feedback
sorin-davidoi Jul 12, 2017
bf3397d
refactor: Remove logging statements
sorin-davidoi Jul 12, 2017
0bf308a
chore(yarn): Fix conflicts with master
sorin-davidoi Jul 12, 2017
be5ea12
chore(yarn): Copy latest from master
sorin-davidoi Jul 12, 2017
bccb6e7
chore(yarn): Readd offline-plugin
sorin-davidoi Jul 12, 2017
44785e0
chore: Fix conflicts with master
sorin-davidoi Jul 12, 2017
28a2c0f
refactor: Use save! and update!
sorin-davidoi Jul 12, 2017
99a482b
refactor: Send notifications async
sorin-davidoi Jul 12, 2017
e3e79f3
fix: Allow retry when push fails
sorin-davidoi Jul 12, 2017
c4c0a7a
fix: Save track for failed pushes
sorin-davidoi Jul 12, 2017
580fea4
fix: Minify sw.js
sorin-davidoi Jul 13, 2017
5e6132d
fix: Remove account_id from fabricator
sorin-davidoi Jul 13, 2017
5067d70
fix: Fix conflicts with master
sorin-davidoi Jul 13, 2017
787279a
fix: Conflicts with master
sorin-davidoi Jul 13, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.production.sample
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -21,6 +21,7 @@ public/system
public/assets
public/packs
public/packs-test
public/sw.js
.env
.env.production
node_modules/
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Expand Up @@ -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'
Expand Down
6 changes: 6 additions & 0 deletions Gemfile.lock
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -573,6 +578,7 @@ DEPENDENCIES
uglifier (~> 3.2)
webmock (~> 3.0)
webpacker (~> 2.0)
webpush

RUBY VERSION
ruby 2.4.1p111
Expand Down
39 changes: 39 additions & 0 deletions 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
1 change: 1 addition & 0 deletions app/controllers/home_controller.rb
Expand Up @@ -22,6 +22,7 @@ def set_initial_state_json
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),
Expand Down
52 changes: 52 additions & 0 deletions 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,
},
});
};
}
Expand Up @@ -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'>
Expand All @@ -30,31 +39,35 @@ 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>

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

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

<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>
Expand Down
Expand Up @@ -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,
}

Expand All @@ -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>
);
}
Expand Down
Expand Up @@ -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({
Expand All @@ -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 () {
Expand Down
8 changes: 8 additions & 0 deletions app/javascript/mastodon/main.js
Expand Up @@ -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()');
});
}
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/mastodon/reducers/index.js
Expand Up @@ -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';
Expand All @@ -32,6 +33,7 @@ const reducers = {
statuses,
relationships,
settings,
push_notifications,
cards,
reports,
contexts,
Expand Down
51 changes: 51 additions & 0 deletions 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;
}
};
1 change: 1 addition & 0 deletions app/javascript/mastodon/service_worker/entry.js
@@ -0,0 +1 @@
import './web_push_notifications';