Artifact 48426562c0fb3e05d81d1504d9382b4e546af2ce2a668b64a2e2175bd70b3a08:
- File
internal/oltp/read.go
— part of check-in
[c8a93fda64]
at
2026-08-25 22:10:51
on branch trunk
— Forum list: mute what you have read, not what is old
Dimming keyed on a 30-day window meant a quiet forum rendered almost entirely grey. Read-state replaces it: forum_read stores when an account last viewed a thread, and a thread is read only while its newest post is no newer than that view — so a reply re-brightens it with no flag to clear. Anonymous readers have no read-state and see every row at full ink.
Marking read is its own op, not a side effect of forum.get, so crawlers and API clients cannot spend a reader's unread state. Titles now take the accent colour on hover (gated on hover: hover) and on :active, so touch keeps the affordance. (user: dmestas, size: 5136) [annotate] [blame] [check-ins using]
// read.go stores forum read-state — which threads an account has seen // and when. Like votes and subscriptions it is forge-local social // state: per-account, private, and never part of the artifact DAG. // // The row holds a TIMESTAMP, not a boolean. "Read" is not a property of // a thread but a comparison — you have seen it AND nothing has arrived // since — so a thread re-brightens on its next reply with no flag to // hunt down and clear. A boolean would need every new post to walk // every reader's row; a timestamp needs nothing. // // Timestamps here are stored in the INDEX's format (19 chars, UTC, no // zone suffix) rather than the RFC3339 the rest of this package uses. // These values exist only to be compared against index post dates, and // two formats that must compare would be a bug waiting for its first // mismatched hour. package oltp import "time" const readSchema = ` CREATE TABLE IF NOT EXISTS forum_read ( repo TEXT NOT NULL, account TEXT NOT NULL, thread TEXT NOT NULL, -- thread root hash at TEXT NOT NULL, -- when the account last viewed the thread PRIMARY KEY (repo, account, thread) ); -- The watermark: everything in this repo older than at is read. It -- makes "mark all read" one write instead of one per thread, and it is -- what lets the per-thread rows below it be pruned, so the table stays -- bounded rather than growing as accounts x threads forever. CREATE TABLE IF NOT EXISTS forum_read_mark ( repo TEXT NOT NULL, account TEXT NOT NULL, at TEXT NOT NULL, PRIMARY KEY (repo, account) );` // stamp is the index's date format: sortable, UTC, no zone suffix. const stamp = "2006-01-02T15:04:05" // clip cuts an index or RFC3339 date back to the comparable 19-char // prefix. Lexicographic order IS chronological order at that width, // which is why none of this needs to parse a time. func clip(s string) string { if len(s) > 19 { return s[:19] } return s } // MarkForumRead records that an account has seen a thread — but only // when that changes the answer. A re-visit with nothing new since the // last one is a read, not a write, which matters because opening a // thread is the single most common act on a forum and SQLite has one // writer. The guard lives in the upsert's WHERE so the decision is made // where the row is, without a read-then-write race. func (d *DB) MarkForumRead(repo, account, thread, lastReply string) error { if account == "" || thread == "" { return nil } _, err := d.db.Exec(`INSERT INTO forum_read (repo, account, thread, at) VALUES (?, ?, ?, ?) ON CONFLICT (repo, account, thread) DO UPDATE SET at = excluded.at WHERE forum_read.at < ?`, repo, account, thread, time.Now().UTC().Format(stamp), clip(lastReply)) return err } // MarkAllForumRead sets the repo watermark to now and prunes every // per-thread row it subsumes — the rows still say "read", the watermark // says it for them. func (d *DB) MarkAllForumRead(repo, account string) error { if account == "" { return nil } at := time.Now().UTC().Format(stamp) tx, err := d.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`INSERT INTO forum_read_mark (repo, account, at) VALUES (?, ?, ?) ON CONFLICT (repo, account) DO UPDATE SET at = excluded.at`, repo, account, at); err != nil { return err } if _, err := tx.Exec(`DELETE FROM forum_read WHERE repo = ? AND account = ? AND at <= ?`, repo, account, at); err != nil { return err } return tx.Commit() } // ForumRead answers which of the given threads the account has already // seen, keyed root -> that thread's last-reply date. A thread is read // when the account's view of it is no older than its newest post. // // An empty account reads nothing — that is the anonymous answer and it // is the correct one: with no identity there is no "already seen", so // every row renders at full ink rather than guessing. func (d *DB) ForumRead(repo, account string, threads map[string]string) (map[string]bool, error) { out := map[string]bool{} if account == "" || len(threads) == 0 { return out, nil } var mark string // No row is not an error: it means this account has never swept the // repo, so the watermark is simply the zero time. d.db.QueryRow(`SELECT at FROM forum_read_mark WHERE repo = ? AND account = ?`, repo, account).Scan(&mark) roots := make([]string, 0, len(threads)) for root := range threads { roots = append(roots, root) } args := append([]any{repo, account}, toAny(roots)...) rows, err := d.db.Query(`SELECT thread, at FROM forum_read WHERE repo = ? AND account = ? AND thread IN (`+placeholders(len(roots))+`)`, args...) if err != nil { return nil, err } defer rows.Close() seen := make(map[string]string, len(roots)) for rows.Next() { var thread, at string if err := rows.Scan(&thread, &at); err != nil { return nil, err } seen[thread] = at } if err := rows.Err(); err != nil { return nil, err } for root, lastReply := range threads { at := seen[root] if mark > at { at = mark } if at != "" && at >= clip(lastReply) { out[root] = true } } return out, nil }