Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v0 source pure

Package threads is the engine behind an embeddable discussion block: the text half of what a reaction bar does, keyed...

Readme View source

gno.land/p/moul/x/social/threads/v0

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 signal
4b.Pin(id, height+1000)                              // placement, bought elsewhere
5threads.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.

Live realm: r/moul/x/social/threads · render it at /r/moul/x/social/threads/v0.


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:

gno.land/p/moul/x/social/threads/v0 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.

Constants 1

const MaxPageLen, MaxBodyLen, ExcerptLen

 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.
 4	MaxPageLen = 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.
 9	MaxBodyLen = 1000
10
11	// ExcerptLen is how much of a body a listing shows.
12	ExcerptLen = 60
13)
source

Variables 1

var ErrBadPage, ErrBadBody, ErrNoThread, ErrPinIsPast

1var (
2	ErrBadPage   = errors.New("threads: not a page key")
3	ErrBadBody   = errors.New("threads: body is empty, too long, or has control characters")
4	ErrNoThread  = errors.New("threads: no such thread")
5	ErrPinIsPast = errors.New("threads: a pin cannot be moved backwards")
6)
source

The errors a caller can get back. A p/ returns them; the realm decides to abort.

Functions 7

func Block

1func Block(realmPath, page string, items []Listing, total int, now int64) string
source

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.

func PageURL

1func PageURL(realmPath, page string) string
source

PageURL is the gnoweb path of a page's full thread list.

func RealmURL

1func RealmURL(realmPath string) string
source

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.

func ThreadURL

1func ThreadURL(realmPath string, id store.ID) string
source

ThreadURL is the gnoweb path of one thread on the hosting realm.

func ValidBody

1func ValidBody(body string) bool
source

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.

func ValidPage

1func ValidPage(page string) bool
source

ValidPage reports whether page is a usable page key: path-shaped, lowercase ASCII, no leading, trailing or doubled slash, at most MaxPageLen bytes.

Same rule as gno.land/p/moul/reactions, deliberately: a realm embedding both blocks passes one key to both.

func NewBoard

1func NewBoard() *Board
source

NewBoard returns an empty board.

Types 4

type Board

struct
1type Board struct {
2	threads *store.Store
3	pages   map[string][]store.ID // page key -> ids, oldest first
4}
source

Board holds every thread, indexed by the page it was posted under.

Methods on Board

func Get

method on Board
1func (b *Board) Get(id store.ID) (*Thread, bool)
source

Get returns a thread by id.

func Len

method on Board
1func (b *Board) Len() int
source

Len is how many threads exist, across every page.

func List

method on Board
1func (b *Board) List(page string, now int64, limit int) []Listing
source

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.

func PageLen

method on Board
1func (b *Board) PageLen(page string) int
source

PageLen is how many threads a page holds.

func Pages

method on Board
1func (b *Board) Pages() int
source

Pages is how many pages have ever been posted on.

func Pin

method on Board
1func (b *Board) Pin(id store.ID, until int64) error
source

Pin keeps a thread at the top of its page until height until.

It refuses to move a pin backwards, so a cheap pin cannot cut short an expensive one, and extends from whichever is later: the current pin or now.

func Post

method on Board
1func (b *Board) Post(page string, author address, body string, at int64) (store.ID, error)
source

Post opens a thread on page and returns its id.

func Recent

method on Board
1func (b *Board) Recent(limit int) []Listing
source

Recent returns up to limit threads from every page, newest first. It is what the hosting realm's own homepage shows.

func Reply

method on Board
1func (b *Board) Reply(id store.ID, author address, body string, at int64) (isNewReplier bool, err error)
source

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.

type Listing

struct
1type Listing struct {
2	ID     store.ID
3	Thread *Thread
4}
source

Listing is one row of Board.List: the thread and the id a link needs.

type Reply

struct
1type Reply struct {
2	Author address
3	Body   string
4	At     int64 // block height
5}
source

Reply is one answer under a thread.

type Thread

struct
 1type Thread struct {
 2	Page    string
 3	Author  address
 4	Body    string
 5	At      int64 // block height
 6	Replies []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 to
10	// sweep it.
11	PinnedUntil int64
12
13	// repliers is the set of addresses that have replied, so a thread can
14	// be scored on people rather than on messages.
15	repliers map[string]bool
16}
source

Thread is a root post and everything under it.

Methods on Thread

func HasReplied

method on Thread
1func (t *Thread) HasReplied(who address) bool
source

HasReplied reports whether who has already replied to this thread.

func Pinned

method on Thread
1func (t *Thread) Pinned(now int64) bool
source

Pinned reports whether the thread is pinned at height now.

func Repliers

method on Thread
1func (t *Thread) Repliers() int
source

Repliers is how many distinct addresses have replied.

Imports 6

Source Files 4