forked from cybrespace/mastodon
		
	Add voters count support (#11917)
* Add voters count to polls * Add ActivityPub serialization and parsing of voters count * Add support for voters count in WebUI * Move incrementation of voters count out of redis lock * Reword “voters” to “people”
This commit is contained in:
		
							parent
							
								
									cfe2d1cc4a
								
							
						
					
					
						commit
						3babf8464b
					
				
					 13 changed files with 113 additions and 20 deletions
				
			
		| 
						 | 
				
			
			@ -102,10 +102,11 @@ class Poll extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
  renderOption (option, optionIndex, showResults) {
 | 
			
		||||
    const { poll, disabled, intl } = this.props;
 | 
			
		||||
    const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
 | 
			
		||||
    const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
 | 
			
		||||
    const active  = !!this.state.selected[`${optionIndex}`];
 | 
			
		||||
    const voted   = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
 | 
			
		||||
    const pollVotesCount  = poll.get('voters_count') || poll.get('votes_count');
 | 
			
		||||
    const percent         = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
 | 
			
		||||
    const leading         = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
 | 
			
		||||
    const active          = !!this.state.selected[`${optionIndex}`];
 | 
			
		||||
    const voted           = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
 | 
			
		||||
 | 
			
		||||
    let titleEmojified = option.get('title_emojified');
 | 
			
		||||
    if (!titleEmojified) {
 | 
			
		||||
| 
						 | 
				
			
			@ -157,6 +158,14 @@ class Poll extends ImmutablePureComponent {
 | 
			
		|||
    const showResults   = poll.get('voted') || expired;
 | 
			
		||||
    const disabled      = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
 | 
			
		||||
 | 
			
		||||
    let votesCount = null;
 | 
			
		||||
 | 
			
		||||
    if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
 | 
			
		||||
      votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
 | 
			
		||||
    } else {
 | 
			
		||||
      votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className='poll'>
 | 
			
		||||
        <ul>
 | 
			
		||||
| 
						 | 
				
			
			@ -166,7 +175,7 @@ class Poll extends ImmutablePureComponent {
 | 
			
		|||
        <div className='poll__footer'>
 | 
			
		||||
          {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
 | 
			
		||||
          {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
 | 
			
		||||
          <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />
 | 
			
		||||
          {votesCount}
 | 
			
		||||
          {poll.get('expires_at') && <span> · {timeRemaining}</span>}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -232,25 +232,40 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		|||
      items    = @object['oneOf']
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    voters_count = @object['votersCount']
 | 
			
		||||
 | 
			
		||||
    @account.polls.new(
 | 
			
		||||
      multiple: multiple,
 | 
			
		||||
      expires_at: expires_at,
 | 
			
		||||
      options: items.map { |item| item['name'].presence || item['content'] }.compact,
 | 
			
		||||
      cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
 | 
			
		||||
      cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 },
 | 
			
		||||
      voters_count: voters_count
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def poll_vote?
 | 
			
		||||
    return false if replied_to_status.nil? || replied_to_status.preloadable_poll.nil? || !replied_to_status.local? || !replied_to_status.preloadable_poll.options.include?(@object['name'])
 | 
			
		||||
 | 
			
		||||
    unless replied_to_status.preloadable_poll.expired?
 | 
			
		||||
      replied_to_status.preloadable_poll.votes.create!(account: @account, choice: replied_to_status.preloadable_poll.options.index(@object['name']), uri: @object['id'])
 | 
			
		||||
      ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.preloadable_poll.hide_totals?
 | 
			
		||||
    end
 | 
			
		||||
    poll_vote! unless replied_to_status.preloadable_poll.expired?
 | 
			
		||||
 | 
			
		||||
    true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def poll_vote!
 | 
			
		||||
    poll = replied_to_status.preloadable_poll
 | 
			
		||||
    already_voted = true
 | 
			
		||||
    RedisLock.acquire(poll_lock_options) do |lock|
 | 
			
		||||
      if lock.acquired?
 | 
			
		||||
        already_voted = poll.votes.where(account: @account).exists?
 | 
			
		||||
        poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: @object['id'])
 | 
			
		||||
      else
 | 
			
		||||
        raise Mastodon::RaceConditionError
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
    increment_voters_count! unless already_voted
 | 
			
		||||
    ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.preloadable_poll.hide_totals?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def resolve_thread(status)
 | 
			
		||||
    return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri)
 | 
			
		||||
    ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
 | 
			
		||||
| 
						 | 
				
			
			@ -416,7 +431,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		|||
    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def increment_voters_count!
 | 
			
		||||
    poll = replied_to_status.preloadable_poll
 | 
			
		||||
    unless poll.voters_count.nil?
 | 
			
		||||
      poll.voters_count = poll.voters_count + 1
 | 
			
		||||
      poll.save
 | 
			
		||||
    end
 | 
			
		||||
  rescue ActiveRecord::StaleObjectError
 | 
			
		||||
    poll.reload
 | 
			
		||||
    retry
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def lock_options
 | 
			
		||||
    { redis: Redis.current, key: "create:#{@object['id']}" }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def poll_lock_options
 | 
			
		||||
    { redis: Redis.current, key: "vote:#{replied_to_status.poll_id}:#{@account.id}" }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
 | 
			
		|||
    identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
 | 
			
		||||
    blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
 | 
			
		||||
    discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
 | 
			
		||||
    voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
 | 
			
		||||
  }.freeze
 | 
			
		||||
 | 
			
		||||
  def self.default_key_transform
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@
 | 
			
		|||
#  created_at      :datetime         not null
 | 
			
		||||
#  updated_at      :datetime         not null
 | 
			
		||||
#  lock_version    :integer          default(0), not null
 | 
			
		||||
#  voters_count    :bigint(8)
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class Poll < ApplicationRecord
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
			
		||||
  context_extensions :atom_uri, :conversation, :sensitive
 | 
			
		||||
  context_extensions :atom_uri, :conversation, :sensitive, :voters_count
 | 
			
		||||
 | 
			
		||||
  attributes :id, :type, :summary,
 | 
			
		||||
             :in_reply_to, :published, :url,
 | 
			
		||||
| 
						 | 
				
			
			@ -23,6 +23,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
			
		|||
  attribute :end_time, if: :poll_and_expires?
 | 
			
		||||
  attribute :closed, if: :poll_and_expired?
 | 
			
		||||
 | 
			
		||||
  attribute :voters_count, if: :poll_and_voters_count?
 | 
			
		||||
 | 
			
		||||
  def id
 | 
			
		||||
    ActivityPub::TagManager.instance.uri_for(object)
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -141,6 +143,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
			
		|||
 | 
			
		||||
  alias end_time closed
 | 
			
		||||
 | 
			
		||||
  def voters_count
 | 
			
		||||
    object.preloadable_poll.voters_count
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def poll_and_expires?
 | 
			
		||||
    object.preloadable_poll&.expires_at&.present?
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -149,6 +155,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
			
		|||
    object.preloadable_poll&.expired?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def poll_and_voters_count?
 | 
			
		||||
    object.preloadable_poll&.voters_count
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  class MediaAttachmentSerializer < ActivityPub::Serializer
 | 
			
		||||
    context_extensions :blurhash, :focal_point
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
 | 
			
		||||
class REST::PollSerializer < ActiveModel::Serializer
 | 
			
		||||
  attributes :id, :expires_at, :expired,
 | 
			
		||||
             :multiple, :votes_count
 | 
			
		||||
             :multiple, :votes_count, :voters_count
 | 
			
		||||
 | 
			
		||||
  has_many :loaded_options, key: :options
 | 
			
		||||
  has_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,6 +28,8 @@ class ActivityPub::ProcessPollService < BaseService
 | 
			
		|||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    voters_count = @json['votersCount']
 | 
			
		||||
 | 
			
		||||
    latest_options = items.map { |item| item['name'].presence || item['content'] }
 | 
			
		||||
 | 
			
		||||
    # If for some reasons the options were changed, it invalidates all previous
 | 
			
		||||
| 
						 | 
				
			
			@ -39,7 +41,8 @@ class ActivityPub::ProcessPollService < BaseService
 | 
			
		|||
        last_fetched_at: Time.now.utc,
 | 
			
		||||
        expires_at: expires_at,
 | 
			
		||||
        options: latest_options,
 | 
			
		||||
        cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
 | 
			
		||||
        cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 },
 | 
			
		||||
        voters_count: voters_count
 | 
			
		||||
      )
 | 
			
		||||
    rescue ActiveRecord::StaleObjectError
 | 
			
		||||
      poll.reload
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -174,7 +174,7 @@ class PostStatusService < BaseService
 | 
			
		|||
  def poll_attributes
 | 
			
		||||
    return if @options[:poll].blank?
 | 
			
		||||
 | 
			
		||||
    @options[:poll].merge(account: @account)
 | 
			
		||||
    @options[:poll].merge(account: @account, voters_count: 0)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def scheduled_options
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,12 +12,24 @@ class VoteService < BaseService
 | 
			
		|||
    @choices = choices
 | 
			
		||||
    @votes   = []
 | 
			
		||||
 | 
			
		||||
    ApplicationRecord.transaction do
 | 
			
		||||
      @choices.each do |choice|
 | 
			
		||||
        @votes << @poll.votes.create!(account: @account, choice: choice)
 | 
			
		||||
    already_voted = true
 | 
			
		||||
 | 
			
		||||
    RedisLock.acquire(lock_options) do |lock|
 | 
			
		||||
      if lock.acquired?
 | 
			
		||||
        already_voted = @poll.votes.where(account: @account).exists?
 | 
			
		||||
 | 
			
		||||
        ApplicationRecord.transaction do
 | 
			
		||||
          @choices.each do |choice|
 | 
			
		||||
            @votes << @poll.votes.create!(account: @account, choice: choice)
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      else
 | 
			
		||||
        raise Mastodon::RaceConditionError
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    increment_voters_count! unless already_voted
 | 
			
		||||
 | 
			
		||||
    ActivityTracker.increment('activity:interactions')
 | 
			
		||||
 | 
			
		||||
    if @poll.account.local?
 | 
			
		||||
| 
						 | 
				
			
			@ -53,4 +65,18 @@ class VoteService < BaseService
 | 
			
		|||
  def build_json(vote)
 | 
			
		||||
    Oj.dump(serialize_payload(vote, ActivityPub::VoteSerializer))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def increment_voters_count!
 | 
			
		||||
    unless @poll.voters_count.nil?
 | 
			
		||||
      @poll.voters_count = @poll.voters_count + 1
 | 
			
		||||
      @poll.save
 | 
			
		||||
    end
 | 
			
		||||
  rescue ActiveRecord::StaleObjectError
 | 
			
		||||
    @poll.reload
 | 
			
		||||
    retry
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def lock_options
 | 
			
		||||
    { redis: Redis.current, key: "vote:#{@poll.id}:#{@account.id}" }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,13 @@
 | 
			
		|||
- show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired?
 | 
			
		||||
- own_votes = user_signed_in? ? poll.own_votes(current_account) : []
 | 
			
		||||
- total_votes_count = poll.voters_count || poll.votes_count
 | 
			
		||||
 | 
			
		||||
.poll
 | 
			
		||||
  %ul
 | 
			
		||||
    - poll.loaded_options.each_with_index do |option, index|
 | 
			
		||||
      %li
 | 
			
		||||
        - if show_results
 | 
			
		||||
          - percent = poll.votes_count > 0 ? 100 * option.votes_count / poll.votes_count : 0
 | 
			
		||||
          - percent = total_votes_count > 0 ? 100 * option.votes_count / total_votes_count : 0
 | 
			
		||||
          %span.poll__chart{ style: "width: #{percent}%" }
 | 
			
		||||
 | 
			
		||||
          %label.poll__text><
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +25,10 @@
 | 
			
		|||
      %button.button.button-secondary{ disabled: true }
 | 
			
		||||
        = t('statuses.poll.vote')
 | 
			
		||||
 | 
			
		||||
    %span= t('statuses.poll.total_votes', count: poll.votes_count)
 | 
			
		||||
    - if poll.voters_count.nil?
 | 
			
		||||
      %span= t('statuses.poll.total_votes', count: poll.votes_count)
 | 
			
		||||
    - else
 | 
			
		||||
      %span= t('statuses.poll.total_people', count: poll.voters_count)
 | 
			
		||||
 | 
			
		||||
    - unless poll.expires_at.nil?
 | 
			
		||||
      ·
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1030,6 +1030,9 @@ en:
 | 
			
		|||
      private: Non-public toot cannot be pinned
 | 
			
		||||
      reblog: A boost cannot be pinned
 | 
			
		||||
    poll:
 | 
			
		||||
      total_people:
 | 
			
		||||
        one: "%{count} person"
 | 
			
		||||
        other: "%{count} people"
 | 
			
		||||
      total_votes:
 | 
			
		||||
        one: "%{count} vote"
 | 
			
		||||
        other: "%{count} votes"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										5
									
								
								db/migrate/20190927232842_add_voters_count_to_polls.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								db/migrate/20190927232842_add_voters_count_to_polls.rb
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
class AddVotersCountToPolls < ActiveRecord::Migration[5.2]
 | 
			
		||||
  def change
 | 
			
		||||
    add_column :polls, :voters_count, :bigint
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -10,7 +10,7 @@
 | 
			
		|||
#
 | 
			
		||||
# It's strongly recommended that you check this file into your version control system.
 | 
			
		||||
 | 
			
		||||
ActiveRecord::Schema.define(version: 2019_09_27_124642) do
 | 
			
		||||
ActiveRecord::Schema.define(version: 2019_09_27_232842) do
 | 
			
		||||
 | 
			
		||||
  # These are extensions that must be enabled in order to support this database
 | 
			
		||||
  enable_extension "plpgsql"
 | 
			
		||||
| 
						 | 
				
			
			@ -529,6 +529,7 @@ ActiveRecord::Schema.define(version: 2019_09_27_124642) do
 | 
			
		|||
    t.datetime "created_at", null: false
 | 
			
		||||
    t.datetime "updated_at", null: false
 | 
			
		||||
    t.integer "lock_version", default: 0, null: false
 | 
			
		||||
    t.bigint "voters_count"
 | 
			
		||||
    t.index ["account_id"], name: "index_polls_on_account_id"
 | 
			
		||||
    t.index ["status_id"], name: "index_polls_on_status_id"
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue