Skip to content

Commit 59fc2ae

Browse files
authored
Der Permalink zeigt den Beitrag, den eine Antwort beantwortet (#1991)
Eine Antwort auf einen Beitrag aus einem anderen Netzwerk stand auf ihrer eigenen Seite allein, über einer Zeile "Antwort an @user@host", die zum Konto führte und nicht zu dem, was beantwortet wurde. Der Feed zeichnet diese Karte seit dem 01.09. darüber; hier fehlte sie, ausgerechnet auf der Seite, auf der ein geteilter Link landet. Die Montage einer Konversation stand dafür zweimal im Renderer, weshalb die vierte Stufe hier schlicht vergessen war: sie steht jetzt einmal. Die Sichtbarkeit wird dabei gegen den Leser geprüft, nicht gegen den Antwortenden, denn die Seite ist öffentlich: abgemeldet nur ein wirklich öffentlicher Beitrag innerhalb der Aufbewahrungsgrenze, angemeldet der eigene akzeptierte Follow. Weil die HTML-Seite jetzt Text zeigt, tragen ihn die .md/.txt/.json/.xml-Geschwister mit, wie es die Antwort von draußen seit #1069 schon tut. Diesen Text hat ein KI-Agent in meinem Namen geschrieben. Ich weiß, dass das problematisch ist. Claude-Session: https://claude.ai/code/session_01LcukwaT7DsFqp6GPB7YjdY
1 parent c0d9998 commit 59fc2ae

9 files changed

Lines changed: 440 additions & 51 deletions

File tree

lib/vutuv/fediverse.ex

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7820,6 +7820,40 @@ defmodule Vutuv.Fediverse do
78207820
do: post.id
78217821
end
78227822

7823+
@doc """
7824+
Whether a page open to **everyone** may show this cached post — the arm
7825+
`remote_post_readable?/2` deliberately does not have, because it answers for a
7826+
signed-in party and a public page has readers who are nobody.
7827+
7828+
Two halves, and both are already spelled in SQL by the two viewer-independent
7829+
public readers here (`Vutuv.Tags.Timeline.remote_posts_query/1` and
7830+
`recent_public_remote_posts/1`): outright `public` — never `unlisted`, whose
7831+
whole meaning is "keep me off discovery surfaces" — and still inside the
7832+
retention ceiling, which is the claim to hold somebody else's post at all, so
7833+
a row the sweep has not reached yet is not one to keep publishing.
7834+
"""
7835+
def publicly_readable_remote_post?(%RemotePost{expires_at: %DateTime{} = expires} = post),
7836+
do: RemotePost.public?(post) and DateTime.after?(expires, DateTime.utc_now(:second))
7837+
7838+
def publicly_readable_remote_post?(_post), do: false
7839+
7840+
@doc """
7841+
The same for a page of them, and the function a **public surface** asks
7842+
instead of `readable_remote_post_ids/2`: an anonymous reader gets
7843+
`publicly_readable_remote_post?/1`, a signed-in one the ordinary answer,
7844+
their own accepted follow included. One call, so a caller serving both never
7845+
has to hold the difference itself.
7846+
"""
7847+
def publicly_readable_remote_post_ids(posts, nil) do
7848+
for post <- posts,
7849+
publicly_readable_remote_post?(post),
7850+
into: MapSet.new(),
7851+
do: post.id
7852+
end
7853+
7854+
def publicly_readable_remote_post_ids(posts, party),
7855+
do: readable_remote_post_ids(posts, party)
7856+
78237857
# Claims one slot from the member's hourly like budget. `:ok`, or
78247858
# `{:error, :like_capped}`.
78257859
#

lib/vutuv/fediverse/remote_post.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,16 @@ defmodule Vutuv.Fediverse.RemotePost do
193193
"""
194194
def open?(%__MODULE__{audience: audience}), do: audience in @open_audiences
195195

196+
@doc """
197+
Whether the author addressed it to the public collection outright — stricter
198+
than `open?/1`, which also takes `unlisted` in. The twin of
199+
`Vutuv.Fediverse.Note.public?/1`, and the column half of what a page open to
200+
everyone may show (`Vutuv.Fediverse.publicly_readable_remote_post?/1` adds the
201+
retention half).
202+
"""
203+
def public?(%__MODULE__{audience: "public"}), do: true
204+
def public?(%__MODULE__{}), do: false
205+
196206
@doc """
197207
Whether the author put the post behind a content warning, or marked it
198208
sensitive. Either one closes the lid: the card shows the warning (or a plain

lib/vutuv/posts.ex

Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3713,9 +3713,8 @@ defmodule Vutuv.Posts do
37133713
# answer with nothing to read above it used to be.
37143714
#
37153715
# It rides `remote_reply_ref`, already preloaded with its account
3716-
# (`post_preloads/0`), so this costs no lookup — only the same three batch
3717-
# reads a remote card needs anywhere (its pictures, the reader's marks, the
3718-
# reader's follow), run once for the page. `taken` keeps a post that already
3716+
# (`post_preloads/0`), so finding one costs no lookup; what it costs beyond
3717+
# that is `decorate_remote_parents/2` below. `taken` keeps a post that already
37193718
# has a card **further up the feed** from getting a second one; a post whose
37203719
# own row is on *this* page is not in it — that row steps aside instead, see
37213720
# `settle_remote_cards/5`.
@@ -3727,42 +3726,81 @@ defmodule Vutuv.Posts do
37273726
Vutuv.Fediverse.subject_key(parent) not in taken,
37283727
do: %{post_id: post.id, remote_post: parent}
37293728

3730-
case Enum.uniq_by(parents, &parent_key/1) do
3729+
case decorate_remote_parents(parents, viewer) do
3730+
empty when empty == %{} -> entries
3731+
by_post -> Enum.map(entries, &claim_remote_parents(&1, by_post))
3732+
end
3733+
end
3734+
3735+
@doc """
3736+
The cached post each of these posts answers (issue #1165), as the card the
3737+
renderer draws above it: `%{post_id => card}`, and `%{}` where none of them
3738+
answers anything out there.
3739+
3740+
What `attach_remote_parents/3` builds for the feed, for a caller holding a
3741+
plain list of posts instead of feed entries — the permalink's conversation
3742+
(`VutuvWeb.PostLive.Thread`), which is the page a link to such an answer
3743+
lands on and was the one place that showed the answer without what it
3744+
answers.
3745+
"""
3746+
def remote_parents(posts, viewer) do
3747+
parents =
3748+
for post <- posts,
3749+
%RemotePost{} = parent <- [remote_parent(post)],
3750+
do: %{post_id: post.id, remote_post: parent}
3751+
3752+
decorate_remote_parents(parents, viewer)
3753+
end
3754+
3755+
# The shared tail of both callers: one card per cached post, only the ones
3756+
# this reader may read, and the batch reads a remote card needs, run once for
3757+
# the page rather than once per card. Nothing to draw means no read at all.
3758+
#
3759+
# **One card per cached post per page**, the rule `dedupe_remote/1`,
3760+
# `collapse_reposts/1` and `attach_thread_notes/3` already hold for the other
3761+
# kinds — and here it is not about repetition. The card's action bar is a
3762+
# LiveComponent keyed by the cached post
3763+
# (`RemoteActionsComponent.dom_id(:remote_post, id)`), so a second card emits
3764+
# a duplicate LiveView id, which raises inside `render_pending_components/6`
3765+
# during the **static** render: the page 500s rather than degrading. Two
3766+
# members answering the same post out there is ordinary behaviour, and the
3767+
# first such pair in the database took /feed down for every reader who had
3768+
# both answers on one page (2026-08-28). So the deduped list decides where
3769+
# the card renders as well as which reads run: the answers that do not claim
3770+
# it keep their "Replying to …" line, which is what this feature replaced and
3771+
# still beats an error page.
3772+
defp decorate_remote_parents(parents, viewer) do
3773+
case parents |> Enum.uniq_by(&parent_key/1) |> readable_parents(viewer) do
37313774
[] ->
3732-
entries
3775+
%{}
37333776

37343777
unique ->
3735-
# **One card per cached post per page**, the rule `dedupe_remote/1`,
3736-
# `collapse_reposts/1` and `attach_thread_notes/3` already hold for the
3737-
# other kinds — and here it is not about repetition. The card's action
3738-
# bar is a LiveComponent keyed by the cached post
3739-
# (`RemoteActionsComponent.dom_id(:remote_post, id)`), so a second card
3740-
# emits a duplicate LiveView id, which raises inside
3741-
# `render_pending_components/6` during the **static** render: the page
3742-
# 500s rather than degrading. Two members answering the same post out
3743-
# there is ordinary behaviour, and the first such pair in the database
3744-
# took /feed down for every reader who had both answers on one page
3745-
# (2026-08-28).
3746-
#
3747-
# `unique` therefore decides where the card renders as well as which
3748-
# batch reads run, and the placement is built from the decorated list
3749-
# itself — the older version kept a second list to re-expand from, and
3750-
# a comment saying the repeats "must not pay for the batch reads, not
3751-
# the places they render" is exactly how the trap was rationalised.
3752-
# The answers that do not claim it keep their "Replying to …" line,
3753-
# which is what this feature replaced and still beats an error page.
3754-
by_post =
3755-
unique
3756-
|> attach_remote_images()
3757-
|> attach_remote_quotes()
3758-
|> attach_remote_follows(viewer)
3759-
|> attach_remote_likes(viewer)
3760-
|> Map.new(&{&1.post_id, &1})
3761-
3762-
Enum.map(entries, &claim_remote_parents(&1, by_post))
3778+
unique
3779+
|> attach_remote_images()
3780+
|> attach_remote_quotes()
3781+
|> attach_remote_follows(viewer)
3782+
|> attach_remote_likes(viewer)
3783+
|> Map.new(&{&1.post_id, &1})
37633784
end
37643785
end
37653786

3787+
# Whose audience lets *this* reader see it — never the audience the answer's
3788+
# author had. Answering a followers-only post in public is refused
3789+
# (`Fediverse.check_remote_post_reply/2`), but an audience narrows *after* the
3790+
# answer often enough that the composer re-reads the row for exactly that, and
3791+
# a card drawn above a public answer would then hand the post to every reader
3792+
# of that answer. The permalink is a public page, so the question is
3793+
# `Fediverse.publicly_readable_remote_post_ids/2` rather than the signed-in
3794+
# one, and an open parent — nearly all of them — costs no query either way.
3795+
defp readable_parents(parents, viewer) do
3796+
readable =
3797+
parents
3798+
|> Enum.map(& &1.remote_post)
3799+
|> Vutuv.Fediverse.publicly_readable_remote_post_ids(viewer)
3800+
3801+
Enum.filter(parents, &MapSet.member?(readable, &1.remote_post.id))
3802+
end
3803+
37663804
defp parent_key(%{remote_post: post}), do: Vutuv.Fediverse.subject_key(post)
37673805

37683806
defp claim_remote_parents(entry, by_post) do

lib/vutuv_web/agent_docs/markdown.ex

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,11 +1180,27 @@ defmodule VutuvWeb.AgentDocs.Markdown do
11801180
defp in_reply_to_line(%{url: nil, author: author}),
11811181
do: "> " <> gettext("In reply to a deleted post by %{name}.", name: author)
11821182

1183-
defp in_reply_to_line(%{url: url, author: author}) do
1183+
defp in_reply_to_line(%{url: url, author: author} = ref) do
11841184
author_link = md_link(author, url)
1185-
"> " <> gettext("In reply to a post by %{name}.", name: author_link)
1185+
"> " <> gettext("In reply to a post by %{name}.", name: author_link) <> answered_words(ref)
11861186
end
11871187

1188+
# The words of the post answered, when the document carries them — an answer
1189+
# to another network (issue #1165), whose HTML page draws that post as a card
1190+
# above the answer. Inside the same blockquote, so it cannot be read as this
1191+
# post's own body, and escaped as text like every other stranger's writing
1192+
# here: it must not be able to mint links in our document.
1193+
defp answered_words(%{content_warning: warning} = ref) when is_binary(warning) do
1194+
"\n>\n> " <> gettext("Content warning") <> ": " <> md_text(warning) <> answered_text(ref)
1195+
end
1196+
1197+
defp answered_words(ref), do: answered_text(ref)
1198+
1199+
defp answered_text(%{text: text}) when is_binary(text) and text != "",
1200+
do: "\n>\n" <> Enum.map_join(String.split(text, "\n"), "\n", &("> " <> md_text(&1)))
1201+
1202+
defp answered_text(_ref), do: ""
1203+
11881204
defp tags_line([]), do: nil
11891205
defp tags_line(tags), do: "Tags: " <> Enum.map_join(tags, ", ", &"##{&1}")
11901206

lib/vutuv_web/agent_docs/post_doc.ex

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -670,9 +670,27 @@ defmodule VutuvWeb.AgentDocs.PostDoc do
670670
end
671671
end
672672

673+
# `text` is the same words the HTML page draws above the answer as a card, so
674+
# it is here under the same gate: our copy of the post, and only while a page
675+
# open to everyone may show it (`Fediverse.publicly_readable_remote_post?/1`).
676+
# Without it the `.md` sibling of an answer would name the post and withhold
677+
# what it says while the page beside it prints both — and the reply written
678+
# from out there, four fields further down, has carried its text since #1069.
679+
# The handle and the origin URI come off the sidecar either way: they outlive
680+
# our copy, which is the whole reason that row keeps them.
673681
defp remote_in_reply_to(%{remote_reply_ref: %PostRemoteReply{} = ref})
674-
when is_binary(ref.handle),
675-
do: %{url: ref.in_reply_to_uri, author: ref.handle, network: "fediverse"}
682+
when is_binary(ref.handle) do
683+
%{url: ref.in_reply_to_uri, author: ref.handle, network: "fediverse"}
684+
|> Map.merge(remote_parent_words(ref.remote_post))
685+
end
676686

677687
defp remote_in_reply_to(_post), do: nil
688+
689+
defp remote_parent_words(%RemotePost{} = parent) do
690+
if Fediverse.publicly_readable_remote_post?(parent),
691+
do: %{text: parent.content_text, content_warning: parent.summary},
692+
else: %{}
693+
end
694+
695+
defp remote_parent_words(_parent), do: %{}
678696
end

lib/vutuv_web/agent_docs/text.ex

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -816,8 +816,22 @@ defmodule VutuvWeb.AgentDocs.Text do
816816
defp in_reply_to_line(%{url: nil, author: author}),
817817
do: gettext("In reply to a deleted post by %{name}.", name: author)
818818

819-
defp in_reply_to_line(%{url: url, author: author}),
820-
do: gettext("In reply to a post by %{name}.", name: author) <> " #{url}"
819+
defp in_reply_to_line(%{url: url, author: author} = ref),
820+
do:
821+
gettext("In reply to a post by %{name}.", name: author) <> " #{url}" <> answered_words(ref)
822+
823+
# The words of the post answered, when the document carries them (issue
824+
# #1165) — indented under the line naming its author, the way a reply from
825+
# another network is indented under its own.
826+
defp answered_words(%{content_warning: warning} = ref) when is_binary(warning),
827+
do: "\n " <> gettext("Content warning") <> ": #{warning}" <> answered_text(ref)
828+
829+
defp answered_words(ref), do: answered_text(ref)
830+
831+
defp answered_text(%{text: text}) when is_binary(text) and text != "",
832+
do: "\n" <> indent_block(text, " ")
833+
834+
defp answered_text(_ref), do: ""
821835

822836
defp tags_line([]), do: nil
823837
defp tags_line(tags), do: "Tags: " <> Enum.join(tags, ", ")

lib/vutuv_web/components/post_components.ex

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,18 @@ defmodule VutuvWeb.PostComponents do
965965
entry_id: assigns.entry_id
966966
}
967967

968-
(ancestors ++ [leaf])
968+
assemble_conversation(ancestors ++ [leaf], assigns)
969+
end
970+
971+
# The assembly order of a conversation, for both surfaces that build one: the
972+
# reply tree, the "Replying to" banner on whatever ended up a root, the
973+
# replies from other networks woven in among the siblings, and the cached post
974+
# an answer answers hung above it. Four stages that have to be remembered
975+
# together, which is why they are written once — the permalink was missing the
976+
# last of them for exactly as long as the two callers each spelled the list
977+
# out for themselves.
978+
defp assemble_conversation(cards, assigns) do
979+
cards
969980
|> Posts.thread_forest()
970981
|> banner_on_roots()
971982
|> weave_remote_replies(
@@ -3292,6 +3303,13 @@ defmodule VutuvWeb.PostComponents do
32923303
"one card, never the replies around it"
32933304
)
32943305

3306+
attr(:remote_parents, :map,
3307+
default: %{},
3308+
doc:
3309+
"`%{post_id => card}` (`Vutuv.Posts.remote_parents/2`) for a post here " <>
3310+
"that answers one out there (issue #1165) — drawn above it, the way the feed draws it"
3311+
)
3312+
32953313
attr(:conn_or_socket, :any, required: true)
32963314
attr(:translations, :map, default: nil)
32973315

@@ -3329,6 +3347,7 @@ defmodule VutuvWeb.PostComponents do
33293347
attr(:remote_replies, :map, default: %{})
33303348
attr(:note_marks, :any, default: nil)
33313349
attr(:likers, :any, default: nil)
3350+
attr(:remote_parents, :map, default: %{}, doc: "as on `thread_conversation/1`")
33323351
attr(:conn_or_socket, :any, required: true)
33333352
attr(:translations, :map, default: nil)
33343353

@@ -3407,6 +3426,12 @@ defmodule VutuvWeb.PostComponents do
34073426
# is the first card on the page, the one case where the focused post has no
34083427
# context above it and the page must not jump on arrival.
34093428
defp conversation_nodes(posts, top_id, assigns) do
3429+
# A post with the card it answers drawn above it is no longer the page's
3430+
# first card, so the focus scrolls into view like any other post with
3431+
# context over it. Decided once, here, rather than as a second condition
3432+
# every card then evaluates.
3433+
top_id = if is_map_key(assigns.remote_parents, top_id), do: nil, else: top_id
3434+
34103435
posts
34113436
|> Enum.map(fn post ->
34123437
focus? = post.id == assigns.focus_id
@@ -3427,13 +3452,7 @@ defmodule VutuvWeb.PostComponents do
34273452
scroll?: assigns.auto_scroll? and focus? and post.id != top_id
34283453
}
34293454
end)
3430-
|> Posts.thread_forest()
3431-
|> banner_on_roots()
3432-
|> weave_remote_replies(
3433-
assigns[:remote_replies] || %{},
3434-
assigns.viewer,
3435-
assigns[:note_marks] || fn _note -> nil end
3436-
)
3455+
|> assemble_conversation(assigns)
34373456
end
34383457

34393458
# Hangs the replies written on other networks (issue #1069) under the posts

0 commit comments

Comments
 (0)