Merge pull request 'federate votersCount' (#1116) from Yonle/akkoma:fed_votersCount into develop
All checks were successful
ci/woodpecker/push/docs Pipeline was successful
ci/woodpecker/push/publish/4 Pipeline was successful
ci/woodpecker/push/publish/1 Pipeline was successful
ci/woodpecker/push/publish/2 Pipeline was successful

Reviewed-on: #1116
This commit is contained in:
Oneric 2026-05-09 21:56:10 +00:00
commit 22e3792ddb
12 changed files with 251 additions and 1 deletions

View file

@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
## Added
- federated voter count of polls is now parsed and federated out too;
this fixes vote percetanges for new and refreshed remote multi-selection polls
## 2026.05 (3.19.0)
### General note

View file

@ -336,12 +336,21 @@ defmodule Pleroma.Object do
option
end)
voters = [actor | object.data["voters"] || []] |> Enum.uniq()
old_voters = object.data["voters"] || []
old_voters_count = object.data["votersCount"] || length(old_voters)
{voters, voters_count} =
if actor in old_voters do
{old_voters, old_voters_count}
else
{[actor | old_voters], old_voters_count + 1}
end
data =
object.data
|> Map.put(key, options)
|> Map.put("voters", voters)
|> Map.put("votersCount", voters_count)
object
|> Object.change(%{data: data})

View file

@ -3,6 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Object.Updater do
alias Pleroma.Maps
require Pleroma.Constants
def update_content_fields(orig_object_data, updated_object) do
@ -110,6 +111,7 @@ defmodule Pleroma.Object.Updater do
# Choices are the same, but counts are different
to_be_updated
|> Map.put(key, updated_object[key])
|> Maps.put_if_present("votersCount", updated_object["votersCount"])
else
# Choices (or vote type) have changed, do not allow this
_ -> to_be_updated

View file

@ -28,6 +28,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do
end
field(:closed, ObjectValidators.DateTime)
field(:votersCount, :integer)
field(:voters, {:array, ObjectValidators.ObjectID}, default: [])
field(:nonAnonymous, :boolean)
embeds_many(:anyOf, QuestionOptionsValidator)

View file

@ -811,6 +811,16 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
end
end
defp set_voters_count(%{"votersCount" => n} = obj) when is_integer(n) do
obj
end
defp set_voters_count(%{"voters" => voters} = obj) when is_list(voters) do
Map.put_new(obj, "votersCount", length(voters))
end
defp set_voters_count(obj), do: obj
# Prepares the object of an outgoing create activity.
def prepare_object(object) do
object
@ -823,6 +833,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
|> set_reply_to_uri
|> set_quote_url()
|> set_replies
|> set_voters_count()
|> strip_internal_fields
|> strip_internal_tags
|> set_type

View file

@ -105,6 +105,10 @@ defmodule Pleroma.Web.ActivityPub.Utils do
"https://purl.archive.org/socialweb/webfinger",
%{
"@language" => "und",
# More Mastodon extensions not included in litepub
# (The toot: prefix is already defined in the litepub schema)
"votersCount" => "toot:votersCount",
# Further verbose definitions
"htmlMfm" => "https://w3id.org/fep/c16b#htmlMfm",
"sm" => "http://smithereen.software/ns#",
"nonAnonymous" => "sm:nonAnonymous"

View file

@ -157,6 +157,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
"type" => "Question",
key => option_notes,
"closed" => end_time,
"votersCount" => 0,
"nonAnonymous" => false
}

View file

@ -85,6 +85,8 @@ defmodule Pleroma.Web.MastodonAPI.PollView do
nil
end
defp voters_count(_multiple, %{data: %{"votersCount" => count}}), do: count
defp voters_count(_multiple, %{data: %{"voters" => voters}}) when is_list(voters) do
length(voters)
end

View file

@ -0,0 +1,29 @@
defmodule Pleroma.Repo.Migrations.SanitizePollsVotersCount do
use Ecto.Migration
def up() do
"""
UPDATE objects
SET data = jsonb_set(
data,
'{votersCount}'::text[],
to_jsonb(
CASE
WHEN jsonb_typeof(data->'voters') = 'array' THEN jsonb_array_length(data->'voters')
ELSE 0
END
),
TRUE
)
WHERE data->>'type' = 'Question' AND
COALESCE(jsonb_typeof(data->'votersCount'), 'NULL') <> 'number'
;
"""
|> execute()
end
def down() do
# no need to revert
:ok
end
end

View file

@ -170,4 +170,23 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.QuestionHandlingTest do
assert {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(data)
end
test "it displays voters count for a poll" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "???",
poll: %{expires_in: 10, options: ["yes", "no"]}
})
object = Object.normalize(activity, fetch: false)
{:ok, _, _} = CommonAPI.vote(other_user, object, [1])
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
refute Map.has_key?(modified["object"], "voters")
assert modified["object"]["votersCount"] == 1
end
end

View file

@ -146,6 +146,7 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
"https://purl.archive.org/socialweb/webfinger",
%{
"@language" => "und",
"votersCount" => "toot:votersCount",
"htmlMfm" => "https://w3id.org/fep/c16b#htmlMfm",
"nonAnonymous" => "sm:nonAnonymous",
"sm" => "http://smithereen.software/ns#"

View file

@ -190,4 +190,168 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
]
} = PollView.render("show.json", %{object: object})
end
test "prefers votersCount over voters list when both are present" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "Which flavor?",
poll: %{options: ["chocolate", "vanilla"], expires_in: 20, multiple: true}
})
object = Object.normalize(activity, fetch: false)
voter = insert(:user)
{:ok, _, object} = CommonAPI.vote(voter, object, [0])
assert object.data["votersCount"] == 1
assert length(object.data["voters"]) == 1
object = %{
object
| data: Map.put(object.data, "votersCount", 42)
}
result = PollView.render("show.json", %{object: object})
assert result[:voters_count] == 42
end
test "falls back to voters list when votersCount is absent" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "Which flavor?",
poll: %{options: ["chocolate", "vanilla"], expires_in: 20, multiple: true}
})
object = Object.normalize(activity, fetch: false)
voter = insert(:user)
{:ok, _, object} = CommonAPI.vote(voter, object, [0])
assert length(object.data["voters"]) == 1
data = Map.delete(object.data, "votersCount")
object = %{object | data: data}
result = PollView.render("show.json", %{object: object})
assert result[:voters_count] == 1
end
test "returns 0 when both votersCount and voters are absent" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "Which flavor?",
poll: %{options: ["chocolate", "vanilla"], expires_in: 20, multiple: true}
})
object = Object.normalize(activity, fetch: false)
data =
object.data
|> Map.delete("votersCount")
|> Map.delete("voters")
object = %{object | data: data}
result = PollView.render("show.json", %{object: object})
assert result[:voters_count] == 0
end
test "returns 0 when voters list is empty" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "Which flavor?",
poll: %{options: ["chocolate", "vanilla"], expires_in: 20, multiple: true}
})
object = Object.normalize(activity, fetch: false)
data =
object.data
|> Map.delete("votersCount")
|> Map.put("voters", [])
object = %{object | data: data}
result = PollView.render("show.json", %{object: object})
assert result[:voters_count] == 0
end
test "does not inflate votersCount when same voter picks multiple options" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "Pick several",
poll: %{options: ["a", "b", "c"], expires_in: 20, multiple: true}
})
object = Object.normalize(activity, fetch: false)
voter = insert(:user)
{:ok, _, object} = CommonAPI.vote(voter, object, [0, 2])
assert object.data["votersCount"] == 1
assert length(object.data["voters"]) == 1
result = PollView.render("show.json", %{object: object})
option_result = result[:options]
assert result[:votes_count] == 2
assert result[:voters_count] == 1
assert Enum.find(option_result, fn %{title: t} -> t == "a" end)[:votes_count] == 1
assert Enum.find(option_result, fn %{title: t} -> t == "b" end)[:votes_count] == 0
assert Enum.find(option_result, fn %{title: t} -> t == "c" end)[:votes_count] == 1
end
test "honours pre-existing votersCount" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "Pick several",
poll: %{options: ["a", "b"], expires_in: 20, multiple: true}
})
object = Object.normalize(activity, fetch: false)
chg = Ecto.Changeset.change(object, data: Map.put(object.data, "votersCount", 13))
{:ok, object} = Repo.update(chg)
voter = insert(:user)
{:ok, _, object} = CommonAPI.vote(voter, object, [0, 1])
result = PollView.render("show.json", %{object: object})
assert result[:voters_count] == 14
end
test "returns 0 when votersCount is explicitly 0" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "Pick one",
poll: %{options: ["a", "b"], expires_in: 20, multiple: true}
})
object = Object.normalize(activity, fetch: false)
object = %{object | data: Map.put(object.data, "votersCount", 0)}
result = PollView.render("show.json", %{object: object})
assert result[:voters_count] == 0
end
end