Skip to content
Snippets Groups Projects
Commit 3a92885a authored by ThibG's avatar ThibG Committed by Eugen Rochko
Browse files

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
parent c11dff50
No related branches found
No related tags found
No related merge requests found
Showing
with 256 additions and 62 deletions
......@@ -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
......
# 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
# 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
......
# 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
# 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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment