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
{errMsg(feed.error, "Feed konnte nicht geladen werden.")}
} {feed.isSuccess && !hasNextPage &&YOU REACHED THE END!
}Noch keine Antworten.
} - {data?.replies.map((e) =>