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
|
@ -102,10 +102,11 @@ class Poll extends ImmutablePureComponent {
|
||||||
|
|
||||||
renderOption (option, optionIndex, showResults) {
|
renderOption (option, optionIndex, showResults) {
|
||||||
const { poll, disabled, intl } = this.props;
|
const { poll, disabled, intl } = this.props;
|
||||||
const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
|
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
|
||||||
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
|
const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
|
||||||
const active = !!this.state.selected[`${optionIndex}`];
|
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
|
||||||
const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
|
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');
|
let titleEmojified = option.get('title_emojified');
|
||||||
if (!titleEmojified) {
|
if (!titleEmojified) {
|
||||||
|
@ -157,6 +158,14 @@ class Poll extends ImmutablePureComponent {
|
||||||
const showResults = poll.get('voted') || expired;
|
const showResults = poll.get('voted') || expired;
|
||||||
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
|
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 (
|
return (
|
||||||
<div className='poll'>
|
<div className='poll'>
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -166,7 +175,7 @@ class Poll extends ImmutablePureComponent {
|
||||||
<div className='poll__footer'>
|
<div className='poll__footer'>
|
||||||
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
|
{!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>}
|
{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>}
|
{poll.get('expires_at') && <span> · {timeRemaining}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -232,25 +232,40 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
items = @object['oneOf']
|
items = @object['oneOf']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
voters_count = @object['votersCount']
|
||||||
|
|
||||||
@account.polls.new(
|
@account.polls.new(
|
||||||
multiple: multiple,
|
multiple: multiple,
|
||||||
expires_at: expires_at,
|
expires_at: expires_at,
|
||||||
options: items.map { |item| item['name'].presence || item['content'] }.compact,
|
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
|
end
|
||||||
|
|
||||||
def poll_vote?
|
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'])
|
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?
|
poll_vote! 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
|
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
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)
|
def resolve_thread(status)
|
||||||
return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri)
|
return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri)
|
||||||
ThreadResolveWorker.perform_async(status.id, 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])
|
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
|
||||||
end
|
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
|
def lock_options
|
||||||
{ redis: Redis.current, key: "create:#{@object['id']}" }
|
{ redis: Redis.current, key: "create:#{@object['id']}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def poll_lock_options
|
||||||
|
{ redis: Redis.current, key: "vote:#{replied_to_status.poll_id}:#{@account.id}" }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -21,6 +21,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||||
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
||||||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||||
|
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def self.default_key_transform
|
def self.default_key_transform
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# lock_version :integer default(0), not null
|
# lock_version :integer default(0), not null
|
||||||
|
# voters_count :bigint(8)
|
||||||
#
|
#
|
||||||
|
|
||||||
class Poll < ApplicationRecord
|
class Poll < ApplicationRecord
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
context_extensions :atom_uri, :conversation, :sensitive
|
context_extensions :atom_uri, :conversation, :sensitive, :voters_count
|
||||||
|
|
||||||
attributes :id, :type, :summary,
|
attributes :id, :type, :summary,
|
||||||
:in_reply_to, :published, :url,
|
:in_reply_to, :published, :url,
|
||||||
|
@ -23,6 +23,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
attribute :end_time, if: :poll_and_expires?
|
attribute :end_time, if: :poll_and_expires?
|
||||||
attribute :closed, if: :poll_and_expired?
|
attribute :closed, if: :poll_and_expired?
|
||||||
|
|
||||||
|
attribute :voters_count, if: :poll_and_voters_count?
|
||||||
|
|
||||||
def id
|
def id
|
||||||
ActivityPub::TagManager.instance.uri_for(object)
|
ActivityPub::TagManager.instance.uri_for(object)
|
||||||
end
|
end
|
||||||
|
@ -141,6 +143,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
|
|
||||||
alias end_time closed
|
alias end_time closed
|
||||||
|
|
||||||
|
def voters_count
|
||||||
|
object.preloadable_poll.voters_count
|
||||||
|
end
|
||||||
|
|
||||||
def poll_and_expires?
|
def poll_and_expires?
|
||||||
object.preloadable_poll&.expires_at&.present?
|
object.preloadable_poll&.expires_at&.present?
|
||||||
end
|
end
|
||||||
|
@ -149,6 +155,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
object.preloadable_poll&.expired?
|
object.preloadable_poll&.expired?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def poll_and_voters_count?
|
||||||
|
object.preloadable_poll&.voters_count
|
||||||
|
end
|
||||||
|
|
||||||
class MediaAttachmentSerializer < ActivityPub::Serializer
|
class MediaAttachmentSerializer < ActivityPub::Serializer
|
||||||
context_extensions :blurhash, :focal_point
|
context_extensions :blurhash, :focal_point
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class REST::PollSerializer < ActiveModel::Serializer
|
class REST::PollSerializer < ActiveModel::Serializer
|
||||||
attributes :id, :expires_at, :expired,
|
attributes :id, :expires_at, :expired,
|
||||||
:multiple, :votes_count
|
:multiple, :votes_count, :voters_count
|
||||||
|
|
||||||
has_many :loaded_options, key: :options
|
has_many :loaded_options, key: :options
|
||||||
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
||||||
|
|
|
@ -28,6 +28,8 @@ class ActivityPub::ProcessPollService < BaseService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
voters_count = @json['votersCount']
|
||||||
|
|
||||||
latest_options = items.map { |item| item['name'].presence || item['content'] }
|
latest_options = items.map { |item| item['name'].presence || item['content'] }
|
||||||
|
|
||||||
# If for some reasons the options were changed, it invalidates all previous
|
# 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,
|
last_fetched_at: Time.now.utc,
|
||||||
expires_at: expires_at,
|
expires_at: expires_at,
|
||||||
options: latest_options,
|
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
|
rescue ActiveRecord::StaleObjectError
|
||||||
poll.reload
|
poll.reload
|
||||||
|
|
|
@ -174,7 +174,7 @@ class PostStatusService < BaseService
|
||||||
def poll_attributes
|
def poll_attributes
|
||||||
return if @options[:poll].blank?
|
return if @options[:poll].blank?
|
||||||
|
|
||||||
@options[:poll].merge(account: @account)
|
@options[:poll].merge(account: @account, voters_count: 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
def scheduled_options
|
def scheduled_options
|
||||||
|
|
|
@ -12,12 +12,24 @@ class VoteService < BaseService
|
||||||
@choices = choices
|
@choices = choices
|
||||||
@votes = []
|
@votes = []
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
already_voted = true
|
||||||
@choices.each do |choice|
|
|
||||||
@votes << @poll.votes.create!(account: @account, choice: choice)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
increment_voters_count! unless already_voted
|
||||||
|
|
||||||
ActivityTracker.increment('activity:interactions')
|
ActivityTracker.increment('activity:interactions')
|
||||||
|
|
||||||
if @poll.account.local?
|
if @poll.account.local?
|
||||||
|
@ -53,4 +65,18 @@ class VoteService < BaseService
|
||||||
def build_json(vote)
|
def build_json(vote)
|
||||||
Oj.dump(serialize_payload(vote, ActivityPub::VoteSerializer))
|
Oj.dump(serialize_payload(vote, ActivityPub::VoteSerializer))
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
- show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired?
|
- show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired?
|
||||||
- own_votes = user_signed_in? ? poll.own_votes(current_account) : []
|
- own_votes = user_signed_in? ? poll.own_votes(current_account) : []
|
||||||
|
- total_votes_count = poll.voters_count || poll.votes_count
|
||||||
|
|
||||||
.poll
|
.poll
|
||||||
%ul
|
%ul
|
||||||
- poll.loaded_options.each_with_index do |option, index|
|
- poll.loaded_options.each_with_index do |option, index|
|
||||||
%li
|
%li
|
||||||
- if show_results
|
- 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}%" }
|
%span.poll__chart{ style: "width: #{percent}%" }
|
||||||
|
|
||||||
%label.poll__text><
|
%label.poll__text><
|
||||||
|
@ -24,7 +25,10 @@
|
||||||
%button.button.button-secondary{ disabled: true }
|
%button.button.button-secondary{ disabled: true }
|
||||||
= t('statuses.poll.vote')
|
= 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?
|
- unless poll.expires_at.nil?
|
||||||
·
|
·
|
||||||
|
|
|
@ -1030,6 +1030,9 @@ en:
|
||||||
private: Non-public toot cannot be pinned
|
private: Non-public toot cannot be pinned
|
||||||
reblog: A boost cannot be pinned
|
reblog: A boost cannot be pinned
|
||||||
poll:
|
poll:
|
||||||
|
total_people:
|
||||||
|
one: "%{count} person"
|
||||||
|
other: "%{count} people"
|
||||||
total_votes:
|
total_votes:
|
||||||
one: "%{count} vote"
|
one: "%{count} vote"
|
||||||
other: "%{count} votes"
|
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -529,6 +529,7 @@ ActiveRecord::Schema.define(version: 2019_09_27_124642) do
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "lock_version", default: 0, 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 ["account_id"], name: "index_polls_on_account_id"
|
||||||
t.index ["status_id"], name: "index_polls_on_status_id"
|
t.index ["status_id"], name: "index_polls_on_status_id"
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue