Artifact 2f65e12e27ea01366f70d9174987ae88298081379c2a44ae65f794ede8b168bd:
- File
internal/api/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: 2536) [annotate] [blame] [check-ins using]
// read.go — forum read-state. Marking a thread read is an EXPLICIT act // with its own op, not a side effect of fetching one. forum.get stays a // pure query, so an API client, a crawler or a feed reader can read a // thread's contents without silently spending someone's unread state. // The web thread page calls this because a person opening a page really // is reading it; nothing else has to. package api import ( "context" "fmt" "craftdesign.group/quarry/internal/ops" ) type forumReadIn struct { Repo string `json:"repo" validate:"required,max=64"` // Root empty means the whole repo: mark everything read up to now. // One watermark write instead of one row per thread. Root string `json:"root" validate:"max=64"` } type forumReadOut struct { Root string `json:"root,omitempty"` All bool `json:"all,omitempty"` } func registerRead(r *ops.Registry, d Deps) error { return ops.Register(r, ops.Op[forumReadIn, forumReadOut]{ Name: "forum.read", Caps: "3", Doc: "Mark a forum thread read, or the whole forum when root is omitted. Read means seen-with-nothing-new-since, so a later reply makes a thread unread again on its own.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/forum/read", Run: func(ctx context.Context, in forumReadIn) (forumReadOut, error) { repo, err := d.repoFor(ctx, in.Repo) if err != nil { return forumReadOut{}, err } // Read-state is keyed by account for the same reason a vote // is: without one, every stranger would share a single pile // of "already seen". account := ops.User(ctx) if account == "" { return forumReadOut{}, fmt.Errorf("marking read needs a signed-in account: %w", ops.ErrInvalid) } if in.Root == "" { return forumReadOut{All: true}, d.Forum.MarkAllForumRead(in.Repo, account) } // The thread's own newest post is the mark, not the wall // clock: it is what a later reply must beat to make the // thread unread again, and it cannot drift with a slow // render or a clock skew between forge and index. _, posts, ok, err := repo.Index.ForumThreadPosts(in.Root) if err != nil { return forumReadOut{}, err } if !ok || len(posts) == 0 { return forumReadOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound) } root, last := posts[0].Hash, posts[0].Date for _, p := range posts { if p.Date > last { last = p.Date } } return forumReadOut{Root: root}, d.Forum.MarkForumRead(in.Repo, account, root, last) }, }) }