Web Push Notifications (#3243)
* feat: Register push subscription * feat: Notify when mentioned * feat: Boost, favourite, reply, follow, follow request * feat: Notification interaction * feat: Handle change of public key * feat: Unsubscribe if things go wrong * feat: Do not send normal notifications if push is enabled * feat: Focus client if open * refactor: Move push logic to WebPushSubscription * feat: Better title and body * feat: Localize messages * chore: Fix lint errors * feat: Settings * refactor: Lazy load * fix: Check if push settings exist * feat: Device-based preferences * refactor: Simplify logic * refactor: Pull request feedback * refactor: Pull request feedback * refactor: Create /api/web/push_subscriptions endpoint * feat: Spec PushSubscriptionController * refactor: WebPushSubscription => Web::PushSubscription * feat: Spec Web::PushSubscription * feat: Display first media attachment * feat: Support direction * fix: Stuff broken while rebasing * refactor: Integration with session activations * refactor: Cleanup * refactor: Simplify implementation * feat: Set VAPID keys via environment * chore: Comments * fix: Crash when no alerts * fix: Set VAPID keys in testing environment * fix: Follow link * feat: Notification actions * fix: Delete previous subscription * chore: Temporary logs * refactor: Move migration to a later date * fix: Fetch the correct session activation and misc bugs * refactor: Move migration to a later date * fix: Remove follow request (no notifications) * feat: Send administrator contact to push service * feat: Set time-to-live * fix: Do not show sensitive images * fix: Reducer crash in error handling * feat: Add badge * chore: Fix lint error * fix: Checkbox label overlap * fix: Check for payload support * fix: Rename action "type" (crash in latest Chrome) * feat: Action to expand notification * fix: Lint errors * fix: Unescape notification body * fix: Do not allow boosting if the status is hidden * feat: Add VAPID keys to the production sample environment * fix: Strip HTML tags from status * refactor: Better error messages * refactor: Handle browser not implementing the VAPID protocol (Samsung Internet) * fix: Error when target_status is nil * fix: Handle lack of image * fix: Delete reference to invalid subscriptions * feat: Better error handling * fix: Unescape HTML characters after tags are striped * refactor: Simpify code * fix: Modify to work with #4091 * Sort strings alphabetically * i18n: Updated Polish translation it annoys me that it's not fully localized :P * refactor: Use current_session in PushSubscriptionController * fix: Rebase mistake * fix: Set cacheName to mastodon * refactor: Pull request feedback * refactor: Remove logging statements * chore(yarn): Fix conflicts with master * chore(yarn): Copy latest from master * chore(yarn): Readd offline-plugin * refactor: Use save! and update! * refactor: Send notifications async * fix: Allow retry when push fails * fix: Save track for failed pushes * fix: Minify sw.js * fix: Remove account_id from fabricator
This commit is contained in:
parent
c2753fdfb4
commit
0c7c188c45
42 changed files with 890 additions and 14 deletions
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
|
Loading…
Add table
Add a link
Reference in a new issue