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
pull/953/head
ThibG 2019-03-11 00:49:31 +01:00 committed by Eugen Rochko
parent c11dff5049
commit 3a92885a86
17 changed files with 256 additions and 62 deletions

View File

@ -92,7 +92,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = filter => { 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(); return allTypes.filterNot(item => item === filter).toJS();
}; };

View File

@ -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 () { render () {
const { notification } = this.props; const { notification } = this.props;
const account = notification.get('account'); const account = notification.get('account');
@ -220,6 +252,8 @@ class Notification extends ImmutablePureComponent {
return this.renderFavourite(notification, link); return this.renderFavourite(notification, link);
case 'reblog': case 'reblog':
return this.renderReblog(notification, link); return this.renderReblog(notification, link);
case 'poll':
return this.renderPoll(notification);
} }
return null; return null;

View File

@ -240,6 +240,7 @@
"notification.favourite": "{name} favourited your status", "notification.favourite": "{name} favourited your status",
"notification.follow": "{name} followed you", "notification.follow": "{name} followed you",
"notification.mention": "{name} mentioned you", "notification.mention": "{name} mentioned you",
"notification.poll": "Your poll has ended",
"notification.reblog": "{name} boosted your status", "notification.reblog": "{name} boosted your status",
"notifications.clear": "Clear notifications", "notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",

View File

@ -31,6 +31,7 @@ const initialState = ImmutableMap({
favourite: true, favourite: true,
reblog: true, reblog: true,
mention: true, mention: true,
poll: true,
}), }),
quickFilter: ImmutableMap({ quickFilter: ImmutableMap({
@ -44,6 +45,7 @@ const initialState = ImmutableMap({
favourite: true, favourite: true,
reblog: true, reblog: true,
mention: true, mention: true,
poll: true,
}), }),
sounds: ImmutableMap({ sounds: ImmutableMap({
@ -51,6 +53,7 @@ const initialState = ImmutableMap({
favourite: true, favourite: true,
reblog: true, reblog: true,
mention: true, mention: true,
poll: true,
}), }),
}), }),

View File

@ -18,6 +18,7 @@ filenames.forEach(filename => {
'notification.follow': full['notification.follow'] || '', 'notification.follow': full['notification.follow'] || '',
'notification.mention': full['notification.mention'] || '', 'notification.mention': full['notification.mention'] || '',
'notification.reblog': full['notification.reblog'] || '', 'notification.reblog': full['notification.reblog'] || '',
'notification.poll': full['notification.poll'] || '',
'status.show_more': full['status.show_more'] || '', 'status.show_more': full['status.show_more'] || '',
'status.reblog': full['status.reblog'] || '', 'status.reblog': full['status.reblog'] || '',

View File

@ -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 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? 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']) 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 end
def resolve_thread(status) def resolve_thread(status)

View File

@ -5,6 +5,7 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
def perform def perform
update_account if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES) update_account if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
update_poll if equals_or_includes_any?(@object['type'], %w(Question))
end end
private 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) ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
end 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 end

View File

@ -22,6 +22,7 @@ class Notification < ApplicationRecord
follow: 'Follow', follow: 'Follow',
follow_request: 'FollowRequest', follow_request: 'FollowRequest',
favourite: 'Favourite', favourite: 'Favourite',
poll: 'Poll',
}.freeze }.freeze
STATUS_INCLUDES = [:account, :application, :media_attachments, :tags, active_mentions: :account, reblog: [:account, :application, :media_attachments, :tags, active_mentions: :account]].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, foreign_type: 'Follow', foreign_key: 'activity_id', optional: true
belongs_to :follow_request, foreign_type: 'FollowRequest', 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 :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 :account_id, uniqueness: { scope: [:activity_type, :activity_id] }
validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values } validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values }
@ -44,7 +46,7 @@ class Notification < ApplicationRecord
where(activity_type: types) 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 def type
@type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym @type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym
@ -58,6 +60,8 @@ class Notification < ApplicationRecord
favourite&.status favourite&.status
when :mention when :mention
mention&.status mention&.status
when :poll
poll&.status
end end
end end
@ -97,7 +101,7 @@ class Notification < ApplicationRecord
return unless new_record? return unless new_record?
case activity_type case activity_type
when 'Status', 'Follow', 'Favourite', 'FollowRequest' when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll'
self.from_account_id = activity&.account_id self.from_account_id = activity&.account_id
when 'Mention' when 'Mention'
self.from_account_id = activity&.status&.account_id self.from_account_id = activity&.status&.account_id

View 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

View File

@ -11,6 +11,6 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end end
def status_type? def status_type?
[:favourite, :reblog, :mention].include?(object.type) [:favourite, :reblog, :mention, :poll].include?(object.type)
end end
end end

View File

@ -4,54 +4,7 @@ class ActivityPub::FetchRemotePollService < BaseService
include JsonLdHelper include JsonLdHelper
def call(poll, on_behalf_of = nil) def call(poll, on_behalf_of = nil)
@json = fetch_resource(poll.status.uri, true, on_behalf_of) json = fetch_resource(poll.status.uri, true, on_behalf_of)
ActivityPub::ProcessPollService.new.call(poll, json)
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))
end end
end end

View 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

View File

@ -38,6 +38,10 @@ class NotifyService < BaseService
false false
end end
def blocked_poll?
false
end
def following_sender? def following_sender?
return @following_sender if defined?(@following_sender) return @following_sender if defined?(@following_sender)
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account) @following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
@ -88,7 +92,7 @@ class NotifyService < BaseService
def blocked? def blocked?
blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway 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? return blocked if message? && from_staff?

View File

@ -90,6 +90,7 @@ class PostStatusService < BaseService
DistributionWorker.perform_async(@status.id) DistributionWorker.perform_async(@status.id)
Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id) Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id)
ActivityPub::DistributionWorker.perform_async(@status.id) ActivityPub::DistributionWorker.perform_async(@status.id)
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
end end
def validate_media! def validate_media!

View File

@ -19,14 +19,17 @@ class VoteService < BaseService
end end
end end
return if @poll.account.local? if @poll.account.local?
ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, @poll.status.id) unless @poll.hide_totals
@votes.each do |vote| else
ActivityPub::DeliveryWorker.perform_async( @votes.each do |vote|
build_json(vote), ActivityPub::DeliveryWorker.perform_async(
@account.id, build_json(vote),
@poll.account.inbox_url @account.id,
) @poll.account.inbox_url
)
end
PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id) unless @poll.expires_at.nil?
end end
end end

View 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

View 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