Support pushing and receiving updates to poll tallies (#10209)
* Process incoming poll tallies update * Send Update on poll vote * Do not send Updates for a poll more often than once every 3 minutes * Include voters in people to notify of results update * Schedule closing poll worker on poll creation * Add new notification type for ending polls * Add front-end support for ended poll notifications * Fix UpdatePollSerializer * Fix Updates not being triggered by local votes * Fix tests failure * Fix web push notifications for closing polls * Minor cleanup * Notify voters of both remote and local polls when those close * Fix delivery of poll updates to mentioned accounts and voters
This commit is contained in:
parent
c11dff5049
commit
3a92885a86
|
@ -92,7 +92,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
|
||||
|
||||
const excludeTypesFromFilter = filter => {
|
||||
const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']);
|
||||
const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
|
||||
return allTypes.filterNot(item => item === filter).toJS();
|
||||
};
|
||||
|
||||
|
|
|
@ -205,6 +205,38 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderPoll (notification) {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.poll', defaultMessage: 'Your poll has ended' }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='tasks' fixedWidth />
|
||||
</div>
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.poll' defaultMessage='Your poll has ended' />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusContainer
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
muted
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { notification } = this.props;
|
||||
const account = notification.get('account');
|
||||
|
@ -220,6 +252,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderFavourite(notification, link);
|
||||
case 'reblog':
|
||||
return this.renderReblog(notification, link);
|
||||
case 'poll':
|
||||
return this.renderPoll(notification);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -240,6 +240,7 @@
|
|||
"notification.favourite": "{name} favourited your status",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
"notification.poll": "Your poll has ended",
|
||||
"notification.reblog": "{name} boosted your status",
|
||||
"notifications.clear": "Clear notifications",
|
||||
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
||||
|
|
|
@ -31,6 +31,7 @@ const initialState = ImmutableMap({
|
|||
favourite: true,
|
||||
reblog: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
}),
|
||||
|
||||
quickFilter: ImmutableMap({
|
||||
|
@ -44,6 +45,7 @@ const initialState = ImmutableMap({
|
|||
favourite: true,
|
||||
reblog: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
}),
|
||||
|
||||
sounds: ImmutableMap({
|
||||
|
@ -51,6 +53,7 @@ const initialState = ImmutableMap({
|
|||
favourite: true,
|
||||
reblog: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
}),
|
||||
}),
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ filenames.forEach(filename => {
|
|||
'notification.follow': full['notification.follow'] || '',
|
||||
'notification.mention': full['notification.mention'] || '',
|
||||
'notification.reblog': full['notification.reblog'] || '',
|
||||
'notification.poll': full['notification.poll'] || '',
|
||||
|
||||
'status.show_more': full['status.show_more'] || '',
|
||||
'status.reblog': full['status.reblog'] || '',
|
||||
|
|
|
@ -243,6 +243,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
return false if replied_to_status.nil? || replied_to_status.poll.nil? || !replied_to_status.local? || !replied_to_status.poll.options.include?(@object['name'])
|
||||
return true if replied_to_status.poll.expired?
|
||||
replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id'])
|
||||
ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.poll.hide_totals
|
||||
true
|
||||
end
|
||||
|
||||
def resolve_thread(status)
|
||||
|
|
|
@ -5,6 +5,7 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
|
|||
|
||||
def perform
|
||||
update_account if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
|
||||
update_poll if equals_or_includes_any?(@object['type'], %w(Question))
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -14,4 +15,14 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
|
|||
|
||||
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
|
||||
end
|
||||
|
||||
def update_poll
|
||||
return reject_payload! if invalid_origin?(@object['id'])
|
||||
status = Status.find_by(uri: object_uri, account_id: @account.id)
|
||||
return if status.nil? || status.poll_id.nil?
|
||||
poll = Poll.find(status.poll_id)
|
||||
return if poll.nil?
|
||||
|
||||
ActivityPub::ProcessPollService.new.call(poll, @object)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,6 +22,7 @@ class Notification < ApplicationRecord
|
|||
follow: 'Follow',
|
||||
follow_request: 'FollowRequest',
|
||||
favourite: 'Favourite',
|
||||
poll: 'Poll',
|
||||
}.freeze
|
||||
|
||||
STATUS_INCLUDES = [:account, :application, :media_attachments, :tags, active_mentions: :account, reblog: [:account, :application, :media_attachments, :tags, active_mentions: :account]].freeze
|
||||
|
@ -35,6 +36,7 @@ class Notification < ApplicationRecord
|
|||
belongs_to :follow, foreign_type: 'Follow', foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow_request, foreign_type: 'FollowRequest', foreign_key: 'activity_id', optional: true
|
||||
belongs_to :favourite, foreign_type: 'Favourite', foreign_key: 'activity_id', optional: true
|
||||
belongs_to :poll, foreign_type: 'Poll', foreign_key: 'activity_id', optional: true
|
||||
|
||||
validates :account_id, uniqueness: { scope: [:activity_type, :activity_id] }
|
||||
validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values }
|
||||
|
@ -44,7 +46,7 @@ class Notification < ApplicationRecord
|
|||
where(activity_type: types)
|
||||
}
|
||||
|
||||
cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account
|
||||
cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account, poll: [status: STATUS_INCLUDES]
|
||||
|
||||
def type
|
||||
@type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym
|
||||
|
@ -58,6 +60,8 @@ class Notification < ApplicationRecord
|
|||
favourite&.status
|
||||
when :mention
|
||||
mention&.status
|
||||
when :poll
|
||||
poll&.status
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -97,7 +101,7 @@ class Notification < ApplicationRecord
|
|||
return unless new_record?
|
||||
|
||||
case activity_type
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest'
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll'
|
||||
self.from_account_id = activity&.account_id
|
||||
when 'Mention'
|
||||
self.from_account_id = activity&.status&.account_id
|
||||
|
|
27
app/serializers/activitypub/update_poll_serializer.rb
Normal file
27
app/serializers/activitypub/update_poll_serializer.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::UpdatePollSerializer < ActiveModel::Serializer
|
||||
attributes :id, :type, :actor, :to
|
||||
|
||||
has_one :object, serializer: ActivityPub::NoteSerializer
|
||||
|
||||
def id
|
||||
[ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.poll.updated_at.to_i].join
|
||||
end
|
||||
|
||||
def type
|
||||
'Update'
|
||||
end
|
||||
|
||||
def actor
|
||||
ActivityPub::TagManager.instance.uri_for(object)
|
||||
end
|
||||
|
||||
def to
|
||||
ActivityPub::TagManager.instance.to(object)
|
||||
end
|
||||
|
||||
def cc
|
||||
ActivityPub::TagManager.instance.cc(object)
|
||||
end
|
||||
end
|
|
@ -11,6 +11,6 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
|||
end
|
||||
|
||||
def status_type?
|
||||
[:favourite, :reblog, :mention].include?(object.type)
|
||||
[:favourite, :reblog, :mention, :poll].include?(object.type)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,54 +4,7 @@ class ActivityPub::FetchRemotePollService < BaseService
|
|||
include JsonLdHelper
|
||||
|
||||
def call(poll, on_behalf_of = nil)
|
||||
@json = fetch_resource(poll.status.uri, true, on_behalf_of)
|
||||
|
||||
return unless supported_context? && expected_type?
|
||||
|
||||
expires_at = begin
|
||||
if @json['closed'].is_a?(String)
|
||||
@json['closed']
|
||||
elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
|
||||
Time.now.utc
|
||||
else
|
||||
@json['endTime']
|
||||
end
|
||||
end
|
||||
|
||||
items = begin
|
||||
if @json['anyOf'].is_a?(Array)
|
||||
@json['anyOf']
|
||||
else
|
||||
@json['oneOf']
|
||||
end
|
||||
end
|
||||
|
||||
latest_options = items.map { |item| item['name'].presence || item['content'] }
|
||||
|
||||
# If for some reasons the options were changed, it invalidates all previous
|
||||
# votes, so we need to remove them
|
||||
poll.votes.delete_all if latest_options != poll.options
|
||||
|
||||
begin
|
||||
poll.update!(
|
||||
last_fetched_at: Time.now.utc,
|
||||
expires_at: expires_at,
|
||||
options: latest_options,
|
||||
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
|
||||
)
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
poll.reload
|
||||
retry
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def supported_context?
|
||||
super(@json)
|
||||
end
|
||||
|
||||
def expected_type?
|
||||
equals_or_includes_any?(@json['type'], %w(Question))
|
||||
json = fetch_resource(poll.status.uri, true, on_behalf_of)
|
||||
ActivityPub::ProcessPollService.new.call(poll, json)
|
||||
end
|
||||
end
|
||||
|
|
64
app/services/activitypub/process_poll_service.rb
Normal file
64
app/services/activitypub/process_poll_service.rb
Normal file
|
@ -0,0 +1,64 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ProcessPollService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(poll, json)
|
||||
@json = json
|
||||
return unless supported_context? && expected_type?
|
||||
|
||||
previous_expires_at = poll.expires_at
|
||||
|
||||
expires_at = begin
|
||||
if @json['closed'].is_a?(String)
|
||||
@json['closed']
|
||||
elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
|
||||
Time.now.utc
|
||||
else
|
||||
@json['endTime']
|
||||
end
|
||||
end
|
||||
|
||||
items = begin
|
||||
if @json['anyOf'].is_a?(Array)
|
||||
@json['anyOf']
|
||||
else
|
||||
@json['oneOf']
|
||||
end
|
||||
end
|
||||
|
||||
latest_options = items.map { |item| item['name'].presence || item['content'] }
|
||||
|
||||
# If for some reasons the options were changed, it invalidates all previous
|
||||
# votes, so we need to remove them
|
||||
poll.votes.delete_all if latest_options != poll.options
|
||||
|
||||
begin
|
||||
poll.update!(
|
||||
last_fetched_at: Time.now.utc,
|
||||
expires_at: expires_at,
|
||||
options: latest_options,
|
||||
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
|
||||
)
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
poll.reload
|
||||
retry
|
||||
end
|
||||
|
||||
# If the poll had no expiration date set but now has, and people have voted,
|
||||
# schedule a notification.
|
||||
if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists?
|
||||
PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def supported_context?
|
||||
super(@json)
|
||||
end
|
||||
|
||||
def expected_type?
|
||||
equals_or_includes_any?(@json['type'], %w(Question))
|
||||
end
|
||||
end
|
|
@ -38,6 +38,10 @@ class NotifyService < BaseService
|
|||
false
|
||||
end
|
||||
|
||||
def blocked_poll?
|
||||
false
|
||||
end
|
||||
|
||||
def following_sender?
|
||||
return @following_sender if defined?(@following_sender)
|
||||
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
|
||||
|
@ -88,7 +92,7 @@ class NotifyService < BaseService
|
|||
|
||||
def blocked?
|
||||
blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway
|
||||
blocked ||= from_self? # Skip for interactions with self
|
||||
blocked ||= from_self? unless @notification.type == :poll # Skip for interactions with self
|
||||
|
||||
return blocked if message? && from_staff?
|
||||
|
||||
|
|
|
@ -90,6 +90,7 @@ class PostStatusService < BaseService
|
|||
DistributionWorker.perform_async(@status.id)
|
||||
Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id)
|
||||
ActivityPub::DistributionWorker.perform_async(@status.id)
|
||||
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
|
||||
end
|
||||
|
||||
def validate_media!
|
||||
|
|
|
@ -19,14 +19,17 @@ class VoteService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
return if @poll.account.local?
|
||||
|
||||
@votes.each do |vote|
|
||||
ActivityPub::DeliveryWorker.perform_async(
|
||||
build_json(vote),
|
||||
@account.id,
|
||||
@poll.account.inbox_url
|
||||
)
|
||||
if @poll.account.local?
|
||||
ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, @poll.status.id) unless @poll.hide_totals
|
||||
else
|
||||
@votes.each do |vote|
|
||||
ActivityPub::DeliveryWorker.perform_async(
|
||||
build_json(vote),
|
||||
@account.id,
|
||||
@poll.account.inbox_url
|
||||
)
|
||||
end
|
||||
PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id) unless @poll.expires_at.nil?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
62
app/workers/activitypub/distribute_poll_update_worker.rb
Normal file
62
app/workers/activitypub/distribute_poll_update_worker.rb
Normal file
|
@ -0,0 +1,62 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::DistributePollUpdateWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options queue: 'push', unique: :until_executed, retry: 0
|
||||
|
||||
def perform(status_id)
|
||||
@status = Status.find(status_id)
|
||||
@account = @status.account
|
||||
|
||||
return unless @status.poll
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
||||
[payload, @account.id, inbox_url]
|
||||
end
|
||||
|
||||
relay! if relayable?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def relayable?
|
||||
@status.public_visibility?
|
||||
end
|
||||
|
||||
def inboxes
|
||||
return @inboxes if defined?(@inboxes)
|
||||
target_accounts = @status.mentions.map(&:account).reject(&:local?)
|
||||
target_accounts += @status.reblogs.map(&:account).reject(&:local?)
|
||||
target_accounts += @status.poll.votes.map(&:account).reject(&:local?)
|
||||
target_accounts.uniq!(&:id)
|
||||
@inboxes = target_accounts.select(&:activitypub?).pluck(&:inbox_url)
|
||||
@inboxes += @account.followers.inboxes unless @status.direct_visibility?
|
||||
@inboxes.uniq!
|
||||
@inboxes
|
||||
end
|
||||
|
||||
def signed_payload
|
||||
Oj.dump(ActivityPub::LinkedDataSignature.new(unsigned_payload).sign!(@account))
|
||||
end
|
||||
|
||||
def unsigned_payload
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
@status,
|
||||
serializer: ActivityPub::UpdatePollSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json
|
||||
end
|
||||
|
||||
def payload
|
||||
@payload ||= @status.distributable? ? signed_payload : Oj.dump(unsigned_payload)
|
||||
end
|
||||
|
||||
def relay!
|
||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
|
||||
[payload, @account.id, inbox_url]
|
||||
end
|
||||
end
|
||||
end
|
24
app/workers/poll_expiration_notify_worker.rb
Normal file
24
app/workers/poll_expiration_notify_worker.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PollExpirationNotifyWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options unique: :until_executed
|
||||
|
||||
def perform(poll_id)
|
||||
poll = Poll.find(poll_id)
|
||||
|
||||
# Notify poll owner and remote voters
|
||||
if poll.local?
|
||||
ActivityPub::DistributePollUpdateWorker.perform_async(poll.status.id)
|
||||
NotifyService.new.call(poll.account, poll)
|
||||
end
|
||||
|
||||
# Notify local voters
|
||||
poll.votes.includes(:account).map(&:account).filter(&:local?).each do |account|
|
||||
NotifyService.new.call(account, poll)
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue