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 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();
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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?",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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,
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
  }),
 | 
					  }),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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'] || '',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def status_type?
 | 
					  def status_type?
 | 
				
			||||||
    [:favourite, :reblog, :mention].include?(object.type)
 | 
					    [:favourite, :reblog, :mention, :poll].include?(object.type)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
					    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?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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!
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,8 +19,9 @@ 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
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
      @votes.each do |vote|
 | 
					      @votes.each do |vote|
 | 
				
			||||||
        ActivityPub::DeliveryWorker.perform_async(
 | 
					        ActivityPub::DeliveryWorker.perform_async(
 | 
				
			||||||
          build_json(vote),
 | 
					          build_json(vote),
 | 
				
			||||||
| 
						 | 
					@ -28,6 +29,8 @@ class VoteService < BaseService
 | 
				
			||||||
          @poll.account.inbox_url
 | 
					          @poll.account.inbox_url
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					      PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id) unless @poll.expires_at.nil?
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					@ -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 New Issue