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
					
				@ -31,6 +31,17 @@ PAPERCLIP_SECRET=
 | 
				
			|||||||
SECRET_KEY_BASE=
 | 
					SECRET_KEY_BASE=
 | 
				
			||||||
OTP_SECRET=
 | 
					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
 | 
					# Registrations
 | 
				
			||||||
# Single user mode will disable registrations and redirect frontpage to the first profile
 | 
					# Single user mode will disable registrations and redirect frontpage to the first profile
 | 
				
			||||||
# SINGLE_USER_MODE=true
 | 
					# SINGLE_USER_MODE=true
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -21,6 +21,7 @@ public/system
 | 
				
			|||||||
public/assets
 | 
					public/assets
 | 
				
			||||||
public/packs
 | 
					public/packs
 | 
				
			||||||
public/packs-test
 | 
					public/packs-test
 | 
				
			||||||
 | 
					public/sw.js
 | 
				
			||||||
.env
 | 
					.env
 | 
				
			||||||
.env.production
 | 
					.env.production
 | 
				
			||||||
node_modules/
 | 
					node_modules/
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							@ -64,6 +64,7 @@ gem 'statsd-instrument', '~> 2.1'
 | 
				
			|||||||
gem 'twitter-text', '~> 1.14'
 | 
					gem 'twitter-text', '~> 1.14'
 | 
				
			||||||
gem 'tzinfo-data', '~> 1.2017'
 | 
					gem 'tzinfo-data', '~> 1.2017'
 | 
				
			||||||
gem 'webpacker', '~> 2.0'
 | 
					gem 'webpacker', '~> 2.0'
 | 
				
			||||||
 | 
					gem 'webpush'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
group :development, :test do
 | 
					group :development, :test do
 | 
				
			||||||
  gem 'fabrication', '~> 2.16'
 | 
					  gem 'fabrication', '~> 2.16'
 | 
				
			||||||
 | 
				
			|||||||
@ -181,6 +181,7 @@ GEM
 | 
				
			|||||||
    hashdiff (0.3.4)
 | 
					    hashdiff (0.3.4)
 | 
				
			||||||
    highline (1.7.8)
 | 
					    highline (1.7.8)
 | 
				
			||||||
    hiredis (0.6.1)
 | 
					    hiredis (0.6.1)
 | 
				
			||||||
 | 
					    hkdf (0.3.0)
 | 
				
			||||||
    htmlentities (4.3.4)
 | 
					    htmlentities (4.3.4)
 | 
				
			||||||
    http (2.2.2)
 | 
					    http (2.2.2)
 | 
				
			||||||
      addressable (~> 2.3)
 | 
					      addressable (~> 2.3)
 | 
				
			||||||
@ -209,6 +210,7 @@ GEM
 | 
				
			|||||||
    jmespath (1.3.1)
 | 
					    jmespath (1.3.1)
 | 
				
			||||||
    json (2.1.0)
 | 
					    json (2.1.0)
 | 
				
			||||||
    jsonapi-renderer (0.1.2)
 | 
					    jsonapi-renderer (0.1.2)
 | 
				
			||||||
 | 
					    jwt (1.5.6)
 | 
				
			||||||
    kaminari (1.0.1)
 | 
					    kaminari (1.0.1)
 | 
				
			||||||
      activesupport (>= 4.1.0)
 | 
					      activesupport (>= 4.1.0)
 | 
				
			||||||
      kaminari-actionview (= 1.0.1)
 | 
					      kaminari-actionview (= 1.0.1)
 | 
				
			||||||
@ -475,6 +477,9 @@ GEM
 | 
				
			|||||||
      activesupport (>= 4.2)
 | 
					      activesupport (>= 4.2)
 | 
				
			||||||
      multi_json (~> 1.2)
 | 
					      multi_json (~> 1.2)
 | 
				
			||||||
      railties (>= 4.2)
 | 
					      railties (>= 4.2)
 | 
				
			||||||
 | 
					    webpush (0.3.2)
 | 
				
			||||||
 | 
					      hkdf (~> 0.2)
 | 
				
			||||||
 | 
					      jwt
 | 
				
			||||||
    websocket-driver (0.6.5)
 | 
					    websocket-driver (0.6.5)
 | 
				
			||||||
      websocket-extensions (>= 0.1.0)
 | 
					      websocket-extensions (>= 0.1.0)
 | 
				
			||||||
    websocket-extensions (0.1.2)
 | 
					    websocket-extensions (0.1.2)
 | 
				
			||||||
@ -573,6 +578,7 @@ DEPENDENCIES
 | 
				
			|||||||
  uglifier (~> 3.2)
 | 
					  uglifier (~> 3.2)
 | 
				
			||||||
  webmock (~> 3.0)
 | 
					  webmock (~> 3.0)
 | 
				
			||||||
  webpacker (~> 2.0)
 | 
					  webpacker (~> 2.0)
 | 
				
			||||||
 | 
					  webpush
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUBY VERSION
 | 
					RUBY VERSION
 | 
				
			||||||
   ruby 2.4.1p111
 | 
					   ruby 2.4.1p111
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										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
 | 
					  def initial_state_params
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      settings: Web::Setting.find_by(user: current_user)&.data || {},
 | 
					      settings: Web::Setting.find_by(user: current_user)&.data || {},
 | 
				
			||||||
 | 
					      push_subscription: current_account.user.web_push_subscription(current_session),
 | 
				
			||||||
      current_account: current_account,
 | 
					      current_account: current_account,
 | 
				
			||||||
      token: current_session.token,
 | 
					      token: current_session.token,
 | 
				
			||||||
      admin: Account.find_local(Setting.site_contact_username),
 | 
					      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 = {
 | 
					  static propTypes = {
 | 
				
			||||||
    settings: ImmutablePropTypes.map.isRequired,
 | 
					    settings: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
 | 
					    pushSettings: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
    onChange: PropTypes.func.isRequired,
 | 
					    onChange: PropTypes.func.isRequired,
 | 
				
			||||||
    onSave: PropTypes.func.isRequired,
 | 
					    onSave: PropTypes.func.isRequired,
 | 
				
			||||||
    onClear: PropTypes.func.isRequired,
 | 
					    onClear: PropTypes.func.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onPushChange = (key, checked) => {
 | 
				
			||||||
 | 
					    this.props.onChange(['push', ...key], checked);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  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 alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
 | 
				
			||||||
    const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
 | 
					    const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
 | 
				
			||||||
    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
 | 
					    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 (
 | 
					    return (
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <div className='column-settings__row'>
 | 
					        <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>
 | 
					        <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className='column-settings__row'>
 | 
					        <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={['shows', 'follow']} onChange={onChange} label={showStr} />
 | 
				
			||||||
          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
 | 
					          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
 | 
				
			||||||
        </div>
 | 
					        </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>
 | 
					        <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className='column-settings__row'>
 | 
					        <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={['shows', 'favourite']} onChange={onChange} label={showStr} />
 | 
				
			||||||
          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
 | 
					          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
 | 
				
			||||||
        </div>
 | 
					        </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>
 | 
					        <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className='column-settings__row'>
 | 
					        <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={['shows', 'mention']} onChange={onChange} label={showStr} />
 | 
				
			||||||
          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
 | 
					          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
 | 
				
			||||||
        </div>
 | 
					        </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>
 | 
					        <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className='column-settings__row'>
 | 
					        <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={['shows', 'reblog']} onChange={onChange} label={showStr} />
 | 
				
			||||||
          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
 | 
					          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ export default class SettingToggle extends React.PureComponent {
 | 
				
			|||||||
    settings: ImmutablePropTypes.map.isRequired,
 | 
					    settings: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
    settingKey: PropTypes.array.isRequired,
 | 
					    settingKey: PropTypes.array.isRequired,
 | 
				
			||||||
    label: PropTypes.node.isRequired,
 | 
					    label: PropTypes.node.isRequired,
 | 
				
			||||||
 | 
					    meta: PropTypes.node,
 | 
				
			||||||
    onChange: PropTypes.func.isRequired,
 | 
					    onChange: PropTypes.func.isRequired,
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -18,13 +19,14 @@ export default class SettingToggle extends React.PureComponent {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  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('-');
 | 
					    const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='setting-toggle'>
 | 
					      <div className='setting-toggle'>
 | 
				
			||||||
        <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
 | 
					        <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
 | 
				
			||||||
        <label htmlFor={id} className='setting-toggle__label'>{label}</label>
 | 
					        <label htmlFor={id} className='setting-toggle__label'>{label}</label>
 | 
				
			||||||
 | 
					        {meta && <span className='setting-meta__label'>{meta}</span>}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl';
 | 
				
			|||||||
import ColumnSettings from '../components/column_settings';
 | 
					import ColumnSettings from '../components/column_settings';
 | 
				
			||||||
import { changeSetting, saveSettings } from '../../../actions/settings';
 | 
					import { changeSetting, saveSettings } from '../../../actions/settings';
 | 
				
			||||||
import { clearNotifications } from '../../../actions/notifications';
 | 
					import { clearNotifications } from '../../../actions/notifications';
 | 
				
			||||||
 | 
					import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications';
 | 
				
			||||||
import { openModal } from '../../../actions/modal';
 | 
					import { openModal } from '../../../actions/modal';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
@ -12,16 +13,22 @@ const messages = defineMessages({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = state => ({
 | 
					const mapStateToProps = state => ({
 | 
				
			||||||
  settings: state.getIn(['settings', 'notifications']),
 | 
					  settings: state.getIn(['settings', 'notifications']),
 | 
				
			||||||
 | 
					  pushSettings: state.get('push_notifications'),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
 | 
					const mapDispatchToProps = (dispatch, { intl }) => ({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onChange (key, checked) {
 | 
					  onChange (key, checked) {
 | 
				
			||||||
 | 
					    if (key[0] === 'push') {
 | 
				
			||||||
 | 
					      dispatch(changePushNotifications(key.slice(1), checked));
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
      dispatch(changeSetting(['notifications', ...key], checked));
 | 
					      dispatch(changeSetting(['notifications', ...key], checked));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onSave () {
 | 
					  onSave () {
 | 
				
			||||||
    dispatch(saveSettings());
 | 
					    dispatch(saveSettings());
 | 
				
			||||||
 | 
					    dispatch(savePushNotificationSettings());
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onClear () {
 | 
					  onClear () {
 | 
				
			||||||
 | 
				
			|||||||
@ -29,6 +29,14 @@ function main() {
 | 
				
			|||||||
    const props = JSON.parse(mountNode.getAttribute('data-props'));
 | 
					    const props = JSON.parse(mountNode.getAttribute('data-props'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ReactDOM.render(<Mastodon {...props} />, mountNode);
 | 
					    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()');
 | 
					    perf.stop('main()');
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ import accounts_counters from './accounts_counters';
 | 
				
			|||||||
import statuses from './statuses';
 | 
					import statuses from './statuses';
 | 
				
			||||||
import relationships from './relationships';
 | 
					import relationships from './relationships';
 | 
				
			||||||
import settings from './settings';
 | 
					import settings from './settings';
 | 
				
			||||||
 | 
					import push_notifications from './push_notifications';
 | 
				
			||||||
import status_lists from './status_lists';
 | 
					import status_lists from './status_lists';
 | 
				
			||||||
import cards from './cards';
 | 
					import cards from './cards';
 | 
				
			||||||
import reports from './reports';
 | 
					import reports from './reports';
 | 
				
			||||||
@ -32,6 +33,7 @@ const reducers = {
 | 
				
			|||||||
  statuses,
 | 
					  statuses,
 | 
				
			||||||
  relationships,
 | 
					  relationships,
 | 
				
			||||||
  settings,
 | 
					  settings,
 | 
				
			||||||
 | 
					  push_notifications,
 | 
				
			||||||
  cards,
 | 
					  cards,
 | 
				
			||||||
  reports,
 | 
					  reports,
 | 
				
			||||||
  contexts,
 | 
					  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;
 | 
					  line-height: 24px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.setting-toggle__label {
 | 
					.setting-toggle__label,
 | 
				
			||||||
 | 
					.setting-meta__label {
 | 
				
			||||||
  color: $ui-primary-color;
 | 
					  color: $ui-primary-color;
 | 
				
			||||||
  display: inline-block;
 | 
					  display: inline-block;
 | 
				
			||||||
  margin-bottom: 14px;
 | 
					  margin-bottom: 14px;
 | 
				
			||||||
@ -2360,6 +2361,11 @@ button.icon-button.active i.fa-retweet {
 | 
				
			|||||||
  vertical-align: middle;
 | 
					  vertical-align: middle;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.setting-meta__label {
 | 
				
			||||||
 | 
					  color: $ui-primary-color;
 | 
				
			||||||
 | 
					  float: right;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.empty-column-indicator,
 | 
					.empty-column-indicator,
 | 
				
			||||||
.error-column {
 | 
					.error-column {
 | 
				
			||||||
  color: lighten($ui-base-color, 20%);
 | 
					  color: lighten($ui-base-color, 20%);
 | 
				
			||||||
 | 
				
			|||||||
@ -45,6 +45,10 @@ body.rtl {
 | 
				
			|||||||
    margin-right: 8px;
 | 
					    margin-right: 8px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .setting-meta__label {
 | 
				
			||||||
 | 
					    float: left;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .status__avatar {
 | 
					  .status__avatar {
 | 
				
			||||||
    left: auto;
 | 
					    left: auto;
 | 
				
			||||||
    right: 10px;
 | 
					    right: 10px;
 | 
				
			||||||
 | 
				
			|||||||
@ -3,6 +3,17 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
# Table name: session_activations
 | 
					# 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
 | 
					#  id              :integer          not null, primary key
 | 
				
			||||||
#  user_id         :integer          not null
 | 
					#  user_id         :integer          not null
 | 
				
			||||||
#  session_id      :string           not null
 | 
					#  session_id      :string           not null
 | 
				
			||||||
@ -15,6 +26,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class SessionActivation < ApplicationRecord
 | 
					class SessionActivation < ApplicationRecord
 | 
				
			||||||
  belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
 | 
					  belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
 | 
				
			||||||
 | 
					  belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  delegate :token,
 | 
					  delegate :token,
 | 
				
			||||||
           to: :access_token,
 | 
					           to: :access_token,
 | 
				
			||||||
 | 
				
			|||||||
@ -113,6 +113,10 @@ class User < ApplicationRecord
 | 
				
			|||||||
    session_activations.active? id
 | 
					    session_activations.active? id
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def web_push_subscription(session)
 | 
				
			||||||
 | 
					    session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected
 | 
					  protected
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def send_devise_notification(notification, *args)
 | 
					  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
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class InitialStatePresenter < ActiveModelSerializers::Model
 | 
					class InitialStatePresenter < ActiveModelSerializers::Model
 | 
				
			||||||
  attributes :settings, :token, :current_account, :admin
 | 
					  attributes :settings, :push_subscription, :token, :current_account, :admin
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class InitialStateSerializer < ActiveModel::Serializer
 | 
					class InitialStateSerializer < ActiveModel::Serializer
 | 
				
			||||||
  attributes :meta, :compose, :accounts,
 | 
					  attributes :meta, :compose, :accounts,
 | 
				
			||||||
             :media_attachments, :settings
 | 
					             :media_attachments, :settings, :push_subscription
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def meta
 | 
					  def meta
 | 
				
			||||||
    store = {
 | 
					    store = {
 | 
				
			||||||
 | 
				
			|||||||
@ -61,6 +61,11 @@ class NotifyService < BaseService
 | 
				
			|||||||
    @notification.save!
 | 
					    @notification.save!
 | 
				
			||||||
    return unless @notification.browserable?
 | 
					    return unless @notification.browserable?
 | 
				
			||||||
    Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
 | 
					    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
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def send_email
 | 
					  def send_email
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
- content_for :header_tags do
 | 
					- 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)
 | 
					  %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  = javascript_pack_tag 'application', integrity: true, crossorigin: 'anonymous'
 | 
					  = 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
 | 
				
			||||||
@ -31,6 +31,11 @@ Rails.application.configure do
 | 
				
			|||||||
    config.logger = ActiveSupport::TaggedLogging.new(logger)
 | 
					    config.logger = ActiveSupport::TaggedLogging.new(logger)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Generate random VAPID keys
 | 
				
			||||||
 | 
					  vapid_key = Webpush.generate_key
 | 
				
			||||||
 | 
					  config.x.vapid_private_key = vapid_key.private_key
 | 
				
			||||||
 | 
					  config.x.vapid_public_key = vapid_key.public_key
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # Don't care if the mailer can't send.
 | 
					  # Don't care if the mailer can't send.
 | 
				
			||||||
  config.action_mailer.raise_delivery_errors = false
 | 
					  config.action_mailer.raise_delivery_errors = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -40,6 +40,11 @@ Rails.application.configure do
 | 
				
			|||||||
  # Print deprecation notices to the stderr.
 | 
					  # Print deprecation notices to the stderr.
 | 
				
			||||||
  config.active_support.deprecation = :stderr
 | 
					  config.active_support.deprecation = :stderr
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Generate random VAPID keys
 | 
				
			||||||
 | 
					  vapid_key = Webpush.generate_key
 | 
				
			||||||
 | 
					  config.x.vapid_private_key = vapid_key.private_key
 | 
				
			||||||
 | 
					  config.x.vapid_public_key = vapid_key.public_key
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # Raises error for missing translations
 | 
					  # Raises error for missing translations
 | 
				
			||||||
  # config.action_view.raise_on_missing_translations = true
 | 
					  # config.action_view.raise_on_missing_translations = true
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										17
									
								
								config/initializers/vapid.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								config/initializers/vapid.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Rails.application.configure do
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # 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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if Rails.env.production?
 | 
				
			||||||
 | 
					    config.x.vapid_private_key = ENV['VAPID_PRIVATE_KEY']
 | 
				
			||||||
 | 
					    config.x.vapid_public_key = ENV['VAPID_PUBLIC_KEY']
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@ -335,6 +335,21 @@ en:
 | 
				
			|||||||
    next: Next
 | 
					    next: Next
 | 
				
			||||||
    prev: Prev
 | 
					    prev: Prev
 | 
				
			||||||
    truncate: "…"
 | 
					    truncate: "…"
 | 
				
			||||||
 | 
					  push_notifications:
 | 
				
			||||||
 | 
					    favourite:
 | 
				
			||||||
 | 
					      title: "%{name} favourited your status"
 | 
				
			||||||
 | 
					    follow:
 | 
				
			||||||
 | 
					      title: "%{name} is now following you"
 | 
				
			||||||
 | 
					    mention:
 | 
				
			||||||
 | 
					      action_boost: 'Boost'
 | 
				
			||||||
 | 
					      action_expand: 'Show more'
 | 
				
			||||||
 | 
					      action_favourite: 'Favourite'
 | 
				
			||||||
 | 
					      title: "%{name} mentioned you"
 | 
				
			||||||
 | 
					    reblog:
 | 
				
			||||||
 | 
					      title: "%{name} boosted your status"
 | 
				
			||||||
 | 
					    subscribed:
 | 
				
			||||||
 | 
					      body: "You can now receive push notifications."
 | 
				
			||||||
 | 
					      title: "Subscription registered!"
 | 
				
			||||||
  remote_follow:
 | 
					  remote_follow:
 | 
				
			||||||
    acct: Enter your username@domain you want to follow from
 | 
					    acct: Enter your username@domain you want to follow from
 | 
				
			||||||
    missing_resource: Could not find the required redirect URL for your account
 | 
					    missing_resource: Could not find the required redirect URL for your account
 | 
				
			||||||
 | 
				
			|||||||
@ -339,6 +339,21 @@ pl:
 | 
				
			|||||||
    next: Następna
 | 
					    next: Następna
 | 
				
			||||||
    prev: Poprzednia
 | 
					    prev: Poprzednia
 | 
				
			||||||
    truncate: "…"
 | 
					    truncate: "…"
 | 
				
			||||||
 | 
					  push_notifications:
 | 
				
			||||||
 | 
					    favourite:
 | 
				
			||||||
 | 
					      title: "%{name} dodał Twój status do ulubionych"
 | 
				
			||||||
 | 
					    follow:
 | 
				
			||||||
 | 
					      title: "%{name} zaczął Cię śledzić"
 | 
				
			||||||
 | 
					    mention:
 | 
				
			||||||
 | 
					      action_boost: 'Podbij'
 | 
				
			||||||
 | 
					      action_expand: 'Pokaż więcej'
 | 
				
			||||||
 | 
					      action_favourite: 'Dodaj do ulubionych'
 | 
				
			||||||
 | 
					      title: "%{name} wspomniał o Tobie"
 | 
				
			||||||
 | 
					    reblog:
 | 
				
			||||||
 | 
					      title: "%{name} podbił Twój status"
 | 
				
			||||||
 | 
					    subscribed:
 | 
				
			||||||
 | 
					      body: "Otrzymujesz teraz powiadomienia push."
 | 
				
			||||||
 | 
					      title: "Zarejestrowano subskrypcję!"
 | 
				
			||||||
  remote_follow:
 | 
					  remote_follow:
 | 
				
			||||||
    acct: Podaj swój adres (nazwa@domena), z którego chcesz śledzić
 | 
					    acct: Podaj swój adres (nazwa@domena), z którego chcesz śledzić
 | 
				
			||||||
    missing_resource: Nie udało się znaleźć adresu przekierowania z Twojej domeny
 | 
					    missing_resource: Nie udało się znaleźć adresu przekierowania z Twojej domeny
 | 
				
			||||||
 | 
				
			|||||||
@ -206,6 +206,11 @@ Rails.application.routes.draw do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    namespace :web do
 | 
					    namespace :web do
 | 
				
			||||||
      resource :settings, only: [:update]
 | 
					      resource :settings, only: [:update]
 | 
				
			||||||
 | 
					      resources :push_subscriptions, only: [:create] do
 | 
				
			||||||
 | 
					        member do
 | 
				
			||||||
 | 
					          put :update
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,9 @@ const merge = require('webpack-merge');
 | 
				
			|||||||
const CompressionPlugin = require('compression-webpack-plugin');
 | 
					const CompressionPlugin = require('compression-webpack-plugin');
 | 
				
			||||||
const sharedConfig = require('./shared.js');
 | 
					const sharedConfig = require('./shared.js');
 | 
				
			||||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 | 
					const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 | 
				
			||||||
 | 
					const OfflinePlugin = require('offline-plugin');
 | 
				
			||||||
 | 
					const { publicPath } = require('./configuration.js');
 | 
				
			||||||
 | 
					const path = require('path');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = merge(sharedConfig, {
 | 
					module.exports = merge(sharedConfig, {
 | 
				
			||||||
  output: { filename: '[name]-[chunkhash].js' },
 | 
					  output: { filename: '[name]-[chunkhash].js' },
 | 
				
			||||||
@ -39,5 +42,16 @@ module.exports = merge(sharedConfig, {
 | 
				
			|||||||
      openAnalyzer: false,
 | 
					      openAnalyzer: false,
 | 
				
			||||||
      logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout
 | 
					      logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
 | 
					    new OfflinePlugin({
 | 
				
			||||||
 | 
					      publicPath: publicPath, // sw.js must be served from the root to avoid scope issues
 | 
				
			||||||
 | 
					      caches: { }, // do not cache things, we only use it for push notifications for now
 | 
				
			||||||
 | 
					      ServiceWorker: {
 | 
				
			||||||
 | 
					        entry: path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'),
 | 
				
			||||||
 | 
					        cacheName: 'mastodon',
 | 
				
			||||||
 | 
					        output: '../sw.js',
 | 
				
			||||||
 | 
					        publicPath: '/sw.js',
 | 
				
			||||||
 | 
					        minify: true,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										12
									
								
								db/migrate/20170713175513_create_web_push_subscriptions.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								db/migrate/20170713175513_create_web_push_subscriptions.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					class CreateWebPushSubscriptions < ActiveRecord::Migration[5.1]
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    create_table :web_push_subscriptions do |t|
 | 
				
			||||||
 | 
					      t.string :endpoint, null: false
 | 
				
			||||||
 | 
					      t.string :key_p256dh, null: false
 | 
				
			||||||
 | 
					      t.string :key_auth, null: false
 | 
				
			||||||
 | 
					      t.json :data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      t.timestamps
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					class AddWebPushSubscriptionToSessionActivations < ActiveRecord::Migration[5.1]
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    add_column :session_activations, :web_push_subscription_id, :integer
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										12
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								db/schema.rb
									
									
									
									
									
								
							@ -10,7 +10,7 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
# It's strongly recommended that you check this file into your version control system.
 | 
					# It's strongly recommended that you check this file into your version control system.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ActiveRecord::Schema.define(version: 20170713112503) do
 | 
					ActiveRecord::Schema.define(version: 20170713190709) do
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # These are extensions that must be enabled in order to support this database
 | 
					  # These are extensions that must be enabled in order to support this database
 | 
				
			||||||
  enable_extension "plpgsql"
 | 
					  enable_extension "plpgsql"
 | 
				
			||||||
@ -258,6 +258,7 @@ ActiveRecord::Schema.define(version: 20170713112503) do
 | 
				
			|||||||
    t.string "user_agent", default: "", null: false
 | 
					    t.string "user_agent", default: "", null: false
 | 
				
			||||||
    t.inet "ip"
 | 
					    t.inet "ip"
 | 
				
			||||||
    t.integer "access_token_id"
 | 
					    t.integer "access_token_id"
 | 
				
			||||||
 | 
					    t.integer "web_push_subscription_id"
 | 
				
			||||||
    t.index ["session_id"], name: "index_session_activations_on_session_id", unique: true
 | 
					    t.index ["session_id"], name: "index_session_activations_on_session_id", unique: true
 | 
				
			||||||
    t.index ["user_id"], name: "index_session_activations_on_user_id"
 | 
					    t.index ["user_id"], name: "index_session_activations_on_user_id"
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@ -371,6 +372,15 @@ ActiveRecord::Schema.define(version: 20170713112503) do
 | 
				
			|||||||
    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
 | 
					    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  create_table "web_push_subscriptions", force: :cascade do |t|
 | 
				
			||||||
 | 
					    t.string "endpoint", null: false
 | 
				
			||||||
 | 
					    t.string "key_p256dh", null: false
 | 
				
			||||||
 | 
					    t.string "key_auth", null: false
 | 
				
			||||||
 | 
					    t.json "data"
 | 
				
			||||||
 | 
					    t.datetime "created_at", null: false
 | 
				
			||||||
 | 
					    t.datetime "updated_at", null: false
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create_table "web_settings", id: :serial, force: :cascade do |t|
 | 
					  create_table "web_settings", id: :serial, force: :cascade do |t|
 | 
				
			||||||
    t.integer "user_id"
 | 
					    t.integer "user_id"
 | 
				
			||||||
    t.json "data"
 | 
					    t.json "data"
 | 
				
			||||||
 | 
				
			|||||||
@ -67,6 +67,7 @@
 | 
				
			|||||||
    "node-sass": "^4.5.2",
 | 
					    "node-sass": "^4.5.2",
 | 
				
			||||||
    "npmlog": "^4.1.2",
 | 
					    "npmlog": "^4.1.2",
 | 
				
			||||||
    "object-assign": "^4.1.1",
 | 
					    "object-assign": "^4.1.1",
 | 
				
			||||||
 | 
					    "offline-plugin": "^4.8.3",
 | 
				
			||||||
    "path-complete-extname": "^0.1.0",
 | 
					    "path-complete-extname": "^0.1.0",
 | 
				
			||||||
    "pg": "^6.4.0",
 | 
					    "pg": "^6.4.0",
 | 
				
			||||||
    "postcss-loader": "^2.0.6",
 | 
					    "postcss-loader": "^2.0.6",
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								public/badge.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/badge.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 30 KiB  | 
@ -0,0 +1,81 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe Api::Web::PushSubscriptionsController do
 | 
				
			||||||
 | 
					  render_views
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:user) { Fabricate(:user) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:create_payload) do
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        endpoint: 'https://fcm.googleapis.com/fcm/send/fiuH06a27qE:APA91bHnSiGcLwdaxdyqVXNDR9w1NlztsHb6lyt5WDKOC_Z_Q8BlFxQoR8tWFSXUIDdkyw0EdvxTu63iqamSaqVSevW5LfoFwojws8XYDXv_NRRLH6vo2CdgiN4jgHv5VLt2A8ah6lUX',
 | 
				
			||||||
 | 
					        keys: {
 | 
				
			||||||
 | 
					          p256dh: 'BEm_a0bdPDhf0SOsrnB2-ategf1hHoCnpXgQsFj5JCkcoMrMt2WHoPfEYOYPzOIs9mZE8ZUaD7VA5vouy0kEkr8=',
 | 
				
			||||||
 | 
					          auth: 'eH_C8rq2raXqlcBVDa1gLg==',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:alerts_payload) do
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        alerts: {
 | 
				
			||||||
 | 
					          follow: true,
 | 
				
			||||||
 | 
					          favourite: false,
 | 
				
			||||||
 | 
					          reblog: true,
 | 
				
			||||||
 | 
					          mention: false,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe 'POST #create' do
 | 
				
			||||||
 | 
					    it 'saves push subscriptions' do
 | 
				
			||||||
 | 
					      sign_in(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      post :create, format: :json, params: create_payload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      user.reload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(push_subscription['endpoint']).to eq(create_payload[:data][:endpoint])
 | 
				
			||||||
 | 
					      expect(push_subscription['key_p256dh']).to eq(create_payload[:data][:keys][:p256dh])
 | 
				
			||||||
 | 
					      expect(push_subscription['key_auth']).to eq(create_payload[:data][:keys][:auth])
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'sends welcome notification' do
 | 
				
			||||||
 | 
					      sign_in(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      post :create, format: :json, params: create_payload
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe 'PUT #update' do
 | 
				
			||||||
 | 
					    it 'changes alert settings' do
 | 
				
			||||||
 | 
					      sign_in(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      post :create, format: :json, params: create_payload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      alerts_payload[:id] = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]).id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      put :update, format: :json, params: alerts_payload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(push_subscription.data['follow']).to eq(alerts_payload[:data][:follow])
 | 
				
			||||||
 | 
					      expect(push_subscription.data['favourite']).to eq(alerts_payload[:data][:favourite])
 | 
				
			||||||
 | 
					      expect(push_subscription.data['reblog']).to eq(alerts_payload[:data][:reblog])
 | 
				
			||||||
 | 
					      expect(push_subscription.data['mention']).to eq(alerts_payload[:data][:mention])
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										5
									
								
								spec/fabricators/web_push_subscription_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								spec/fabricators/web_push_subscription_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					Fabricator(:web_push_subscription) do
 | 
				
			||||||
 | 
					  endpoint   Faker::Internet.url
 | 
				
			||||||
 | 
					  key_p256dh Faker::Internet.password
 | 
				
			||||||
 | 
					  key_auth   Faker::Internet.password
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										28
									
								
								spec/models/web/push_subscription_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								spec/models/web/push_subscription_spec.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe Web::PushSubscription, type: :model do
 | 
				
			||||||
 | 
					  let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } }
 | 
				
			||||||
 | 
					  let(:payload_no_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd').as_payload }
 | 
				
			||||||
 | 
					  let(:payload_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd', data: { alerts: alerts }).as_payload }
 | 
				
			||||||
 | 
					  let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe '#as_payload' do
 | 
				
			||||||
 | 
					    it 'only returns id and endpoint' do
 | 
				
			||||||
 | 
					      expect(payload_no_alerts.keys).to eq [:id, :endpoint]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns alerts if set' do
 | 
				
			||||||
 | 
					      expect(payload_alerts.keys).to eq [:id, :endpoint, :alerts]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe '#pushable?' do
 | 
				
			||||||
 | 
					    it 'obeys alert settings' do
 | 
				
			||||||
 | 
					      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true
 | 
				
			||||||
 | 
					      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false
 | 
				
			||||||
 | 
					      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true
 | 
				
			||||||
 | 
					      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false
 | 
				
			||||||
 | 
					      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										25
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								yarn.lock
									
									
									
									
									
								
							@ -2209,7 +2209,7 @@ deep-equal@^1.0.1:
 | 
				
			|||||||
  version "1.0.1"
 | 
					  version "1.0.1"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
 | 
					  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
deep-extend@~0.4.0:
 | 
					deep-extend@^0.4.0, deep-extend@~0.4.0:
 | 
				
			||||||
  version "0.4.2"
 | 
					  version "0.4.2"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
 | 
					  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -2416,7 +2416,7 @@ ee-first@1.1.1:
 | 
				
			|||||||
  version "1.1.1"
 | 
					  version "1.1.1"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
 | 
					  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ejs@^2.5.6:
 | 
					ejs@^2.3.4, ejs@^2.5.6:
 | 
				
			||||||
  version "2.5.6"
 | 
					  version "2.5.6"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88"
 | 
					  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -4059,6 +4059,15 @@ loader-runner@^2.3.0:
 | 
				
			|||||||
  version "2.3.0"
 | 
					  version "2.3.0"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2"
 | 
					  resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					loader-utils@0.2.x:
 | 
				
			||||||
 | 
					  version "0.2.17"
 | 
				
			||||||
 | 
					  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348"
 | 
				
			||||||
 | 
					  dependencies:
 | 
				
			||||||
 | 
					    big.js "^3.1.3"
 | 
				
			||||||
 | 
					    emojis-list "^2.0.0"
 | 
				
			||||||
 | 
					    json5 "^0.5.0"
 | 
				
			||||||
 | 
					    object-assign "^4.0.1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0:
 | 
					loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0:
 | 
				
			||||||
  version "1.1.0"
 | 
					  version "1.1.0"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd"
 | 
					  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd"
 | 
				
			||||||
@ -4419,7 +4428,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
 | 
				
			|||||||
  version "1.0.1"
 | 
					  version "1.0.1"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
 | 
					  resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
 | 
					minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2:
 | 
				
			||||||
  version "3.0.4"
 | 
					  version "3.0.4"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
 | 
					  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
 | 
				
			||||||
  dependencies:
 | 
					  dependencies:
 | 
				
			||||||
@ -4760,6 +4769,16 @@ obuf@^1.0.0, obuf@^1.1.1:
 | 
				
			|||||||
  version "1.1.1"
 | 
					  version "1.1.1"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e"
 | 
					  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					offline-plugin@^4.8.3:
 | 
				
			||||||
 | 
					  version "4.8.3"
 | 
				
			||||||
 | 
					  resolved "https://registry.yarnpkg.com/offline-plugin/-/offline-plugin-4.8.3.tgz#9e95bd342ea2ac836b001b81f204c40638694d6c"
 | 
				
			||||||
 | 
					  dependencies:
 | 
				
			||||||
 | 
					    deep-extend "^0.4.0"
 | 
				
			||||||
 | 
					    ejs "^2.3.4"
 | 
				
			||||||
 | 
					    loader-utils "0.2.x"
 | 
				
			||||||
 | 
					    minimatch "^3.0.3"
 | 
				
			||||||
 | 
					    slash "^1.0.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
on-finished@~2.3.0:
 | 
					on-finished@~2.3.0:
 | 
				
			||||||
  version "2.3.0"
 | 
					  version "2.3.0"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
 | 
					  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user