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 fabricatormaster
parent
c2753fdfb4
commit
0c7c188c45
@ -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 |
@ -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, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}; |
||||||
|
} |
@ -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; |
||||||
|
} |
||||||
|
}; |
@ -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); |
@ -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.'); |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
@ -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 |
@ -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 |
@ -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 |
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 |
@ -0,0 +1,5 @@ |
|||||||
|
Fabricator(:web_push_subscription) do |
||||||
|
endpoint Faker::Internet.url |
||||||
|
key_p256dh Faker::Internet.password |
||||||
|
key_auth Faker::Internet.password |
||||||
|
end |
@ -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 |
Loading…
Reference in new issue