1// Package threads is the engine behind an embeddable discussion block: the2// text half of what a reaction bar does, keyed on the PAGE it is shown under3// rather than on the realm that stores it.4//5// # Why not a forum6//7// A forum is a destination, and a destination has to earn its traffic before8// anybody writes the first post. A block does not: it is dropped into pages9// that already have readers, and the discussion attaches to the object it is10// about. One realm holds every thread, every host realm ships the same two11// lines, and no host realm stores anything.12//13// That is the shape the web settled on for comments in 2010, and the same one14// [gno.land/p/moul/reactions] uses for the tally. This package is deliberately15// its neighbour: reactions are a closed palette and need no moderation, text16// needs some, and the two are separate so a realm can take the cheap one alone.17//18// # The model19//20// Board every thread, across every page21// Thread a root post: page, author, body, height, pin, replies22// Reply an author, a body and a height, and nothing else23//24// A thread remembers the set of addresses that have replied to it, which is25// what lets a realm implement "earned by being replied to" without counting26// one person twice. [Board.Reply] reports whether the replier was new, so the27// mint rule has exactly one signal and exactly one call site.28//29// # Ordering, and the pin30//31// [Board.List] returns pinned threads first, then the rest, both newest first,32// which is a partition and not a sort: no comparator, no tie to break, and the33// same input always produces the same page. A pin is an absolute height, so it34// expires on its own and nothing has to be swept.35//36// The realm decides what a pin costs. This package only enforces that a pin37// cannot be moved backwards, so buying one does not shorten somebody else's.38//39// # Page keys and bodies40//41// A page key is a package path, the full one, chain domain included.42// [ValidPage] bounds it to a path-shaped lowercase ASCII string of at most43// [MaxPageLen] bytes, so a stored key can never be a markdown payload. A body44// is bounded by [MaxBodyLen] and rejected when it carries a control character,45// but it is otherwise free text and therefore attacker-controlled markdown:46// everything here escapes it with ui.Inline or ui.Cell, and so must any realm47// that renders one itself.48package threads4950import (51 "errors"52 "strconv"53 "strings"5455 "gno.land/p/moul/kit/store/v0"56 "gno.land/p/moul/kit/ui/v0"57 "gno.land/p/moul/md/v0"58)5960const (61 // MaxPageLen is the longest page key accepted, matching62 // gno.land/p/moul/reactions so the two blocks agree on what a page is.63 MaxPageLen = 1206465 // MaxBodyLen is the longest post or reply accepted, in bytes. Long66 // enough for a real comment, short enough that one call cannot lock an67 // unbounded storage deposit somebody else is paying for.68 MaxBodyLen = 10006970 // ExcerptLen is how much of a body a listing shows.71 ExcerptLen = 6072)7374// The errors a caller can get back. A p/ returns them; the realm decides to75// abort.76var (77 ErrBadPage = errors.New("threads: not a page key")78 ErrBadBody = errors.New("threads: body is empty, too long, or has control characters")79 ErrNoThread = errors.New("threads: no such thread")80 ErrPinIsPast = errors.New("threads: a pin cannot be moved backwards")81)8283// Reply is one answer under a thread.84type Reply struct {85 Author address86 Body string87 At int64 // block height88}8990// Thread is a root post and everything under it.91type Thread struct {92 Page string93 Author address94 Body string95 At int64 // block height96 Replies []Reply9798 // PinnedUntil is the height the thread stops being pinned at. Zero is99 // never pinned, and a past height is an expired pin: nothing has to100 // sweep it.101 PinnedUntil int64102103 // repliers is the set of addresses that have replied, so a thread can104 // be scored on people rather than on messages.105 repliers map[string]bool106}107108// Pinned reports whether the thread is pinned at height now.109func (t *Thread) Pinned(now int64) bool { return t != nil && t.PinnedUntil > now }110111// Repliers is how many distinct addresses have replied.112func (t *Thread) Repliers() int {113 if t == nil {114 return 0115 }116 return len(t.repliers)117}118119// HasReplied reports whether who has already replied to this thread.120func (t *Thread) HasReplied(who address) bool {121 if t == nil {122 return false123 }124 return t.repliers[who.String()]125}126127// Board holds every thread, indexed by the page it was posted under.128type Board struct {129 threads *store.Store130 pages map[string][]store.ID // page key -> ids, oldest first131}132133// NewBoard returns an empty board.134func NewBoard() *Board {135 return &Board{threads: store.Named("thread"), pages: map[string][]store.ID{}}136}137138// Post opens a thread on page and returns its id.139func (b *Board) Post(page string, author address, body string, at int64) (store.ID, error) {140 if !ValidPage(page) {141 return 0, ErrBadPage142 }143 if !ValidBody(body) {144 return 0, ErrBadBody145 }146 id := b.threads.Add(&Thread{147 Page: page,148 Author: author,149 Body: body,150 At: at,151 repliers: map[string]bool{},152 })153 b.pages[page] = append(b.pages[page], id)154 return id, nil155}156157// Reply appends to a thread and reports whether this author had never replied158// to it before.159//160// That boolean is the mint signal: a realm that pays an author for attention161// wants distinct people, not distinct messages, and a thread's own author162// replying to themselves is never new.163func (b *Board) Reply(id store.ID, author address, body string, at int64) (isNewReplier bool, err error) {164 t, ok := b.Get(id)165 if !ok {166 return false, ErrNoThread167 }168 if !ValidBody(body) {169 return false, ErrBadBody170 }171 t.Replies = append(t.Replies, Reply{Author: author, Body: body, At: at})172173 key := author.String()174 if author == t.Author || t.repliers[key] {175 return false, nil176 }177 t.repliers[key] = true178 return true, nil179}180181// Pin keeps a thread at the top of its page until height until.182//183// It refuses to move a pin backwards, so a cheap pin cannot cut short an184// expensive one, and extends from whichever is later: the current pin or now.185func (b *Board) Pin(id store.ID, until int64) error {186 t, ok := b.Get(id)187 if !ok {188 return ErrNoThread189 }190 if until <= t.PinnedUntil {191 return ErrPinIsPast192 }193 t.PinnedUntil = until194 return nil195}196197// Get returns a thread by id.198func (b *Board) Get(id store.ID) (*Thread, bool) {199 v, ok := b.threads.Get(id)200 if !ok {201 return nil, false202 }203 return v.(*Thread), true204}205206// Len is how many threads exist, across every page.207func (b *Board) Len() int { return b.threads.Len() }208209// Pages is how many pages have ever been posted on.210func (b *Board) Pages() int { return len(b.pages) }211212// PageLen is how many threads a page holds.213func (b *Board) PageLen(page string) int { return len(b.pages[page]) }214215// Listing is one row of [Board.List]: the thread and the id a link needs.216type Listing struct {217 ID store.ID218 Thread *Thread219}220221// List returns up to limit threads on page: pinned first, then the rest, both222// newest first.223//224// It is a partition and not a sort. There is no comparator and no tie to225// break, so two identical calls always produce the same page, which is what a226// Render needs. limit <= 0 returns everything.227func (b *Board) List(page string, now int64, limit int) []Listing {228 ids := b.pages[page]229 var pinned, rest []Listing230 for i := len(ids) - 1; i >= 0; i-- {231 t, ok := b.Get(ids[i])232 if !ok {233 continue234 }235 item := Listing{ID: ids[i], Thread: t}236 if t.Pinned(now) {237 pinned = append(pinned, item)238 } else {239 rest = append(rest, item)240 }241 }242 out := append(pinned, rest...)243 if limit > 0 && len(out) > limit {244 out = out[:limit]245 }246 return out247}248249// Recent returns up to limit threads from every page, newest first. It is what250// the hosting realm's own homepage shows.251func (b *Board) Recent(limit int) []Listing {252 var out []Listing253 for _, e := range b.threads.PageReverse(1, limit) {254 out = append(out, Listing{ID: e.ID, Thread: e.Value.(*Thread)})255 }256 return out257}258259// ValidPage reports whether page is a usable page key: path-shaped, lowercase260// ASCII, no leading, trailing or doubled slash, at most [MaxPageLen] bytes.261//262// Same rule as gno.land/p/moul/reactions, deliberately: a realm embedding both263// blocks passes one key to both.264func ValidPage(page string) bool {265 if page == "" || len(page) > MaxPageLen {266 return false267 }268 if strings.HasPrefix(page, "/") || strings.HasSuffix(page, "/") ||269 strings.Contains(page, "//") || !strings.Contains(page, "/") {270 return false271 }272 for i := 0; i < len(page); i++ {273 c := page[i]274 switch {275 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':276 case c == '/' || c == '.' || c == '-' || c == '_' || c == ':':277 default:278 return false279 }280 }281 return true282}283284// ValidBody reports whether body can be stored: non-empty after trimming,285// within [MaxBodyLen], and free of control characters.286//287// Control characters are refused rather than stripped because a body is shown288// back to its author: silently rewriting what somebody wrote is worse than289// telling them it was refused. Everything else is allowed and escaped at290// render time, since a validator and an escaper protect against different291// mistakes.292func ValidBody(body string) bool {293 if len(body) > MaxBodyLen || strings.TrimSpace(body) == "" {294 return false295 }296 for i := 0; i < len(body); i++ {297 c := body[i]298 if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f {299 return false300 }301 }302 return true303}304305// Block renders the embeddable widget: the page's threads, then the button306// that opens a new one.307//308// realmPath is the realm that owns Post and Reply, given as the full package309// path from its gnomod.toml module line. It is a parameter and not a constant310// because this package is the engine and not the deployment.311func Block(realmPath, page string, items []Listing, total int, now int64) string {312 out := ""313 if len(items) == 0 {314 out += ui.Empty("No discussion yet.")315 } else {316 t := ui.NewTable("", "#", "thread", "by", "replies")317 for _, it := range items {318 mark := ""319 if it.Thread.Pinned(now) {320 mark = "📌"321 }322 t.Row(323 mark,324 // The link title is the id and never the body. md.Link325 // escapes markdown but NOT a pipe (sanitize.InlineText326 // leaves it; only ui.Cell rewrites it), so a body used as327 // a link title inside a table opens a column.328 md.Link("#"+it.ID.String(), ThreadURL(realmPath, it.ID)),329 // Cut first, escape second: the other order can strand a330 // trailing backslash that escapes the chrome after it.331 ui.Cell(ui.ShortN(it.Thread.Body, ExcerptLen, 0)),332 ui.Addr(it.Thread.Author),333 strconv.Itoa(len(it.Thread.Replies)),334 )335 }336 out += t.String()337 }338339 out += "\n" + ui.ActionIn(realmPath, "💬 Post", "Post", "page", page, "body", "")340 if total > len(items) {341 out += " · " + md.Link(342 strconv.Itoa(total)+" threads",343 PageURL(realmPath, page),344 )345 }346 return out + "\n"347}348349// ThreadURL is the gnoweb path of one thread on the hosting realm.350func ThreadURL(realmPath string, id store.ID) string {351 return RealmURL(realmPath) + ":thread/" + id.String()352}353354// PageURL is the gnoweb path of a page's full thread list.355func PageURL(realmPath, page string) string {356 return RealmURL(realmPath) + ":page/" + page357}358359// RealmURL is the gnoweb path of a realm given as a package path.360//361// The chain domain is the first element of a package path and a gnoweb path is362// the rest of it, so this is a prefix strip and not a hostname this package363// has to know.364func RealmURL(realmPath string) string {365 if i := strings.Index(realmPath, "/"); i >= 0 {366 return realmPath[i:]367 }368 return "/" + realmPath369}370Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.