Diskussionsketten: jüngste Antwort im Teilbaum an Feed- und Antwortkarten

Jede Karte in Feed und Antwortliste zeigt die Antwort, die ihre Diskussion
zuletzt fortgesetzt hat: [Beitrag] -- "k weitere Beiträge" -- [Antwort].
Gelöschte Beiträge werden als jüngste übersprungen, zählen auf dem Pfad mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-27 00:52:17 +02:00
co-authored by Claude Opus 5.5
parent 8e79e94bbb
commit ade853f560
7 changed files with 196 additions and 5 deletions
+92 -1
View File
@@ -35,6 +35,16 @@ type Entry struct {
LastBump int64 `json:"last_bump"`
Username string `json:"username"`
Avatar string `json:"avatar"`
// Latest ist nur in Feed und Antwortliste gesetzt, siehe attachLatest.
Latest *Latest `json:"latest,omitempty"`
}
// Latest ist der jüngste (nicht gelöschte) Beitrag im Teilbaum unter einem
// Beitrag -- die Antwort, die die Diskussion zuletzt fortgesetzt hat. Skipped
// zählt die Beiträge, die auf dem Pfad dazwischen liegen (0 = direkte Antwort).
type Latest struct {
Entry Entry `json:"entry"`
Skipped int64 `json:"skipped"`
}
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
@@ -124,12 +134,90 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
return scanEntries(rows)
}
// attachLatest hängt an jeden Beitrag den jüngsten nicht gelöschten Beitrag
// aus seinem Teilbaum (Latest). So bildet eine flache Liste auch tiefe
// Diskussionen ab: [Beitrag] -- "k weitere" -- [jüngste Antwort], ein Klick
// führt ans aktuelle Ende.
//
// Ein Query für alle Beiträge: die rekursive CTE steigt von allen zugleich
// ab, top merkt sich den Ausgangsbeitrag, depth die Tiefe darunter. Das Limit
// deckelt die Tiefe wie bei der Ahnenkette (threadData).
func attachLatest(entries []Entry) error {
if len(entries) == 0 {
return nil
}
pids := make([]int64, len(entries))
for i, e := range entries {
pids[i] = e.PID
}
rows, err := db.Query(
`WITH RECURSIVE sub(top, pid, depth) AS (
SELECT e.reply_to, e.pid, 1 FROM entry e WHERE e.reply_to = ANY($1)
UNION ALL
SELECT s.top, e.pid, s.depth + 1
FROM entry e JOIN sub s ON e.reply_to = s.pid
WHERE s.depth < 50
)
SELECT DISTINCT ON (s.top) s.top, s.pid, s.depth
FROM sub s JOIN entry x ON x.pid = s.pid
WHERE x.deleted = 0
ORDER BY s.top, x.created_at DESC, x.id DESC`, pids)
if err != nil {
return err
}
type hit struct{ pid, depth int64 }
hits := map[int64]hit{}
latestPIDs := []int64{}
for rows.Next() {
var top int64
var h hit
if err := rows.Scan(&top, &h.pid, &h.depth); err != nil {
rows.Close()
return err
}
hits[top] = h
latestPIDs = append(latestPIDs, h.pid)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(latestPIDs) == 0 {
return nil
}
rows, err = db.Query(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
if err != nil {
return err
}
latest, err := scanEntries(rows)
if err != nil {
return err
}
byPID := make(map[int64]Entry, len(latest))
for _, e := range latest {
byPID[e.PID] = e
}
for i := range entries {
if h, ok := hits[entries[i].PID]; ok {
if e, ok := byPID[h.pid]; ok {
entries[i].Latest = &Latest{Entry: e, Skipped: h.depth - 1}
}
}
}
return nil
}
func handleFeed(w http.ResponseWriter, r *http.Request) error {
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
entries, err := feedPage(page, 0, true)
if err != nil {
return Internal(err)
}
if err := attachLatest(entries); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, entries)
return nil
}
@@ -166,7 +254,7 @@ func pidParam(r *http.Request) (int64, error) {
}
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
// direkten Antworten (öffentlich).
// direkten Antworten samt deren jüngstem Nachfahren (öffentlich).
func handleThread(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
@@ -232,6 +320,9 @@ func threadData(pid int64) (thread, error) {
if t.Replies, err = scanEntries(rows); err != nil {
return thread{}, Internal(err)
}
if err := attachLatest(t.Replies); err != nil {
return thread{}, Internal(err)
}
return t, nil
}