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();
};
);
}
+ 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');
return this.renderFavourite(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
+ case 'poll':
+ return this.renderPoll(notification);
}
return null;
"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?",
favourite: true,
reblog: true,
mention: true,
+ poll: true,
}),
quickFilter: ImmutableMap({
favourite: true,
reblog: true,
mention: true,
+ poll: true,
}),
sounds: ImmutableMap({
favourite: true,
reblog: true,
mention: true,
+ poll: true,
}),
}),
'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'] || '',
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)
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
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
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
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 }
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
favourite&.status
when :mention
mention&.status
+ when :poll
+ poll&.status
end
end
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
--- /dev/null
+# 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
end
def status_type?
- [:favourite, :reblog, :mention].include?(object.type)
+ [:favourite, :reblog, :mention, :poll].include?(object.type)
end
end
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
--- /dev/null
+# 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
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)
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?
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!
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
--- /dev/null
+# 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
--- /dev/null
+# 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