diff options
Diffstat (limited to 'app')
-rw-r--r-- | app/javascript/mastodon/service_worker/web_push_notifications.js | 26 | ||||
-rw-r--r-- | app/models/session_activation.rb | 1 | ||||
-rw-r--r-- | app/models/web/push_subscription.rb | 158 | ||||
-rw-r--r-- | app/serializers/web/notification_serializer.rb | 168 |
4 files changed, 201 insertions, 152 deletions
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js index acb85f626..f63cff335 100644 --- a/app/javascript/mastodon/service_worker/web_push_notifications.js +++ b/app/javascript/mastodon/service_worker/web_push_notifications.js @@ -31,8 +31,8 @@ const notify = options => const group = cloneNotification(notifications[0]); group.title = formatGroupTitle(group.data.message, group.data.count + 1); - group.body = `${options.title}\n${group.body}`; - group.data = { ...group.data, count: group.data.count + 1 }; + group.body = `${options.title}\n${group.body}`; + group.data = { ...group.data, count: group.data.count + 1 }; return self.registration.showNotification(group.title, group); } @@ -43,18 +43,18 @@ const notify = options => 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.body = options.data.nsfw || options.data.content; + options.dir = options.data.dir; + 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.actions = [expandAction]; + options.hiddenActions = options.data.actions.filter(action => action !== expandAction); options.data.hiddenImage = options.image; - options.image = undefined; + options.image = undefined; } else { options.actions = options.data.actions; } @@ -75,8 +75,8 @@ const cloneNotification = (notification) => { const expandNotification = (notification) => { const nextNotification = cloneNotification(notification); - nextNotification.body = notification.data.content; - nextNotification.image = notification.data.hiddenImage; + 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); @@ -105,8 +105,7 @@ const openUrl = url => const webClients = clientList.filter(client => /\/web\//.test(client.url)); if (webClients.length !== 0) { - const client = findBestClient(webClients); - + const client = findBestClient(webClients); const { pathname } = new URL(url); if (pathname.startsWith('/web/')) { @@ -126,8 +125,7 @@ const openUrl = url => }); const removeActionFromNotification = (notification, action) => { - const actions = notification.actions.filter(act => act.action !== action.action); - + const actions = notification.actions.filter(act => act.action !== action.action); const nextNotification = cloneNotification(notification); nextNotification.actions = actions; diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index 7eb16af8f..c1645223b 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -25,6 +25,7 @@ # class SessionActivation < ApplicationRecord + belongs_to :user, inverse_of: :session_activations, required: true belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb index e76f61278..79f782114 100644 --- a/app/models/web/push_subscription.rb +++ b/app/models/web/push_subscription.rb @@ -13,59 +13,14 @@ # require 'webpush' -require_relative '../../models/setting' 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) - 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 - Webpush.payload_send( - message: JSON.generate( - title: title, - dir: dir, - image: image, - badge: full_asset_url('badge.png', skip_pipeline: true), - 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, - message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker - } - ), - 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 - ) + I18n.with_locale(session_activation.user.locale || I18n.default_locale) do + push_payload(message_from(notification), 48.hours.seconds) + end end def pushable?(notification) @@ -73,120 +28,47 @@ class Web::PushSubscription < ApplicationRecord end def as_payload - payload = { - id: id, - endpoint: endpoint, - } - + 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 + def access_token + find_or_create_access_token.token end - def actions_arr(notification) - actions = - case notification.type - when :mention then [ - { - title: translate('push_notifications.mention.action_favourite'), - icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true), - 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('web-push-icon_expand.png', skip_pipeline: true), todo: 'expand', action: 'expand') - end - - if can_boost - actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), 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 + private - def dir_str(body) - rtl?(body) ? 'rtl' : 'ltr' - end + def push_payload(message, ttl = 5.minutes.seconds) + # TODO: Make sure that the payload does not + # exceed 4KB - Webpush::PayloadTooLarge - def send_welcome_notification Webpush.payload_send( - message: JSON.generate( - title: translate('push_notifications.subscribed.title'), - icon: full_asset_url('android-chrome-192x192.png', skip_pipeline: true), - badge: full_asset_url('badge.png', skip_pipeline: true), - data: { - content: translate('push_notifications.subscribed.body'), - actions: [], - url: web_url('notifications'), - message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker - } - ), + message: Oj.dump(message), endpoint: endpoint, p256dh: key_p256dh, auth: key_auth, + ttl: ttl, 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) + def message_from(notification) + serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription) + serializable_resource.as_json + end + + def find_or_create_access_token Doorkeeper::AccessToken.find_or_create_for( Doorkeeper::Application.find_by(superapp: true), - notification.account.user.id, + session_activation.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 diff --git a/app/serializers/web/notification_serializer.rb b/app/serializers/web/notification_serializer.rb new file mode 100644 index 000000000..0fe75f3a8 --- /dev/null +++ b/app/serializers/web/notification_serializer.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +class Web::NotificationSerializer < ActiveModel::Serializer + include StreamEntriesHelper + + class DataSerializer < ActiveModel::Serializer + include RoutingHelper + include StreamEntriesHelper + include ActionView::Helpers::SanitizeHelper + + attributes :content, :nsfw, :url, :actions, + :access_token, :message + + def content + decoder.decode(strip_tags(body)) + end + + def dir + rtl?(body) ? 'rtl' : 'ltr' + end + + def nsfw + return if object.target_status.nil? + object.target_status.spoiler_text.presence + end + + def url + case object.type + when :mention + web_url("statuses/#{object.target_status.id}") + when :follow + web_url("accounts/#{object.from_account.id}") + when :favourite + web_url("statuses/#{object.target_status.id}") + when :reblog + web_url("statuses/#{object.target_status.id}") + end + end + + def actions + return @actions if defined?(@actions) + + @actions = [] + + if object.type == :mention + @actions << expand_action if collapsed? + @actions << favourite_action + @actions << reblog_action if rebloggable? + end + + @actions + end + + def access_token + return if actions.empty? + current_push_subscription.access_token + end + + def message + I18n.t('push_notifications.group.title') + end + + private + + def body + case object.type + when :mention + object.target_status.text + when :follow + object.from_account.note + when :favourite + object.target_status.text + when :reblog + object.target_status.text + end + end + + def decoder + @decoder ||= HTMLEntities.new + end + + def expand_action + { + title: I18n.t('push_notifications.mention.action_expand'), + icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true), + todo: 'expand', + action: 'expand', + } + end + + def favourite_action + { + title: I18n.t('push_notifications.mention.action_favourite'), + icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true), + todo: 'request', + method: 'POST', + action: "/api/v1/statuses/#{object.target_status.id}/favourite", + } + end + + def reblog_action + { + title: I18n.t('push_notifications.mention.action_boost'), + icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), + todo: 'request', + method: 'POST', + action: "/api/v1/statuses/#{object.target_status.id}/reblog", + } + end + + def collapsed? + !object.target_status.nil? && (object.target_status.sensitive? || object.target_status.spoiler_text.present?) + end + + def rebloggable? + !object.target_status.nil? && !object.target_status.hidden? + end + end + + attributes :title, :dir, :image, :badge, :tag, + :timestamp, :icon + + has_one :data + + def title + case object.type + when :mention + I18n.t('push_notifications.mention.title', name: name) + when :follow + I18n.t('push_notifications.follow.title', name: name) + when :favourite + I18n.t('push_notifications.favourite.title', name: name) + when :reblog + I18n.t('push_notifications.reblog.title', name: name) + end + end + + def image + return if object.target_status.nil? || object.target_status.media_attachments.empty? + full_asset_url(object.target_status.media_attachments.first.file.url(:small)) + end + + def badge + full_asset_url('badge.png', skip_pipeline: true) + end + + def tag + object.id + end + + def timestamp + object.created_at + end + + def icon + object.from_account.avatar_static_url + end + + def data + object + end + + private + + def name + display_name(object.from_account) + end +end |