The engine behind an embeddable discussion block, keyed on the page it is
shown under rather than on the realm that stores it: NewBoard, Post,
Reply, Pin, List, Recent, Block.
1b:=threads.NewBoard()2id,_:=b.Post("gno.land/r/moul/home",author,"worth discussing",height)3isNew,_:=b.Reply(id,replier,"it is",height)// isNew is the mint signal4b.Pin(id,height+1000)// placement, bought elsewhere5threads.Block(realmPath,page,b.List(page,height,5),b.PageLen(page),height)
A forum is a destination and has to earn its traffic before anybody writes
the first post. A block does not. It is dropped into pages that already have
readers, and the discussion attaches to the object it is about: a realm page, a
proposal, an address. One realm holds every thread, every host realm ships the
same two lines, and no host realm stores anything. That is the shape the web
settled on for comments in 2010, and the same one
p/moul/reactions
uses for the tally. The two are deliberate neighbours and share a page key
format: reactions are a closed palette and need no moderation, text needs some,
so a realm can take the cheap one alone.
Reply reports whether the replier was new to that thread, and that boolean
is the whole reason the package keeps a replier set. A realm paying an author
for attention wants distinct people, not distinct messages, and a thread's own
author replying to themselves is never new. The mint rule then has exactly one
signal and exactly one call site.
List is a partition, not a sort: pinned first, then the rest, both newest
first. No comparator, no tie to break, and two identical calls always produce
the same page, which is what a Render needs. A pin is an absolute height, so
it expires on its own and nothing has to sweep it. Pin refuses to move a pin
backwards, so a cheap pin cannot cut an expensive one short; what a pin costs is
the realm's decision, not this package's.
A body is free text and therefore attacker-controlled markdown.ValidBody
bounds it and refuses control characters, which is a different protection from
escaping and not a substitute for it: everything rendered here goes through
ui.Inline or ui.Cell, and so must any realm that renders a body itself. Note
Block truncates with ui.ShortN and then hands the result to md.Link, which
escapes: ui.Excerpt there would escape twice and render the backslashes.
A page key is a package path, validated by ValidPage to the same rule
p/moul/reactions uses, so a realm embedding both blocks passes one key to both.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:
🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.
Overview
Package threads is the engine behind an embeddable discussion block: the text half of what a reaction bar does, keyed on the PAGE it is shown under rather than on the realm that stores it.
Why not a forum
A forum is a destination, and a destination has to earn its traffic before anybody writes the first post. A block does not: it is dropped into pages that already have readers, and the discussion attaches to the object it is about. One realm holds every thread, every host realm ships the same two lines, and no host realm stores anything.
That is the shape the web settled on for comments in 2010, and the same one gno.land/p/moul/reactions uses for the tally. This package is deliberately its neighbour: reactions are a closed palette and need no moderation, text needs some, and the two are separate so a realm can take the cheap one alone.
The model
Example
1Board every thread, across every page
2Thread a root post: page, author, body, height, pin, replies
3Reply an author, a body and a height, and nothing else
A thread remembers the set of addresses that have replied to it, which is what lets a realm implement "earned by being replied to" without counting one person twice. Board.Reply reports whether the replier was new, so the mint rule has exactly one signal and exactly one call site.
Ordering, and the pin
Board.List returns pinned threads first, then the rest, both newest first, which is a partition and not a sort: no comparator, no tie to break, and the same input always produces the same page. A pin is an absolute height, so it expires on its own and nothing has to be swept.
The realm decides what a pin costs. This package only enforces that a pin cannot be moved backwards, so buying one does not shorten somebody else's.
Page keys and bodies
A page key is a package path, the full one, chain domain included. ValidPage bounds it to a path-shaped lowercase ASCII string of at most MaxPageLen bytes, so a stored key can never be a markdown payload. A body is bounded by MaxBodyLen and rejected when it carries a control character, but it is otherwise free text and therefore attacker-controlled markdown: everything here escapes it with ui.Inline or ui.Cell, and so must any realm that renders one itself.
1const( 2// MaxPageLen is the longest page key accepted, matching 3// gno.land/p/moul/reactions so the two blocks agree on what a page is. 4MaxPageLen=120 5 6// MaxBodyLen is the longest post or reply accepted, in bytes. Long 7// enough for a real comment, short enough that one call cannot lock an 8// unbounded storage deposit somebody else is paying for. 9MaxBodyLen=10001011// ExcerptLen is how much of a body a listing shows.12ExcerptLen=6013)
1var(2ErrBadPage=errors.New("threads: not a page key")3ErrBadBody=errors.New("threads: body is empty, too long, or has control characters")4ErrNoThread=errors.New("threads: no such thread")5ErrPinIsPast=errors.New("threads: a pin cannot be moved backwards")6)
Block renders the embeddable widget: the page's threads, then the button that opens a new one.
realmPath is the realm that owns Post and Reply, given as the full package path from its gnomod.toml module line. It is a parameter and not a constant because this package is the engine and not the deployment.
RealmURL is the gnoweb path of a realm given as a package path.
The chain domain is the first element of a package path and a gnoweb path is the rest of it, so this is a prefix strip and not a hostname this package has to know.
ValidBody reports whether body can be stored: non-empty after trimming, within MaxBodyLen, and free of control characters.
Control characters are refused rather than stripped because a body is shown back to its author: silently rewriting what somebody wrote is worse than telling them it was refused. Everything else is allowed and escaped at render time, since a validator and an escaper protect against different mistakes.
List returns up to limit threads on page: pinned first, then the rest, both newest first.
It is a partition and not a sort. There is no comparator and no tie to break, so two identical calls always produce the same page, which is what a Render needs. limit <= 0 returns everything.
Reply appends to a thread and reports whether this author had never replied to it before.
That boolean is the mint signal: a realm that pays an author for attention wants distinct people, not distinct messages, and a thread's own author replying to themselves is never new.
1typeThreadstruct{ 2Pagestring 3Authoraddress 4Bodystring 5Atint64// block height 6Replies[]Reply 7 8// PinnedUntil is the height the thread stops being pinned at. Zero is 9// never pinned, and a past height is an expired pin: nothing has to10// sweep it.11PinnedUntilint641213// repliers is the set of addresses that have replied, so a thread can14// be scored on people rather than on messages.15repliersmap[string]bool16}