Merge pull request 'Treat known quotes and replies as such even if parent unavailable' (#991) from Oneric/akkoma:replies-unknown into develop

Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/991
This commit is contained in:
Oneric 2025-10-13 12:24:27 +00:00
commit 300302d432
4 changed files with 82 additions and 10 deletions

View file

@ -5,6 +5,18 @@ 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
### REMOVED
### Added
- status responses include two new fields for ActivityPub cross-referencing: `akkoma.quote_apid` and `akkoma.in_reply_to_apid`
### Fixed
- replies and quotes to unresolvable posts now fill out IDs for replied to
status, user or quoted status with a 404-ing ID to make them recognisable as
replies/quotes instead of pretending theyre root posts
### Changed
## 2025.10

View file

@ -250,6 +250,16 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
}
}
]
},
in_reply_to_apid: %Schema{
type: :string,
nullable: true,
description: "The AcitivityPub ID this post is replying to, if it is a reply."
},
quote_apid: %Schema{
type: :string,
nullable: true,
description: "The AcitivityPub ID this post is quoting, if it is a quote."
}
}
},

View file

@ -26,6 +26,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
# Used as a placeholder to represent known-existing relatives we do cannot resolve locally
# will always 404 when supplied to API endpoints
@ghost_flake_id "_"
defp fetch_rich_media_for_activities(activities) do
Enum.each(activities, fn activity ->
Card.get_by_activity(activity)
@ -277,9 +281,12 @@ def render("show.json", %{activity: %{id: id, data: %{"object" => _object}} = ac
nil
end
reply_to = get_reply_to(activity, opts)
reply_to_apid = get_single_apid(object.data, "inReplyTo")
reply_to = reply_to_apid && get_reply_to(activity, opts)
reply_to_id = reply_to_apid && get_id_or_ghost(reply_to)
reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
reply_to_user_id = reply_to_apid && get_id_or_ghost(reply_to_user)
history_len =
1 +
@ -363,7 +370,10 @@ def render("show.json", %{activity: %{id: id, data: %{"object" => _object}} = ac
{pinned?, pinned_at} = pin_data(object, user)
quote = Activity.get_quoted_activity_from_object(object)
quote_apid = get_single_apid(object.data, "quoteUri")
quote = quote_apid && Activity.get_quoted_activity_from_object(object)
quote_id = quote_apid && get_id_or_ghost(quote)
lang = language(object)
%{
@ -375,8 +385,8 @@ def render("show.json", %{activity: %{id: id, data: %{"object" => _object}} = ac
user: user,
for: opts[:for]
}),
in_reply_to_id: reply_to && to_string(reply_to.id),
in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
in_reply_to_id: reply_to_id,
in_reply_to_account_id: reply_to_user_id,
reblog: nil,
card: card,
content: content_html,
@ -401,7 +411,7 @@ def render("show.json", %{activity: %{id: id, data: %{"object" => _object}} = ac
application: build_application(object.data["generator"]),
language: lang,
emojis: build_emojis(object.data["emoji"]),
quote_id: if(quote, do: quote.id, else: nil),
quote_id: quote_id,
quote: maybe_render_quote(quote, opts),
emoji_reactions: emoji_reactions,
pleroma: %{
@ -419,7 +429,12 @@ def render("show.json", %{activity: %{id: id, data: %{"object" => _object}} = ac
pinned_at: pinned_at
},
akkoma: %{
source: object.data["source"]
source: object.data["source"],
# Note: these AP IDs will also be filled out if we cannot resolve the actual object
# (e.g. because its a private post we aren't allowed to access, or just federation woes)
# allowing users to potentially discover the full context from other accounts/servers.
in_reply_to_apid: reply_to_apid,
quote_apid: quote_apid
}
}
else
@ -637,6 +652,26 @@ defp proxied_url(url, page_url_data) do
end
end
defp get_id_or_ghost(object) do
(object && to_string(object.id)) || @ghost_flake_id
end
defp get_single_apid(object, key) do
apid = object[key]
apid =
case apid do
[head | _] -> head
_ -> apid
end
if apid != "" and is_binary(apid) do
apid
else
nil
end
end
def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
object = Object.normalize(activity, fetch: false)

View file

@ -326,7 +326,9 @@ test "a note activity" do
pinned_at: nil
},
akkoma: %{
source: HTML.filter_tags(object_data["content"])
source: HTML.filter_tags(object_data["content"]),
in_reply_to_apid: nil,
quote_apid: nil
},
quote_id: nil,
quote: nil
@ -417,6 +419,17 @@ test "a reply" do
assert status.in_reply_to_id == to_string(note.id)
end
test "a reply to an unavailable post" do
note = insert(:note, data: %{"inReplyTo" => "https://example.org/404"})
activity = insert(:note_activity, note: note)
status = StatusView.render("show.json", %{activity: activity})
assert status.in_reply_to_id == "_"
assert status.in_reply_to_account_id == "_"
assert status.akkoma.in_reply_to_apid == "https://example.org/404"
end
test "a quote" do
note = insert(:note_activity)
user = insert(:user)
@ -433,12 +446,14 @@ test "a quote" do
end
test "a quote that we can't resolve" do
note = insert(:note_activity, quoteUri: "oopsie")
note = insert(:note, data: %{"quoteUri" => "oopsie"})
activity = insert(:note_activity, note: note)
status = StatusView.render("show.json", %{activity: note})
status = StatusView.render("show.json", %{activity: activity})
assert is_nil(status.quote_id)
assert is_nil(status.quote)
assert status.quote_id == "_"
assert status.akkoma.quote_apid == "oopsie"
end
test "a quote from a user we block" do