From 3890fbc1277df31f51f705daebdb706d71f4184b Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 28 Oct 2026 11:38:49 +0400 Subject: [PATCH] feat(api): add the Mastodon quote entity to statuses Mastodon 4.4+ clients read quotes from a top-level quote object with state and quoted_status; Pleroma only had pleroma.quote, so they did not show quote posts. Quoted statuses get a shallow quote with only quoted_status_id, as quotes are rendered one level deep. Inspired by Akkoma 5b7209980, which can not use the shallow form because Akkoma already had a top-level quote status. --- changelog.d/mastodon-quote-entity.add | 1 + lib/pleroma/web/api_spec/schemas/status.ex | 23 +++++++ .../web/mastodon_api/views/status_view.ex | 25 +++++++- .../mastodon_api/views/status_view_test.exs | 60 ++++++++++++++++++- 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 changelog.d/mastodon-quote-entity.add diff --git a/changelog.d/mastodon-quote-entity.add b/changelog.d/mastodon-quote-entity.add new file mode 100644 index 000000000..dc9eccd97 --- /dev/null +++ b/changelog.d/mastodon-quote-entity.add @@ -0,0 +1 @@ +Add the Mastodon quote entity (`quote` with `state` and `quoted_status`) to statuses, so Mastodon 4.4+ clients show quote posts diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 63e9eebf0..cab019f37 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -234,6 +234,29 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do type: :integer, description: "How many statuses quoted this status." }, + quote: %Schema{ + type: :object, + nullable: true, + description: + "The status being quoted, if any (Mastodon 4.4+). Quoted statuses only have `quoted_status_id`.", + properties: %{ + state: %Schema{ + type: :string, + enum: ["accepted", "unauthorized", "deleted"], + description: "Whether the quoted status can be shown" + }, + quoted_status: %Schema{ + allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], + nullable: true, + description: "The quoted status, if it can be shown" + }, + quoted_status_id: %Schema{ + type: :string, + nullable: true, + description: "ID of the quoted status, if it can be shown" + } + } + }, reblog: %Schema{ allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], nullable: true, diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 6a5d627a5..42cbc1590 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -455,6 +455,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do language: get_language(object), emojis: build_emojis(object.data["emoji"]), quotes_count: object.data["quotesCount"] || 0, + quote: build_quote(object, quote_activity, quote_post, opts), pleroma: %{ local: activity.local, conversation_id: get_context_id(activity), @@ -691,7 +692,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do %{ancestors: ancestors, descendants: descendants} = activities |> Enum.reverse() - |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end) + |> Enum.group_by(fn %{id: id} -> + if id < activity.id, do: :ancestors, else: :descendants + end) |> Map.put_new(:ancestors, []) |> Map.put_new(:descendants, []) @@ -742,6 +745,26 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end end + # Mastodon (4.4+) quote entity. Quoted statuses get a shallow quote that only + # has the id, as quotes are only rendered one level deep. + defp build_quote(%Object{data: %{"quoteUrl" => url}}, quote_activity, quote_post, opts) + when is_binary(url) and url != "" do + state = + cond do + is_nil(quote_activity) -> "deleted" + visible_for_user?(quote_activity, opts[:for]) -> "accepted" + true -> "unauthorized" + end + + if opts[:show_quote] == false do + %{state: state, quoted_status_id: if(state == "accepted", do: to_string(quote_activity.id))} + else + %{state: state, quoted_status: if(state == "accepted", do: quote_post)} + end + end + + defp build_quote(_object, _quote_activity, _quote_post, _opts), do: nil + def get_quote(activity, %{quoted_activities: quoted_activities}) do object = Object.normalize(activity, fetch: false) diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 763eb1599..6d1bda193 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -345,7 +345,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do bookmark_folder: nil, list_id: nil }, - quotes_count: 0 + quotes_count: 0, + quote: nil } assert status == expected @@ -507,6 +508,63 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do refute status.pleroma.quote_visible end + describe "Mastodon quote entity" do + test "a quote has the quoted status, and quoted statuses only a shallow quote" do + post = insert(:note_activity) + user = insert(:user) + + {:ok, quote_post} = CommonAPI.post(user, %{status: "he", quoted_status_id: post.id}) + + {:ok, quoted_quote_post} = + CommonAPI.post(user, %{status: "yo", quoted_status_id: quote_post.id}) + + status = StatusView.render("show.json", %{activity: quoted_quote_post}) + + assert %{state: "accepted", quoted_status: %{id: quoted_id} = quoted} = status.quote + assert quoted_id == to_string(quote_post.id) + assert quoted.quote == %{state: "accepted", quoted_status_id: to_string(post.id)} + + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + end + + test "a quote of a post the user cannot see is unauthorized" do + user = insert(:user) + private = insert(:followers_only_note_activity, user: user) + + quote_private = + insert(:note_activity, + note: insert(:note, data: %{"quoteUrl" => Object.normalize(private).data["id"]}) + ) + + status = StatusView.render("show.json", %{activity: quote_private}) + assert status.quote == %{state: "unauthorized", quoted_status: nil} + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + + follower = insert(:user) + CommonAPI.follow(user, follower) + + status = StatusView.render("show.json", %{activity: quote_private, for: follower}) + assert %{state: "accepted", quoted_status: %{id: id}} = status.quote + assert id == to_string(private.id) + end + + test "a quote of a missing post is deleted" do + quote_missing = + insert(:note_activity, + note: insert(:note, data: %{"quoteUrl" => "https://example.com/objects/gone"}) + ) + + status = StatusView.render("show.json", %{activity: quote_missing}) + assert status.quote == %{state: "deleted", quoted_status: nil} + end + + test "a post without a quote has no quote" do + status = StatusView.render("show.json", %{activity: insert(:note_activity)}) + assert status.quote == nil + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + end + end + test "repost of quote post" do post = insert(:note_activity) user = insert(:user)