diff --git a/changelog.d/replies-collection.add b/changelog.d/replies-collection.add
new file mode 100644
index 000000000..9b7f8dc77
--- /dev/null
+++ b/changelog.d/replies-collection.add
@@ -0,0 +1 @@
+Provide full replies collection in ActivityPub objects
\ No newline at end of file
diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex
index d770b9ff3..7aa42b365 100644
--- a/lib/pleroma/activity/queries.ex
+++ b/lib/pleroma/activity/queries.ex
@@ -7,7 +7,7 @@ defmodule Pleroma.Activity.Queries do
Contains queries for Activity.
"""
- import Ecto.Query, only: [from: 2, where: 3]
+ import Ecto.Query, only: [from: 2]
@type query :: Ecto.Queryable.t() | Pleroma.Activity.t()
@@ -70,22 +70,6 @@ defmodule Pleroma.Activity.Queries do
)
end
- @spec by_object_in_reply_to_id(query, String.t(), keyword()) :: query
- def by_object_in_reply_to_id(query, in_reply_to_id, opts \\ []) do
- query =
- if opts[:skip_preloading] do
- Activity.with_joined_object(query)
- else
- Activity.with_preloaded_object(query)
- end
-
- where(
- query,
- [activity, object: o],
- fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(in_reply_to_id))
- )
- end
-
@spec by_type(query, String.t()) :: query
def by_type(query \\ Activity, activity_type) do
from(
diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex
index 20e5bfb5c..d0cb16b79 100644
--- a/lib/pleroma/object.ex
+++ b/lib/pleroma/object.ex
@@ -398,28 +398,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: []
diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex
index 66812b17b..3fc249f56 100644
--- a/lib/pleroma/pagination.ex
+++ b/lib/pleroma/pagination.ex
@@ -95,13 +95,30 @@ defmodule Pleroma.Pagination do
offset: :integer,
limit: :integer,
skip_extra_order: :boolean,
- skip_order: :boolean
+ skip_order: :boolean,
+ order_asc: :boolean
}
changeset = cast({%{}, param_types}, params, Map.keys(param_types))
changeset.changes
end
+ defp order_statement(query, table_binding, :asc) do
+ order_by(
+ query,
+ [{u, table_position(query, table_binding)}],
+ fragment("? asc nulls last", u.id)
+ )
+ end
+
+ defp order_statement(query, table_binding, :desc) do
+ order_by(
+ query,
+ [{u, table_position(query, table_binding)}],
+ fragment("? desc nulls last", u.id)
+ )
+ end
+
defp restrict(query, :min_id, %{min_id: min_id}, table_binding) do
where(query, [{q, table_position(query, table_binding)}], q.id > ^min_id)
end
@@ -119,19 +136,16 @@ defmodule Pleroma.Pagination do
defp restrict(%{order_bys: [_ | _]} = query, :order, %{skip_extra_order: true}, _), do: query
defp restrict(query, :order, %{min_id: _}, table_binding) do
- order_by(
- query,
- [{u, table_position(query, table_binding)}],
- fragment("? asc nulls last", u.id)
- )
+ order_statement(query, table_binding, :asc)
end
- defp restrict(query, :order, _options, table_binding) do
- order_by(
- query,
- [{u, table_position(query, table_binding)}],
- fragment("? desc nulls last", u.id)
- )
+ defp restrict(query, :order, %{max_id: _}, table_binding) do
+ order_statement(query, table_binding, :desc)
+ end
+
+ defp restrict(query, :order, options, table_binding) do
+ dir = if options[:order_asc], do: :asc, else: :desc
+ order_statement(query, table_binding, dir)
end
defp restrict(query, :offset, %{offset: offset}, _table_binding) do
@@ -151,11 +165,9 @@ defmodule Pleroma.Pagination do
defp restrict(query, _, _, _), do: query
- defp enforce_order(result, %{min_id: _}) do
- result
- |> Enum.reverse()
- end
-
+ defp enforce_order(result, %{min_id: _, order_asc: true}), do: result
+ defp enforce_order(result, %{min_id: _}), do: Enum.reverse(result)
+ defp enforce_order(result, %{max_id: _, order_asc: true}), do: Enum.reverse(result)
defp enforce_order(result, _), do: result
defp table_position(%Ecto.Query{} = query, binding_name) do
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
index edf2e1fa7..e58e3dd57 100644
--- a/lib/pleroma/web/activity_pub/activity_pub.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -499,6 +499,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> Repo.all()
end
+ def fetch_objects_for_replies_collection(parent_ap_id, opts \\ %{}) do
+ opts =
+ opts
+ |> Map.put(:order_asc, true)
+ |> Map.put(:id_type, :integer)
+
+ from(o in Object,
+ where:
+ fragment("?->>'inReplyTo' = ?", o.data, ^parent_ap_id) and
+ fragment(
+ "(?->'to' \\? ?::text OR ?->'cc' \\? ?::text)",
+ o.data,
+ ^Pleroma.Constants.as_public(),
+ o.data,
+ ^Pleroma.Constants.as_public()
+ ) and
+ fragment("?->>'type' <> 'Answer'", o.data),
+ select: %{id: o.id, ap_id: fragment("?->>'id'", o.data)}
+ )
+ |> Pagination.fetch_paginated(opts, :keyset)
+ 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
diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
index df5fcf9f6..4f1613a07 100644
--- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
@@ -31,6 +31,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
@federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers]
+ @object_replies_known_param_keys ["page", "min_id", "max_id", "since_id", "limit"]
+
plug(FederatingPlug when action in @federating_only_actions)
plug(
@@ -95,6 +97,36 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
end
+ def object_replies(%{assigns: assigns, query_params: params} = conn, _all_params) do
+ object_ap_id = conn.path_info |> Enum.reverse() |> tl() |> Enum.reverse()
+ object_ap_id = Endpoint.url() <> "/" <> Enum.join(object_ap_id, "/")
+
+ # Most other API params are converted to atoms by OpenAPISpex 3.x
+ # and therefore helper functions assume atoms. For consistency,
+ # also convert our params to atoms here.
+ params =
+ params
+ |> Map.take(@object_replies_known_param_keys)
+ |> Enum.into(%{}, fn {k, v} -> {String.to_existing_atom(k), v} end)
+ |> Map.put(:object_ap_id, object_ap_id)
+ |> Map.put(:order_asc, true)
+ |> Map.put(:conn, conn)
+
+ with %Object{} = object <- Object.get_cached_by_ap_id(object_ap_id),
+ user <- Map.get(assigns, :user, nil),
+ {_, true} <- {:visible?, Visibility.visible_for_user?(object, user)} do
+ conn
+ |> maybe_skip_cache(user)
+ |> set_cache_ttl_for(object)
+ |> put_resp_content_type("application/activity+json")
+ |> put_view(ObjectView)
+ |> render("object_replies.json", render_params: params)
+ 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
@@ -257,8 +289,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
|> put_view(UserView)
|> render("activity_collection_page.json", %{
activities: activities,
- pagination: ControllerHelper.get_pagination_fields(conn, activities),
- iri: "#{user.ap_id}/outbox"
+ pagination: ControllerHelper.get_pagination_fields(conn, activities)
})
end
end
@@ -404,8 +435,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
|> put_view(UserView)
|> render("activity_collection_page.json", %{
activities: activities,
- pagination: ControllerHelper.get_pagination_fields(conn, activities),
- iri: "#{user.ap_id}/inbox"
+ pagination: ControllerHelper.get_pagination_fields(conn, activities)
})
end
diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex
index 81ab354fe..c0626ce4d 100644
--- a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex
@@ -56,20 +56,24 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag])
defp fix_tag(data), do: Map.drop(data, ["tag"])
+ # legacy internal *oma format
+ defp fix_replies(%{"replies" => replies} = data) when is_list(replies), do: data
+
defp fix_replies(%{"replies" => %{"first" => %{"items" => replies}}} = data)
when is_list(replies),
do: Map.put(data, "replies", replies)
+ defp fix_replies(%{"replies" => %{"first" => %{"orderedItems" => replies}}} = data)
+ when is_list(replies),
+ do: Map.put(data, "replies", replies)
+
defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies),
do: Map.put(data, "replies", replies)
- # TODO: Pleroma does not have any support for Collections at the moment.
- # If the `replies` field is not something the ObjectID validator can handle,
- # the activity/object would be rejected, which is bad behavior.
- defp fix_replies(%{"replies" => replies} = data) when not is_list(replies),
- do: Map.drop(data, ["replies"])
+ defp fix_replies(%{"replies" => %{"orderedItems" => replies}} = data) when is_list(replies),
+ do: Map.put(data, "replies", replies)
- defp fix_replies(data), do: data
+ defp fix_replies(data), do: Map.delete(data, "replies")
def fix_attachments(%{"attachment" => attachment} = data) when is_map(attachment),
do: Map.put(data, "attachment", [attachment])
diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex
index 8db01f9e7..6e04d95e6 100644
--- a/lib/pleroma/web/activity_pub/transmogrifier.ex
+++ b/lib/pleroma/web/activity_pub/transmogrifier.ex
@@ -23,7 +23,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
@@ -762,48 +761,26 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def set_quote_url(obj), do: obj
@doc """
- Serialized Mastodon-compatible `replies` collection containing _self-replies_.
- Based on Mastodon's ActivityPub::NoteSerializer#replies.
+ Inline first page of the `replies` collection,
+ containing any replies in chronological order.
"""
- 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)
+ def set_replies(%{"type" => type} = obj_data)
+ when type in Pleroma.Constants.status_object_types() do
+ with obj_ap_id when is_binary(obj_ap_id) <- obj_data["id"],
+ limit when limit > 0 <-
+ Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
+ collection <-
+ Pleroma.Web.ActivityPub.ObjectView.render("object_replies.json", %{
+ render_params: %{object_ap_id: obj_data["id"], limit: limit, skip_ap_ctx: true}
+ }) do
+ Map.put(obj_data, "replies", collection)
+ else
+ 0 -> Map.put(obj_data, "replies", obj_data["id"] <> "/replies")
+ _ -> obj_data
+ end
end
- defp set_replies(obj, []) do
- obj
- end
-
- defp set_replies(obj, replies_uris) do
- replies_collection = %{
- "type" => "Collection",
- "items" => replies_uris
- }
-
- Map.merge(obj, %{"replies" => replies_collection})
- end
-
- def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do
- items
- end
-
- def replies(%{"replies" => %{"items" => items}}) when not is_nil(items) do
- items
- end
-
- def replies(_), do: []
+ def set_replies(obj_data), do: obj_data
# Prepares the object of an outgoing create activity.
def prepare_object(object) do
diff --git a/lib/pleroma/web/activity_pub/views/collection_view_helper.ex b/lib/pleroma/web/activity_pub/views/collection_view_helper.ex
new file mode 100644
index 000000000..ab2b34bcd
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/views/collection_view_helper.ex
@@ -0,0 +1,59 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors
+# Copyright © 2025 Akkoma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.CollectionViewHelper do
+ alias Pleroma.Web.ActivityPub.Utils
+
+ def collection_page_offset(collection, iri, page, show_items \\ true, total \\ nil) do
+ offset = (page - 1) * 10
+ items = Enum.slice(collection, offset, 10)
+ items = Enum.map(items, fn user -> user.ap_id end)
+ total = total || length(collection)
+
+ map = %{
+ "id" => "#{iri}?page=#{page}",
+ "type" => "OrderedCollectionPage",
+ "partOf" => iri,
+ "totalItems" => total,
+ "orderedItems" => if(show_items, do: items, else: [])
+ }
+
+ if offset + 10 < total do
+ Map.put(map, "next", "#{iri}?page=#{page + 1}")
+ else
+ map
+ end
+ end
+
+ defp maybe_omit_next(pagination, _items, nil), do: pagination
+
+ defp maybe_omit_next(pagination, items, limit) when is_binary(limit) do
+ case Integer.parse(limit) do
+ {limit, ""} -> maybe_omit_next(pagination, items, limit)
+ _ -> maybe_omit_next(pagination, items, nil)
+ end
+ end
+
+ defp maybe_omit_next(pagination, items, limit) when is_number(limit) do
+ if Enum.count(items) < limit, do: Map.delete(pagination, "next"), else: pagination
+ end
+
+ def collection_page_keyset(
+ display_items,
+ pagination,
+ limit \\ nil,
+ skip_ap_context \\ false
+ ) do
+ %{
+ "type" => "OrderedCollectionPage",
+ "orderedItems" => display_items
+ }
+ |> Map.merge(pagination)
+ |> maybe_omit_next(display_items, limit)
+ |> then(fn m ->
+ if skip_ap_context, do: m, else: Map.merge(m, Utils.make_json_ld_header())
+ end)
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/views/object_view.ex b/lib/pleroma/web/activity_pub/views/object_view.ex
index 857410e43..a672ccc8b 100644
--- a/lib/pleroma/web/activity_pub/views/object_view.ex
+++ b/lib/pleroma/web/activity_pub/views/object_view.ex
@@ -6,7 +6,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
use Pleroma.Web, :view
alias Pleroma.Activity
alias Pleroma.Object
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.ActivityPub.CollectionViewHelper
alias Pleroma.Web.ActivityPub.Transmogrifier
+ alias Pleroma.Web.ControllerHelper
def render("object.json", %{object: %Object{} = object}) do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(object.data)
@@ -19,4 +22,90 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
{:ok, ap_data} = Transmogrifier.prepare_outgoing(activity.data)
ap_data
end
+
+ def render("object_replies.json", %{
+ conn: conn,
+ render_params: %{object_ap_id: object_ap_id, page: "true"} = params
+ }) do
+ params = Map.put_new(params, :limit, 40)
+
+ items = ActivityPub.fetch_objects_for_replies_collection(object_ap_id, params)
+ display_items = map_reply_collection_items(items)
+
+ pagination = ControllerHelper.get_pagination_fields(conn, items, %{}, :asc)
+
+ CollectionViewHelper.collection_page_keyset(display_items, pagination, params[:limit])
+ end
+
+ def render(
+ "object_replies.json",
+ %{
+ render_params: %{object_ap_id: object_ap_id} = params
+ } = opts
+ ) do
+ params =
+ params
+ |> Map.drop([:max_id, :min_id, :since_id, :object_ap_id])
+ |> Map.put_new(:limit, 40)
+ |> Map.put(:total, true)
+
+ %{total: total, items: items} =
+ ActivityPub.fetch_objects_for_replies_collection(object_ap_id, params)
+
+ display_items = map_reply_collection_items(items)
+
+ first_pagination = reply_collection_first_pagination(items, opts)
+
+ col_ap =
+ %{
+ "id" => object_ap_id <> "/replies",
+ "type" => "OrderedCollection",
+ "totalItems" => total
+ }
+
+ col_ap =
+ if total > 0 do
+ first_page =
+ CollectionViewHelper.collection_page_keyset(
+ display_items,
+ first_pagination,
+ params[:limit],
+ true
+ )
+
+ Map.put(col_ap, "first", first_page)
+ else
+ col_ap
+ end
+
+ if params[:skip_ap_ctx] do
+ col_ap
+ else
+ Map.merge(col_ap, Pleroma.Web.ActivityPub.Utils.make_json_ld_header())
+ end
+ end
+
+ defp map_reply_collection_items(items), do: Enum.map(items, fn %{ap_id: ap_id} -> ap_id end)
+
+ defp reply_collection_first_pagination(items, %{conn: %Plug.Conn{} = conn}) do
+ pagination = ControllerHelper.get_pagination_fields(conn, items, %{"page" => true}, :asc)
+ Map.put(pagination, "id", Phoenix.Controller.current_url(conn, %{"page" => true}))
+ end
+
+ defp reply_collection_first_pagination(items, %{render_params: %{object_ap_id: object_ap_id}}) do
+ %{
+ "id" => object_ap_id <> "/replies?page=true",
+ "partOf" => object_ap_id <> "/replies"
+ }
+ |> then(fn m ->
+ case items do
+ [] ->
+ m
+
+ i ->
+ next_id = object_ap_id <> "/replies?page=true&min_id=#{List.last(i)[:id]}"
+ Map.put(m, "next", next_id)
+ end
+ end)
+ end
end
diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex
index 61975387b..4362db324 100644
--- a/lib/pleroma/web/activity_pub/views/user_view.ex
+++ b/lib/pleroma/web/activity_pub/views/user_view.ex
@@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.CollectionViewHelper
alias Pleroma.Web.ActivityPub.ObjectView
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Utils
@@ -164,7 +165,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do
0
end
- collection(following, "#{user.ap_id}/following", page, showing_items, total)
+ CollectionViewHelper.collection_page_offset(
+ following,
+ "#{user.ap_id}/following",
+ page,
+ showing_items,
+ total
+ )
|> Map.merge(Utils.make_json_ld_header())
end
@@ -189,7 +196,12 @@ defmodule Pleroma.Web.ActivityPub.UserView do
"totalItems" => total,
"first" =>
if showing_items do
- collection(following, "#{user.ap_id}/following", 1, !user.hide_follows)
+ CollectionViewHelper.collection_page_offset(
+ following,
+ "#{user.ap_id}/following",
+ 1,
+ !user.hide_follows
+ )
else
"#{user.ap_id}/following?page=1"
end
@@ -212,7 +224,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do
0
end
- collection(followers, "#{user.ap_id}/followers", page, showing_items, total)
+ CollectionViewHelper.collection_page_offset(
+ followers,
+ "#{user.ap_id}/followers",
+ page,
+ showing_items,
+ total
+ )
|> Map.merge(Utils.make_json_ld_header())
end
@@ -236,7 +254,12 @@ defmodule Pleroma.Web.ActivityPub.UserView do
"type" => "OrderedCollection",
"first" =>
if showing_items do
- collection(followers, "#{user.ap_id}/followers", 1, showing_items, total)
+ CollectionViewHelper.collection_page_offset(
+ followers,
+ "#{user.ap_id}/followers",
+ 1,
+ showing_items
+ )
else
"#{user.ap_id}/followers?page=1"
end
@@ -256,7 +279,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do
def render("activity_collection_page.json", %{
activities: activities,
- iri: iri,
pagination: pagination
}) do
collection =
@@ -265,13 +287,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do
data
end)
- %{
- "type" => "OrderedCollectionPage",
- "partOf" => iri,
- "orderedItems" => collection
- }
- |> Map.merge(Utils.make_json_ld_header())
- |> Map.merge(pagination)
+ CollectionViewHelper.collection_page_keyset(collection, pagination)
end
def render("featured.json", %{
@@ -299,27 +315,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do
Map.put(map, "totalItems", total)
end
- def collection(collection, iri, page, show_items \\ true, total \\ nil) do
- offset = (page - 1) * 10
- items = Enum.slice(collection, offset, 10)
- items = Enum.map(items, fn user -> user.ap_id end)
- total = total || length(collection)
-
- map = %{
- "id" => "#{iri}?page=#{page}",
- "type" => "OrderedCollectionPage",
- "partOf" => iri,
- "totalItems" => total,
- "orderedItems" => if(show_items, do: items, else: [])
- }
-
- if offset < total do
- Map.put(map, "next", "#{iri}?page=#{page + 1}")
- else
- map
- end
- end
-
defp maybe_make_image(func, description, key, user) do
if image = func.(user, no_default: true) do
%{
diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex
index 1caf0f7e6..b15b0ea81 100644
--- a/lib/pleroma/web/controller_helper.ex
+++ b/lib/pleroma/web/controller_helper.ex
@@ -55,7 +55,7 @@ defmodule Pleroma.Web.ControllerHelper do
# TODO: Only fetch the params from open_api_spex when everything is converted
@id_keys Pagination.page_keys() -- ["limit", "order"]
- defp build_pagination_fields(conn, min_id, max_id, extra_params) do
+ defp build_pagination_fields(conn, min_id, max_id, extra_params, order) do
params =
if Map.has_key?(conn.private, :open_api_spex) do
get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)])
@@ -66,27 +66,50 @@ defmodule Pleroma.Web.ControllerHelper do
|> Map.merge(extra_params)
|> Map.drop(@id_keys)
+ {{next_id, nid}, {prev_id, pid}} =
+ if order == :desc,
+ do: {{:max_id, max_id}, {:min_id, min_id}},
+ else: {{:min_id, min_id}, {:max_id, max_id}}
+
+ id = Phoenix.Controller.current_url(conn)
+ base_id = %{URI.parse(id) | query: nil} |> URI.to_string()
+
%{
- "next" => current_url(conn, Map.put(params, :max_id, max_id)),
- "prev" => current_url(conn, Map.put(params, :min_id, min_id)),
- "id" => current_url(conn)
+ "next" => current_url(conn, Map.put(params, next_id, nid)),
+ "prev" => current_url(conn, Map.put(params, prev_id, pid)),
+ "id" => id,
+ "partOf" => base_id
}
end
- def get_pagination_fields(conn, entries, extra_params \\ %{}) do
+ defp get_first_last_pagination_id(entries) do
case List.last(entries) do
- %{pagination_id: max_id} when not is_nil(max_id) ->
- %{pagination_id: min_id} = List.first(entries)
+ %{pagination_id: last_id} when not is_nil(last_id) ->
+ %{pagination_id: first_id} = List.first(entries)
+ {first_id, last_id}
- build_pagination_fields(conn, min_id, max_id, extra_params)
-
- %{id: max_id} ->
- %{id: min_id} = List.first(entries)
-
- build_pagination_fields(conn, min_id, max_id, extra_params)
+ %{id: last_id} ->
+ %{id: first_id} = List.first(entries)
+ {first_id, last_id}
_ ->
- %{}
+ nil
+ end
+ end
+
+ def get_pagination_fields(conn, entries, extra_params \\ %{}, order \\ :desc)
+
+ def get_pagination_fields(conn, entries, extra_params, :desc) do
+ case get_first_last_pagination_id(entries) do
+ nil -> %{}
+ {min_id, max_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :desc)
+ end
+ end
+
+ def get_pagination_fields(conn, entries, extra_params, :asc) do
+ case get_first_last_pagination_id(entries) do
+ nil -> %{}
+ {max_id, min_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :asc)
end
end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 7c1d97f63..da9626147 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -972,6 +972,7 @@ 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/:uuid/replies", ActivityPubController, :object_replies)
end
scope "/", Pleroma.Web.ActivityPub do
diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
index bd84e0001..d5947186f 100644
--- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
+++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
@@ -430,7 +430,133 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
end
end
+ describe "/objects/:uuid/replies" do
+ test "it renders the top-level collection", %{
+ conn: conn
+ } do
+ user = insert(:user)
+ note = insert(:note_activity)
+ note = Pleroma.Activity.get_by_id_with_object(note.id)
+ uuid = String.split(note.object.data["id"], "/") |> List.last()
+
+ {:ok, _} =
+ CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/activity+json")
+ |> get("/objects/#{uuid}/replies")
+
+ assert match?(
+ %{
+ "id" => _,
+ "type" => "OrderedCollection",
+ "totalItems" => 1,
+ "first" => %{
+ "id" => _,
+ "type" => "OrderedCollectionPage",
+ "orderedItems" => [_]
+ }
+ },
+ json_response(conn, 200)
+ )
+ end
+
+ test "first page id includes `?page=true`", %{conn: conn} do
+ user = insert(:user)
+ note = insert(:note_activity)
+ note = Pleroma.Activity.get_by_id_with_object(note.id)
+ uuid = String.split(note.object.data["id"], "/") |> List.last()
+
+ {:ok, _} =
+ CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/activity+json")
+ |> get("/objects/#{uuid}/replies")
+
+ %{"id" => collection_id, "first" => %{"id" => page_id, "partOf" => part_of}} =
+ json_response(conn, 200)
+
+ assert part_of == collection_id
+ assert String.contains?(page_id, "page=true")
+ end
+
+ test "unknown query params do not crash the endpoint", %{conn: conn} do
+ user = insert(:user)
+ note = insert(:note_activity)
+ note = Pleroma.Activity.get_by_id_with_object(note.id)
+ uuid = String.split(note.object.data["id"], "/") |> List.last()
+
+ {:ok, _} =
+ CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/activity+json")
+ |> get("/objects/#{uuid}/replies?unknown_param=1")
+
+ assert %{"type" => "OrderedCollection"} = json_response(conn, 200)
+ end
+
+ test "it renders a collection page", %{
+ conn: conn
+ } do
+ user = insert(:user)
+ note = insert(:note_activity)
+ note = Pleroma.Activity.get_by_id_with_object(note.id)
+ uuid = String.split(note.object.data["id"], "/") |> List.last()
+
+ {:ok, r1} =
+ CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
+
+ {:ok, r2} =
+ CommonAPI.post(user, %{status: "reply2", in_reply_to_status_id: note.id})
+
+ {:ok, _} =
+ CommonAPI.post(user, %{status: "reply3", in_reply_to_status_id: note.id})
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/activity+json")
+ |> get("/objects/#{uuid}/replies?page=true&min_id=#{r1.object.id}&limit=1")
+
+ expected_uris = [r2.object.data["id"]]
+
+ assert match?(
+ %{
+ "id" => _,
+ "type" => "OrderedCollectionPage",
+ "prev" => _,
+ "next" => _,
+ "orderedItems" => ^expected_uris
+ },
+ json_response(conn, 200)
+ )
+ end
+ end
+
describe "/activities/:uuid" do
+ test "it does not include a top-level replies collection on activities", %{conn: conn} do
+ clear_config([:activitypub, :note_replies_output_limit], 1)
+
+ activity = insert(:note_activity)
+ activity = Activity.get_by_id_with_object(activity.id)
+
+ uuid = String.split(activity.data["id"], "/") |> List.last()
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/activity+json")
+ |> get("/activities/#{uuid}")
+
+ res = json_response(conn, 200)
+
+ refute Map.has_key?(res, "replies")
+ assert get_in(res, ["object", "replies", "id"]) == activity.object.data["id"] <> "/replies"
+ end
+
test "it doesn't return a local-only activity", %{conn: conn} do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "test", visibility: "local"})
diff --git a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs
index fd7a3c772..403c98a2d 100644
--- a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs
+++ b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs
@@ -696,12 +696,19 @@ 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
+ test "still provides reply collection id even if activity doesn't have replies yet" do
data = Jason.decode!(File.read!("test/fixtures/mastodon-post-activity.json"))
- assert Transmogrifier.set_replies(data) == data
+ object = data["object"] |> Map.delete("replies")
+ modified = Transmogrifier.set_replies(object)
+
+ refute object["replies"]
+ assert modified["replies"]
+ assert match?(%{"id" => "http" <> _, "totalItems" => 0}, modified["replies"])
+ # first page should be omitted if there are no entries anyway
+ refute modified["replies"]["first"]
end
- test "sets `replies` collection with a limited number of self-replies" do
+ test "sets `replies` collection with a limited number of replies, preferring oldest" do
[user, another_user] = insert_list(2, :user)
{:ok, %{id: id1} = activity} = CommonAPI.post(user, %{status: "1"})
@@ -730,7 +737,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do
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" => "OrderedCollection", "first" => %{"orderedItems" => ^replies_uris}} =
Transmogrifier.set_replies(object.data)["replies"]
end
end
diff --git a/test/pleroma/web/activity_pub/views/object_view_test.exs b/test/pleroma/web/activity_pub/views/object_view_test.exs
index cc276b4b7..66673a9b5 100644
--- a/test/pleroma/web/activity_pub/views/object_view_test.exs
+++ b/test/pleroma/web/activity_pub/views/object_view_test.exs
@@ -49,9 +49,39 @@ defmodule Pleroma.Web.ActivityPub.ObjectViewTest do
replies_uris = [self_reply1.object.data["id"]]
result = ObjectView.render("object.json", %{object: refresh_record(activity)})
- assert %{"type" => "Collection", "items" => ^replies_uris} =
+ assert %{
+ "type" => "OrderedCollection",
+ "id" => _,
+ "first" => %{"orderedItems" => ^replies_uris}
+ } =
get_in(result, ["object", "replies"])
end
+
+ test "renders a replies collection on its own" do
+ user = insert(:user)
+ activity = insert(:note_activity, user: user)
+ activity = Pleroma.Activity.get_by_id_with_object(activity.id)
+
+ {:ok, r1} =
+ CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id})
+
+ {:ok, r2} =
+ CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id})
+
+ replies_uris = [r1.object.data["id"], r2.object.data["id"]]
+
+ result =
+ ObjectView.render("object_replies.json", %{
+ render_params: %{object_ap_id: activity.object.data["id"]}
+ })
+
+ %{
+ "type" => "OrderedCollection",
+ "id" => _,
+ "totalItems" => 2,
+ "first" => %{"orderedItems" => ^replies_uris}
+ } = result
+ end
end
test "renders a like activity" do
diff --git a/test/pleroma/web/activity_pub/views/user_view_test.exs b/test/pleroma/web/activity_pub/views/user_view_test.exs
index a32e72829..7ac5f7c0f 100644
--- a/test/pleroma/web/activity_pub/views/user_view_test.exs
+++ b/test/pleroma/web/activity_pub/views/user_view_test.exs
@@ -169,6 +169,18 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
user = Map.merge(user, %{hide_followers_count: false, hide_followers: true})
assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user})
end
+
+ test "does not hide follower items based on `hide_follows`" do
+ user = insert(:user)
+ follower = insert(:user)
+ {:ok, user, _follower, _activity} = CommonAPI.follow(user, follower)
+
+ user = Map.merge(user, %{hide_followers: false, hide_follows: true})
+ follower_ap_id = follower.ap_id
+
+ assert %{"first" => %{"orderedItems" => [^follower_ap_id]}} =
+ UserView.render("followers.json", %{user: user})
+ end
end
describe "following" do