From ade853f560a7e9cd0948ef19c63159adf089308c Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sun, 27 Sep 2026 00:52:17 +0200 Subject: [PATCH] =?UTF-8?q?Diskussionsketten:=20j=C3=BCngste=20Antwort=20i?= =?UTF-8?q?m=20Teilbaum=20an=20Feed-=20und=20Antwortkarten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q --- endpoints_test.go | 62 ++++++++++++++++++ entry.go | 93 ++++++++++++++++++++++++++- frontend/src/app.css | 10 +++ frontend/src/components/EntryCard.tsx | 25 +++++++ frontend/src/components/Feed.tsx | 4 +- frontend/src/pages/EntryPage.tsx | 4 +- frontend/src/types.ts | 3 + 7 files changed, 196 insertions(+), 5 deletions(-) diff --git a/endpoints_test.go b/endpoints_test.go index 3764dba..ec42032 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -314,6 +314,10 @@ type threadEntry struct { Deleted int64 `json:"deleted"` Content string `json:"content"` Username string `json:"username"` + Latest *struct { + Entry threadEntry `json:"entry"` + Skipped int64 `json:"skipped"` + } `json:"latest"` } // threadJSON ist die Antwortform von /entry/{pid}/thread. Nicht zu verwechseln @@ -438,6 +442,64 @@ func TestEditEntry(t *testing.T) { } } +// latestOf beschreibt Latest eines Beitrags knapp für Vergleiche und +// Fehlermeldungen: "pid/skipped" oder "-". +func latestOf(e threadEntry) string { + if e.Latest == nil { + return "-" + } + return fmt.Sprintf("%d/%d", e.Latest.Entry.PID, e.Latest.Skipped) +} + +func TestLatestDescendant(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + + // root ── a ── b ── c + // └─ d (d vor b und c erstellt) + root := createEntry(t, alice, srv, "wurzel") + a := createReply(t, alice, srv, "a", root) + d := createReply(t, alice, srv, "d", root) + b := createReply(t, alice, srv, "b", a) + c := createReply(t, alice, srv, "c", b) + + feed := func() []threadEntry { + resp, err := http.Get(srv.URL + "/api/entry/feed/0") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var f []threadEntry + json.NewDecoder(resp.Body).Decode(&f) + return f + } + + // Feed: Root mit c, dazwischen a und b. + if f := feed(); len(f) != 1 || latestOf(f[0]) != fmt.Sprintf("%d/2", c) { + t.Fatalf("feed latest: erwartet %d/2, bekam %s", c, latestOf(f[0])) + } + + // Antworten auf Root: a mit c (b dazwischen), d ohne Nachfahren. + want := map[int64]string{a: fmt.Sprintf("%d/1", c), d: "-"} + for _, r := range getThread(t, srv, root).Replies { + if got := latestOf(r); got != want[r.PID] { + t.Fatalf("reply %d latest: erwartet %s, bekam %s", r.PID, want[r.PID], got) + } + } + + // Direkte Antwort: skipped 0. + if r := getThread(t, srv, a).Replies; len(r) != 1 || latestOf(r[0]) != fmt.Sprintf("%d/0", c) { + t.Fatalf("b latest: erwartet %d/0, bekam %+v", c, r) + } + + // Gelöschter jüngster Beitrag wird übersprungen -> nächstjüngerer (b). + resp := postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, c), url.Values{}) + resp.Body.Close() + if f := feed(); latestOf(f[0]) != fmt.Sprintf("%d/1", b) { + t.Fatalf("feed latest nach delete: erwartet %d/1, bekam %s", b, latestOf(f[0])) + } +} + func TestSoftDelete(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") diff --git a/entry.go b/entry.go index 4856eb8..a6f640f 100644 --- a/entry.go +++ b/entry.go @@ -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 } diff --git a/frontend/src/app.css b/frontend/src/app.css index 8f661cb..a604ddf 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -271,6 +271,16 @@ img.avatar-lg { width: 72px; height: 72px; } /* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */ #ancestors .threadline { border-right: var(--line-w) solid gray; } +/* Diskussionskette in Feed und Antworten (Discussion): Beitrag, ggf. + "k weitere Beiträge", jüngste Antwort. Gleiches Bild wie die Ahnenkette -- + Strich an allen Gliedern außer dem letzten. */ +.chain > :not(:last-child) .threadline { + display: block; + flex: 0 0 calc(var(--avatar-sm) / 2 - var(--line-w)); + margin-right: calc(var(--avatar-sm) / 2); + border-right: var(--line-w) solid gray; +} + /* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown wandert in die Byline (siehe setupBump). */ diff --git a/frontend/src/components/EntryCard.tsx b/frontend/src/components/EntryCard.tsx index 42dbfd1..d65ef2d 100644 --- a/frontend/src/components/EntryCard.tsx +++ b/frontend/src/components/EntryCard.tsx @@ -65,3 +65,28 @@ function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: ); } + +// Discussion zeigt einen Beitrag mit der Antwort, die seine Diskussion zuletzt +// fortgesetzt hat: [Beitrag] ── "k weitere Beiträge" ── [jüngste Antwort]. +// Der Strich verbindet die Glieder wie in der Ahnenkette; "k weitere" führt +// wie die Antwort selbst zu deren Seite, wo die ganze Kette darüber steht. +export function Discussion({ entry: e }: { entry: Entry }) { + if (!e.latest) return ; + const { entry: latest, skipped } = e.latest; + return ( +
+ + {skipped > 0 && ( +
+
+
+ + {skipped === 1 ? "1 weiterer Beitrag" : `${skipped} weitere Beiträge`} + +
+
+ )} + +
+ ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index fb2edc7..e40ea08 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { api, errMsg } from "../api"; import type { Entry } from "../types"; -import { EntryCard } from "./EntryCard"; +import { Discussion } from "./EntryCard"; // Das Backend deckelt die Seitenzahl bei 100 und liefert darüber wieder // Seite 100 -- ohne diese Grenze würde der Feed sie endlos wiederholen. @@ -54,7 +54,7 @@ export function Feed({ queryKey, urlFor, as: Container = "section" }: { return ( <> - {entries.map((e) => )} + {entries.map((e) => )} {feed.isError &&

{errMsg(feed.error, "Feed konnte nicht geladen werden.")}

} {feed.isSuccess && !hasNextPage &&

YOU REACHED THE END!

}
diff --git a/frontend/src/pages/EntryPage.tsx b/frontend/src/pages/EntryPage.tsx index 183742d..9d8861e 100644 --- a/frontend/src/pages/EntryPage.tsx +++ b/frontend/src/pages/EntryPage.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, errMsg } from "../api"; import { useMe } from "../auth"; import { pasteImage } from "../paste"; -import { EntryCard } from "../components/EntryCard"; +import { Discussion, EntryCard } from "../components/EntryCard"; import type { CreatedEntry, Entry, Thread } from "../types"; // Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung, @@ -56,7 +56,7 @@ function ThreadView({ pid }: { pid: string }) {
{data && !data.replies.length &&

Noch keine Antworten.

} - {data?.replies.map((e) => )} + {data?.replies.map((e) => )}
); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3d647a6..d8cc697 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -15,6 +15,9 @@ export interface Entry { last_bump: number; username: string; avatar: string; + // Nur in Feed und Antwortliste: jüngster Beitrag im Teilbaum, skipped = + // Beiträge auf dem Pfad dazwischen (0 = direkte Antwort). + latest?: { entry: Entry; skipped: number }; } export interface Thread {