Merge branch 'replies-collection' into 'develop'

Draft: Support replies collection for compatibility with Mastodon reply-fetching

See merge request pleroma/pleroma!4340
This commit is contained in:
mkljczk 2025-03-20 13:22:57 +00:00
commit 870f5890f8
8 changed files with 158 additions and 83 deletions

View file

@ -0,0 +1 @@
Support replies collection for compatibility with Mastodon reply-fetching

View file

@ -401,28 +401,6 @@ defmodule Pleroma.Object do
String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/")
end
def replies(object, opts \\ []) do
object = Object.normalize(object, fetch: false)
query =
Object
|> where(
[o],
fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
)
|> order_by([o], asc: o.id)
if opts[:self_only] do
actor = object.data["actor"]
where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
else
query
end
end
def self_replies(object, opts \\ []),
do: replies(object, Keyword.put(opts, :self_only, true))
def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
def tags(_), do: []

View file

@ -501,6 +501,32 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> Repo.all()
end
def fetch_replies(%{data: %{"actor" => actor, "id" => ap_id}}, opts) do
public = [Constants.as_public()]
recipients =
if opts[:user],
do: [opts[:user].ap_id | User.following(opts[:user])] ++ public,
else: public
from(activity in Activity)
|> maybe_preload_objects(opts)
|> maybe_preload_bookmarks(opts)
|> maybe_set_thread_muted_field(opts)
|> Activity.Queries.by_object_in_reply_to_id(ap_id)
|> restrict_type(%{type: "Create"})
|> restrict_author(actor, opts)
|> restrict_unauthenticated(opts[:user])
|> restrict_blocked(opts)
|> restrict_blockers_visibility(opts)
|> restrict_recipients(recipients, opts[:user])
|> restrict_filtered(opts)
|> exclude_poll_votes(opts)
|> exclude_id(opts)
|> order_by([activity], desc: activity.id)
|> Repo.all()
end
@spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) ::
Ecto.UUID.t() | nil
def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do
@ -1299,6 +1325,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp restrict_rule(query, _), do: query
defp restrict_author(query, actor, %{only_self: true}) do
where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
end
defp restrict_author(query, actor, %{only_other_accounts: true}) do
where(query, [o], fragment("(?)->>'actor' != ?", o.data, ^actor))
end
defp restrict_author(query, _, _), do: query
defp exclude_poll_votes(query, %{include_poll_votes: true}), do: query
defp exclude_poll_votes(query, _) do

View file

@ -96,6 +96,49 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
end
def replies(%{assigns: assigns} = conn, %{"page" => page?} = params)
when page? in [true, "true"] do
with only_other_accounts? <- Map.get(params, "only_other_accounts", false),
ap_id <- (Endpoint.url() <> conn.request_path) |> String.trim_trailing("/replies"),
%Object{} = object <- Object.get_cached_by_ap_id(ap_id),
user <- Map.get(assigns, :user, nil),
{_, true} <- {:visible?, Visibility.visible_for_user?(object, user)} do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(ObjectView)
|> render("replies_collection_page.json", %{
user: user,
object: object,
only_other_accounts: only_other_accounts?,
iri: "#{object.data["id"]}/replies"
})
else
{:visible?, false} -> {:error, :not_found}
nil -> {:error, :not_found}
end
end
def replies(%{assigns: assigns} = conn, _) do
with ap_id <-
(Endpoint.url() <> conn.request_path)
|> String.trim_trailing("/replies"),
%Object{} = object <- Object.get_cached_by_ap_id(ap_id),
user <- Map.get(assigns, :user, nil),
{_, true} <- {:visible?, Visibility.visible_for_user?(object, user)} do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(ObjectView)
|> render("replies_collection.json", %{
user: user,
object: object,
iri: "#{object.data["id"]}/replies"
})
else
{:visible?, false} -> {:error, :not_found}
nil -> {:error, :not_found}
end
end
def track_object_fetch(conn, nil), do: conn
def track_object_fetch(conn, object_id) do

View file

@ -22,7 +22,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.Federator
import Ecto.Query
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
require Pleroma.Constants
@ -724,36 +723,18 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
Serialized Mastodon-compatible `replies` collection containing _self-replies_.
Based on Mastodon's ActivityPub::NoteSerializer#replies.
"""
def set_replies(obj_data) do
replies_uris =
with limit when limit > 0 <-
Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
%Object{} = object <- Object.get_cached_by_ap_id(obj_data["id"]) do
object
|> Object.self_replies()
|> select([o], fragment("?->>'id'", o.data))
|> limit(^limit)
|> Repo.all()
else
_ -> []
end
set_replies(obj_data, replies_uris)
end
defp set_replies(obj, []) do
obj
end
defp set_replies(obj, replies_uris) do
def set_replies(%{"id" => object_id} = object) do
replies_collection = %{
"id" => object_id <> "/replies",
"type" => "Collection",
"items" => replies_uris
"first" => object_id <> "/replies?only_other_accounts=false&page=true"
}
Map.merge(obj, %{"replies" => replies_collection})
Map.merge(object, %{"replies" => replies_collection})
end
def set_replies(object), do: object
def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do
items
end

View file

@ -7,9 +7,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Utils
def render("object.json", %{object: %Object{} = object}) do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(object.data)
base = Utils.make_json_ld_header(object.data)
additional = Transmogrifier.prepare_object(object.data)
Map.merge(base, additional)
@ -17,7 +18,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
def render("object.json", %{object: %Activity{data: %{"type" => activity_type}} = activity})
when activity_type in ["Create", "Listen"] do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(activity.data)
base = Utils.make_json_ld_header(activity.data)
object = Object.normalize(activity, fetch: false)
additional =
@ -28,7 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
end
def render("object.json", %{object: %Activity{} = activity}) do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(activity.data)
base = Utils.make_json_ld_header(activity.data)
object_id = Object.normalize(activity, id_only: true)
additional =
@ -37,4 +38,61 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
Map.merge(base, additional)
end
def render("replies_collection.json", %{user: user, object: object, iri: iri}) do
%{
"id" => iri,
"type" => "Collection",
"first" =>
render("replies_collection_page.json", %{
user: user,
object: object,
only_other_accounts: false,
iri: iri
})
|> Map.drop(["@context"])
}
|> Map.merge(Utils.make_json_ld_header())
end
def render("replies_collection_page.json", %{
user: user,
object: object,
only_other_accounts: only_other_accounts,
iri: iri
}) do
replies =
Pleroma.Web.ActivityPub.ActivityPub.fetch_replies(object, %{
user: user,
only_other_accounts: only_other_accounts,
only_self: !only_other_accounts
})
collection =
Enum.map(replies, fn
%{local: true} = activity ->
Transmogrifier.prepare_object(activity.object.data)
activity ->
activity.object.data["id"]
end)
next =
if !only_other_accounts do
%{
"next" => iri <> "?only_other_accounts=true&page=true"
}
else
%{}
end
%{
"id" => iri <> "?only_other_accounts=#{only_other_accounts}&page=true",
"type" => "CollectionPage",
"partOf" => iri,
"items" => collection
}
|> Map.merge(next)
|> Map.merge(Utils.make_json_ld_header())
end
end

View file

@ -943,6 +943,8 @@ defmodule Pleroma.Web.Router do
get("/users/:nickname/followers", ActivityPubController, :followers)
get("/users/:nickname/following", ActivityPubController, :following)
get("/users/:nickname/collections/featured", ActivityPubController, :pinned)
get("/objects/:id/replies", ActivityPubController, :replies)
end
scope "/", Pleroma.Web.ActivityPub do

View file

@ -696,41 +696,17 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do
describe "set_replies/1" do
setup do: clear_config([:activitypub, :note_replies_output_limit], 2)
test "returns unmodified object if activity doesn't have self-replies" do
data = Jason.decode!(File.read!("test/fixtures/mastodon-post-activity.json"))
assert Transmogrifier.set_replies(data) == data
end
test "sets `replies` to a link to a collection page" do
object = insert(:note) |> IO.inspect()
test "sets `replies` collection with a limited number of self-replies" do
[user, another_user] = insert_list(2, :user)
replies_url = object.data["id"] <> "/replies"
replies_page = replies_url <> "?only_other_accounts=false&page=true"
{:ok, %{id: id1} = activity} = CommonAPI.post(user, %{status: "1"})
{:ok, %{id: id2} = self_reply1} =
CommonAPI.post(user, %{status: "self-reply 1", in_reply_to_status_id: id1})
{:ok, self_reply2} =
CommonAPI.post(user, %{status: "self-reply 2", in_reply_to_status_id: id1})
# Assuming to _not_ be present in `replies` due to :note_replies_output_limit is set to 2
{:ok, _} = CommonAPI.post(user, %{status: "self-reply 3", in_reply_to_status_id: id1})
{:ok, _} =
CommonAPI.post(user, %{
status: "self-reply to self-reply",
in_reply_to_status_id: id2
})
{:ok, _} =
CommonAPI.post(another_user, %{
status: "another user's reply",
in_reply_to_status_id: id1
})
object = Object.normalize(activity, fetch: false)
replies_uris = Enum.map([self_reply1, self_reply2], fn a -> a.object.data["id"] end)
assert %{"type" => "Collection", "items" => ^replies_uris} =
assert %{
"type" => "Collection",
"id" => ^replies_url,
"first" => ^replies_page
} =
Transmogrifier.set_replies(object.data)["replies"]
end
end