Support lists exclusive param

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk 2026-02-18 11:06:13 +01:00 committed by nicole mikołajczyk
commit 490cd33bc9
11 changed files with 133 additions and 48 deletions

View file

@ -0,0 +1 @@
Support lists `exclusive` param

View file

@ -17,13 +17,14 @@ defmodule Pleroma.List do
field(:title, :string) field(:title, :string)
field(:following, {:array, :string}, default: []) field(:following, {:array, :string}, default: [])
field(:ap_id, :string) field(:ap_id, :string)
field(:exclusive, :boolean, default: false)
timestamps() timestamps()
end end
def title_changeset(list, attrs \\ %{}) do def update_changeset(list, attrs \\ %{}) do
list list
|> cast(attrs, [:title]) |> cast(attrs, [:title, :exclusive])
|> validate_required([:title]) |> validate_required([:title])
end end
@ -91,14 +92,14 @@ defmodule Pleroma.List do
|> Repo.all() |> Repo.all()
end end
def rename(%Pleroma.List{} = list, title) do def update(%Pleroma.List{} = list, params) do
list list
|> title_changeset(%{title: title}) |> update_changeset(params)
|> Repo.update() |> Repo.update()
end end
def create(title, %User{} = creator) do def create(params, %User{} = creator) do
changeset = title_changeset(%Pleroma.List{user_id: creator.id}, %{title: title}) changeset = update_changeset(%Pleroma.List{user_id: creator.id}, params)
if changeset.valid? do if changeset.valid? do
Repo.transaction(fn -> Repo.transaction(fn ->
@ -149,4 +150,14 @@ defmodule Pleroma.List do
end end
def member?(_, _), do: false def member?(_, _), do: false
def get_exclusive_list_members(%User{id: user_id}) do
Pleroma.List
|> where([l], l.user_id == ^user_id)
|> where([l], l.exclusive == true)
|> select([l], l.following)
|> Repo.all()
|> List.flatten()
|> Enum.uniq()
end
end end

View file

@ -36,7 +36,7 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do
summary: "Create a list", summary: "Create a list",
description: "Fetch the list with the given ID. Used for verifying the title of a list.", description: "Fetch the list with the given ID. Used for verifying the title of a list.",
operationId: "ListController.create", operationId: "ListController.create",
requestBody: create_update_request(), requestBody: create_request(),
security: [%{"oAuth" => ["write:lists"]}], security: [%{"oAuth" => ["write:lists"]}],
responses: %{ responses: %{
200 => Operation.response("List", "application/json", List), 200 => Operation.response("List", "application/json", List),
@ -68,7 +68,7 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do
description: "Change the title of a list", description: "Change the title of a list",
operationId: "ListController.update", operationId: "ListController.update",
parameters: [id_param()], parameters: [id_param()],
requestBody: create_update_request(), requestBody: update_request(),
security: [%{"oAuth" => ["write:lists"]}], security: [%{"oAuth" => ["write:lists"]}],
responses: %{ responses: %{
200 => Operation.response("List", "application/json", List), 200 => Operation.response("List", "application/json", List),
@ -164,14 +164,15 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do
) )
end end
defp create_update_request do defp create_request do
request_body( request_body(
"Parameters", "Parameters",
%Schema{ %Schema{
description: "POST body for creating or updating a List", description: "POST body for creating a List",
type: :object, type: :object,
properties: %{ properties: %{
title: %Schema{type: :string, description: "List title"} title: %Schema{type: :string, description: "List title"},
exclusive: %Schema{type: :boolean, description: "Whether members of the list should be removed from the “Home” feed"}
}, },
required: [:title] required: [:title]
}, },
@ -179,6 +180,21 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do
) )
end end
defp update_request do
request_body(
"Parameters",
%Schema{
description: "PUT body for updating a List",
type: :object,
properties: %{
title: %Schema{type: :string, description: "List title"},
exclusive: %Schema{type: :boolean, description: "Whether members of the list should be removed from the “Home” feed"}
}
},
required: true
)
end
defp add_remove_accounts_request(required) when is_boolean(required) do defp add_remove_accounts_request(required) when is_boolean(required) do
request_body( request_body(
"Parameters", "Parameters",

View file

@ -13,7 +13,11 @@ defmodule Pleroma.Web.ApiSpec.Schemas.List do
type: :object, type: :object,
properties: %{ properties: %{
id: %Schema{type: :string, description: "The internal database ID of the list"}, id: %Schema{type: :string, description: "The internal database ID of the list"},
title: %Schema{type: :string, description: "The user-defined title of the list"} title: %Schema{type: :string, description: "The user-defined title of the list"},
exclusive: %Schema{
type: :boolean,
description: "Whether members of the list should be removed from the “Home” feed"
}
}, },
example: %{ example: %{
"id" => "12249", "id" => "12249",

View file

@ -28,27 +28,27 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
# POST /api/v1/lists # POST /api/v1/lists
def create( def create(
%{assigns: %{user: user}, private: %{open_api_spex: %{body_params: %{title: title}}}} = %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: params}}} =
conn, conn,
_ _
) do ) do
with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(title, user) do with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(params, user) do
render(conn, "show.json", list: list) render(conn, "show.json", list: list)
end end
end end
# GET /api/v1/lists/:idOB # GET /api/v1/lists/:id
def show(%{assigns: %{list: list}} = conn, _) do def show(%{assigns: %{list: list}} = conn, _) do
render(conn, "show.json", list: list) render(conn, "show.json", list: list)
end end
# PUT /api/v1/lists/:id # PUT /api/v1/lists/:id
def update( def update(
%{assigns: %{list: list}, private: %{open_api_spex: %{body_params: %{title: title}}}} = %{assigns: %{list: list}, private: %{open_api_spex: %{body_params: params}}} =
conn, conn,
_ _
) do ) do
with {:ok, list} <- Pleroma.List.rename(list, title) do with {:ok, list} <- Pleroma.List.update(list, params) do
render(conn, "show.json", list: list) render(conn, "show.json", list: list)
end end
end end

View file

@ -45,6 +45,10 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
|> User.followed_hashtags() |> User.followed_hashtags()
|> Enum.map(& &1.id) |> Enum.map(& &1.id)
excluded_list_members =
user
|> Pleroma.List.get_exclusive_list_members()
params = params =
params params
|> Map.put(:type, ["Create", "Announce"]) |> Map.put(:type, ["Create", "Announce"])
@ -58,7 +62,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
|> Map.delete(:local) |> Map.delete(:local)
activities = activities =
[user.ap_id | User.following(user)] [user.ap_id | User.following(user) -- excluded_list_members]
|> ActivityPub.fetch_activities(params) |> ActivityPub.fetch_activities(params)
|> Enum.reverse() |> Enum.reverse()

View file

@ -13,7 +13,8 @@ defmodule Pleroma.Web.MastodonAPI.ListView do
def render("show.json", %{list: list}) do def render("show.json", %{list: list}) do
%{ %{
id: to_string(list.id), id: to_string(list.id),
title: list.title title: list.title,
exclusive: list.exclusive
} }
end end
end end

View file

@ -0,0 +1,9 @@
defmodule Pleroma.Repo.Migrations.AddExclusiveToLists do
use Ecto.Migration
def change do
alter table(:lists) do
add(:exclusive, :boolean, default: false)
end
end
end

View file

@ -10,22 +10,23 @@ defmodule Pleroma.ListTest do
test "creating a list" do test "creating a list" do
user = insert(:user) user = insert(:user)
{:ok, %Pleroma.List{} = list} = Pleroma.List.create("title", user) {:ok, %Pleroma.List{} = list} = Pleroma.List.create(%{title: "title"}, user)
%Pleroma.List{title: title} = Pleroma.List.get(list.id, user) %Pleroma.List{title: title, exclusive: exclusive} = Pleroma.List.get(list.id, user)
assert title == "title" assert title == "title"
assert exclusive == false
end end
test "validates title" do test "validates title" do
user = insert(:user) user = insert(:user)
assert {:error, changeset} = Pleroma.List.create("", user) assert {:error, changeset} = Pleroma.List.create(%{title: ""}, user)
assert changeset.errors == [title: {"can't be blank", [validation: :required]}] assert changeset.errors == [title: {"can't be blank", [validation: :required]}]
end end
test "getting a list not belonging to the user" do test "getting a list not belonging to the user" do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)
{:ok, %Pleroma.List{} = list} = Pleroma.List.create("title", user) {:ok, %Pleroma.List{} = list} = Pleroma.List.create(%{title: "title"}, user)
ret = Pleroma.List.get(list.id, other_user) ret = Pleroma.List.get(list.id, other_user)
assert is_nil(ret) assert is_nil(ret)
end end
@ -33,7 +34,7 @@ defmodule Pleroma.ListTest do
test "adding an user to a list" do test "adding an user to a list" do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user) {:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, %{following: following}} = Pleroma.List.follow(list, other_user) {:ok, %{following: following}} = Pleroma.List.follow(list, other_user)
assert [other_user.follower_address] == following assert [other_user.follower_address] == following
end end
@ -41,7 +42,7 @@ defmodule Pleroma.ListTest do
test "removing an user from a list" do test "removing an user from a list" do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user) {:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, %{following: _following}} = Pleroma.List.follow(list, other_user) {:ok, %{following: _following}} = Pleroma.List.follow(list, other_user)
{:ok, %{following: following}} = Pleroma.List.unfollow(list, other_user) {:ok, %{following: following}} = Pleroma.List.unfollow(list, other_user)
assert [] == following assert [] == following
@ -49,14 +50,27 @@ defmodule Pleroma.ListTest do
test "renaming a list" do test "renaming a list" do
user = insert(:user) user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user) {:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, %{title: title}} = Pleroma.List.rename(list, "new") {:ok, %{title: title}} = Pleroma.List.update(list, %{title: "new"})
assert "new" == title assert "new" == title
end end
test "updating a list exclusivity" do
user = insert(:user)
{:ok, %{exclusive: exclusive} = list} =
Pleroma.List.create(%{title: "title", exclusive: true}, user)
assert exclusive == true
{:ok, %{exclusive: exclusive} = list} = Pleroma.List.update(list, %{exclusive: false})
assert exclusive == false
{:ok, %{exclusive: exclusive}} = Pleroma.List.update(list, %{exclusive: true})
assert exclusive == true
end
test "deleting a list" do test "deleting a list" do
user = insert(:user) user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user) {:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, list} = Pleroma.List.delete(list) {:ok, list} = Pleroma.List.delete(list)
assert is_nil(Repo.get(Pleroma.List, list.id)) assert is_nil(Repo.get(Pleroma.List, list.id))
end end
@ -65,7 +79,7 @@ defmodule Pleroma.ListTest do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)
third_user = insert(:user) third_user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user) {:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user) {:ok, list} = Pleroma.List.follow(list, other_user)
{:ok, list} = Pleroma.List.follow(list, third_user) {:ok, list} = Pleroma.List.follow(list, third_user)
{:ok, following} = Pleroma.List.get_following(list) {:ok, following} = Pleroma.List.get_following(list)
@ -76,9 +90,9 @@ defmodule Pleroma.ListTest do
test "getting all lists by an user" do test "getting all lists by an user" do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)
{:ok, list_one} = Pleroma.List.create("title", user) {:ok, list_one} = Pleroma.List.create(%{title: "title"}, user)
{:ok, list_two} = Pleroma.List.create("other title", user) {:ok, list_two} = Pleroma.List.create(%{title: "other title"}, user)
{:ok, list_three} = Pleroma.List.create("third title", other_user) {:ok, list_three} = Pleroma.List.create(%{title: "third title"}, other_user)
lists = Pleroma.List.for_user(user, %{}) lists = Pleroma.List.for_user(user, %{})
assert list_one in lists assert list_one in lists
assert list_two in lists assert list_two in lists
@ -88,9 +102,9 @@ defmodule Pleroma.ListTest do
test "getting all lists the user is a member of" do test "getting all lists the user is a member of" do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)
{:ok, list_one} = Pleroma.List.create("title", user) {:ok, list_one} = Pleroma.List.create(%{title: "title"}, user)
{:ok, list_two} = Pleroma.List.create("other title", user) {:ok, list_two} = Pleroma.List.create(%{title: "other title"}, user)
{:ok, list_three} = Pleroma.List.create("third title", other_user) {:ok, list_three} = Pleroma.List.create(%{title: "third title"}, other_user)
{:ok, list_one} = Pleroma.List.follow(list_one, other_user) {:ok, list_one} = Pleroma.List.follow(list_one, other_user)
{:ok, list_two} = Pleroma.List.follow(list_two, other_user) {:ok, list_two} = Pleroma.List.follow(list_two, other_user)
{:ok, list_three} = Pleroma.List.follow(list_three, user) {:ok, list_three} = Pleroma.List.follow(list_three, user)
@ -106,8 +120,8 @@ defmodule Pleroma.ListTest do
not_owner = insert(:user) not_owner = insert(:user)
member_1 = insert(:user) member_1 = insert(:user)
member_2 = insert(:user) member_2 = insert(:user)
{:ok, owned_list} = Pleroma.List.create("owned", owner) {:ok, owned_list} = Pleroma.List.create(%{title: "owned"}, owner)
{:ok, not_owned_list} = Pleroma.List.create("not owned", not_owner) {:ok, not_owned_list} = Pleroma.List.create(%{title: "not owned"}, not_owner)
{:ok, owned_list} = Pleroma.List.follow(owned_list, member_1) {:ok, owned_list} = Pleroma.List.follow(owned_list, member_1)
{:ok, owned_list} = Pleroma.List.follow(owned_list, member_2) {:ok, owned_list} = Pleroma.List.follow(owned_list, member_2)
{:ok, not_owned_list} = Pleroma.List.follow(not_owned_list, member_1) {:ok, not_owned_list} = Pleroma.List.follow(not_owned_list, member_1)
@ -123,14 +137,14 @@ defmodule Pleroma.ListTest do
test "get by ap_id" do test "get by ap_id" do
user = insert(:user) user = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user) {:ok, list} = Pleroma.List.create(%{title: "foo"}, user)
assert Pleroma.List.get_by_ap_id(list.ap_id) == list assert Pleroma.List.get_by_ap_id(list.ap_id) == list
end end
test "memberships" do test "memberships" do
user = insert(:user) user = insert(:user)
member = insert(:user) member = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user) {:ok, list} = Pleroma.List.create(%{title: "foo"}, user)
{:ok, list} = Pleroma.List.follow(list, member) {:ok, list} = Pleroma.List.follow(list, member)
assert Pleroma.List.memberships(member) == [list.ap_id] assert Pleroma.List.memberships(member) == [list.ap_id]
@ -140,7 +154,7 @@ defmodule Pleroma.ListTest do
user = insert(:user) user = insert(:user)
member = insert(:user) member = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user) {:ok, list} = Pleroma.List.create(%{title: "foo"}, user)
{:ok, list} = Pleroma.List.follow(list, member) {:ok, list} = Pleroma.List.follow(list, member)
assert Pleroma.List.member?(list, member) assert Pleroma.List.member?(list, member)

View file

@ -56,7 +56,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
%{user: user, conn: conn} = oauth_access(["write:lists"]) %{user: user, conn: conn} = oauth_access(["write:lists"])
other_user = insert(:user) other_user = insert(:user)
third_user = insert(:user) third_user = insert(:user)
{:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.create(%{title: "name"}, user)
assert %{} == assert %{} ==
conn conn
@ -77,7 +77,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
other_user = insert(:user) other_user = insert(:user)
third_user = insert(:user) third_user = insert(:user)
fourth_user = insert(:user) fourth_user = insert(:user)
{:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.create(%{title: "name"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user) {:ok, list} = Pleroma.List.follow(list, other_user)
{:ok, list} = Pleroma.List.follow(list, third_user) {:ok, list} = Pleroma.List.follow(list, third_user)
{:ok, list} = Pleroma.List.follow(list, fourth_user) {:ok, list} = Pleroma.List.follow(list, fourth_user)
@ -98,7 +98,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
%{user: user, conn: conn} = oauth_access(["write:lists"]) %{user: user, conn: conn} = oauth_access(["write:lists"])
other_user = insert(:user) other_user = insert(:user)
third_user = insert(:user) third_user = insert(:user)
{:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.create(%{title: "name"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user) {:ok, list} = Pleroma.List.follow(list, other_user)
{:ok, list} = Pleroma.List.follow(list, third_user) {:ok, list} = Pleroma.List.follow(list, third_user)
@ -115,7 +115,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
test "listing users in a list" do test "listing users in a list" do
%{user: user, conn: conn} = oauth_access(["read:lists"]) %{user: user, conn: conn} = oauth_access(["read:lists"])
other_user = insert(:user) other_user = insert(:user)
{:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.create(%{title: "name"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user) {:ok, list} = Pleroma.List.follow(list, other_user)
conn = conn =
@ -129,7 +129,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
test "retrieving a list" do test "retrieving a list" do
%{user: user, conn: conn} = oauth_access(["read:lists"]) %{user: user, conn: conn} = oauth_access(["read:lists"])
{:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.create(%{title: "name"}, user)
conn = conn =
conn conn
@ -150,7 +150,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
test "renaming a list" do test "renaming a list" do
%{user: user, conn: conn} = oauth_access(["write:lists"]) %{user: user, conn: conn} = oauth_access(["write:lists"])
{:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.create(%{title: "name"}, user)
assert %{"title" => "newname"} = assert %{"title" => "newname"} =
conn conn
@ -161,7 +161,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
test "validates title when renaming a list" do test "validates title when renaming a list" do
%{user: user, conn: conn} = oauth_access(["write:lists"]) %{user: user, conn: conn} = oauth_access(["write:lists"])
{:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.create(%{title: "name"}, user)
conn = conn =
conn conn
@ -175,7 +175,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
test "deleting a list" do test "deleting a list" do
%{user: user, conn: conn} = oauth_access(["write:lists"]) %{user: user, conn: conn} = oauth_access(["write:lists"])
{:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.create(%{title: "name"}, user)
conn = delete(conn, "/api/v1/lists/#{list.id}") conn = delete(conn, "/api/v1/lists/#{list.id}")

View file

@ -149,6 +149,31 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
|> get("/api/v1/timelines/home?remote=true&local=true") |> get("/api/v1/timelines/home?remote=true&local=true")
|> json_response_and_validate_schema(200) == [] |> json_response_and_validate_schema(200) == []
end end
test "the home timeline excludes posts from users in exclusive lists", %{
user: user,
conn: conn
} do
other_user1 = insert(:user)
other_user2 = insert(:user)
{:ok, user, other_user1} = User.follow(user, other_user1)
{:ok, user, other_user2} = User.follow(user, other_user2)
{:ok, list} = Pleroma.List.create(%{title: "foo", exclusive: true}, user)
{:ok, _list} = Pleroma.List.follow(list, other_user1)
{:ok, _activity} = CommonAPI.post(other_user1, %{status: "hi"})
{:ok, %{id: activity2_id}} = CommonAPI.post(other_user2, %{status: "hi too"})
response =
conn
|> assign(:user, user)
|> get("/api/v1/timelines/home")
|> json_response_and_validate_schema(200)
assert [%{"id" => ^activity2_id}] = response
end
end end
describe "public" do describe "public" do