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

threads.gno

11.45 Kb · 369 lines
  1// Package threads is the engine behind an embeddable discussion block: the
  2// text half of what a reaction bar does, keyed on the PAGE it is shown under
  3// rather than on the realm that stores it.
  4//
  5// # Why not a forum
  6//
  7// A forum is a destination, and a destination has to earn its traffic before
  8// anybody writes the first post. A block does not: it is dropped into pages
  9// that already have readers, and the discussion attaches to the object it is
 10// about. One realm holds every thread, every host realm ships the same two
 11// lines, and no host realm stores anything.
 12//
 13// That is the shape the web settled on for comments in 2010, and the same one
 14// [gno.land/p/moul/reactions] uses for the tally. This package is deliberately
 15// its neighbour: reactions are a closed palette and need no moderation, text
 16// needs some, and the two are separate so a realm can take the cheap one alone.
 17//
 18// # The model
 19//
 20//	Board    every thread, across every page
 21//	Thread   a root post: page, author, body, height, pin, replies
 22//	Reply    an author, a body and a height, and nothing else
 23//
 24// A thread remembers the set of addresses that have replied to it, which is
 25// what lets a realm implement "earned by being replied to" without counting
 26// one person twice. [Board.Reply] reports whether the replier was new, so the
 27// mint rule has exactly one signal and exactly one call site.
 28//
 29// # Ordering, and the pin
 30//
 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 the
 33// same input always produces the same page. A pin is an absolute height, so it
 34// 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 pin
 37// cannot be moved backwards, so buying one does not shorten somebody else's.
 38//
 39// # Page keys and bodies
 40//
 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 most
 43// [MaxPageLen] bytes, so a stored key can never be a markdown payload. A body
 44// 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 realm
 47// that renders one itself.
 48package threads
 49
 50import (
 51	"errors"
 52	"strconv"
 53	"strings"
 54
 55	"gno.land/p/moul/kit/store/v0"
 56	"gno.land/p/moul/kit/ui/v0"
 57	"gno.land/p/moul/md/v0"
 58)
 59
 60const (
 61	// MaxPageLen is the longest page key accepted, matching
 62	// gno.land/p/moul/reactions so the two blocks agree on what a page is.
 63	MaxPageLen = 120
 64
 65	// MaxBodyLen is the longest post or reply accepted, in bytes. Long
 66	// enough for a real comment, short enough that one call cannot lock an
 67	// unbounded storage deposit somebody else is paying for.
 68	MaxBodyLen = 1000
 69
 70	// ExcerptLen is how much of a body a listing shows.
 71	ExcerptLen = 60
 72)
 73
 74// The errors a caller can get back. A p/ returns them; the realm decides to
 75// 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)
 82
 83// Reply is one answer under a thread.
 84type Reply struct {
 85	Author address
 86	Body   string
 87	At     int64 // block height
 88}
 89
 90// Thread is a root post and everything under it.
 91type Thread struct {
 92	Page    string
 93	Author  address
 94	Body    string
 95	At      int64 // block height
 96	Replies []Reply
 97
 98	// PinnedUntil is the height the thread stops being pinned at. Zero is
 99	// never pinned, and a past height is an expired pin: nothing has to
100	// sweep it.
101	PinnedUntil int64
102
103	// repliers is the set of addresses that have replied, so a thread can
104	// be scored on people rather than on messages.
105	repliers map[string]bool
106}
107
108// Pinned reports whether the thread is pinned at height now.
109func (t *Thread) Pinned(now int64) bool { return t != nil && t.PinnedUntil > now }
110
111// Repliers is how many distinct addresses have replied.
112func (t *Thread) Repliers() int {
113	if t == nil {
114		return 0
115	}
116	return len(t.repliers)
117}
118
119// HasReplied reports whether who has already replied to this thread.
120func (t *Thread) HasReplied(who address) bool {
121	if t == nil {
122		return false
123	}
124	return t.repliers[who.String()]
125}
126
127// Board holds every thread, indexed by the page it was posted under.
128type Board struct {
129	threads *store.Store
130	pages   map[string][]store.ID // page key -> ids, oldest first
131}
132
133// NewBoard returns an empty board.
134func NewBoard() *Board {
135	return &Board{threads: store.Named("thread"), pages: map[string][]store.ID{}}
136}
137
138// 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, ErrBadPage
142	}
143	if !ValidBody(body) {
144		return 0, ErrBadBody
145	}
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, nil
155}
156
157// Reply appends to a thread and reports whether this author had never replied
158// to it before.
159//
160// That boolean is the mint signal: a realm that pays an author for attention
161// wants distinct people, not distinct messages, and a thread's own author
162// 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, ErrNoThread
167	}
168	if !ValidBody(body) {
169		return false, ErrBadBody
170	}
171	t.Replies = append(t.Replies, Reply{Author: author, Body: body, At: at})
172
173	key := author.String()
174	if author == t.Author || t.repliers[key] {
175		return false, nil
176	}
177	t.repliers[key] = true
178	return true, nil
179}
180
181// 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 an
184// 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 ErrNoThread
189	}
190	if until <= t.PinnedUntil {
191		return ErrPinIsPast
192	}
193	t.PinnedUntil = until
194	return nil
195}
196
197// 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, false
202	}
203	return v.(*Thread), true
204}
205
206// Len is how many threads exist, across every page.
207func (b *Board) Len() int { return b.threads.Len() }
208
209// Pages is how many pages have ever been posted on.
210func (b *Board) Pages() int { return len(b.pages) }
211
212// PageLen is how many threads a page holds.
213func (b *Board) PageLen(page string) int { return len(b.pages[page]) }
214
215// Listing is one row of [Board.List]: the thread and the id a link needs.
216type Listing struct {
217	ID     store.ID
218	Thread *Thread
219}
220
221// List returns up to limit threads on page: pinned first, then the rest, both
222// newest first.
223//
224// It is a partition and not a sort. There is no comparator and no tie to
225// break, so two identical calls always produce the same page, which is what a
226// 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 []Listing
230	for i := len(ids) - 1; i >= 0; i-- {
231		t, ok := b.Get(ids[i])
232		if !ok {
233			continue
234		}
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 out
247}
248
249// Recent returns up to limit threads from every page, newest first. It is what
250// the hosting realm's own homepage shows.
251func (b *Board) Recent(limit int) []Listing {
252	var out []Listing
253	for _, e := range b.threads.PageReverse(1, limit) {
254		out = append(out, Listing{ID: e.ID, Thread: e.Value.(*Thread)})
255	}
256	return out
257}
258
259// ValidPage reports whether page is a usable page key: path-shaped, lowercase
260// 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 both
263// blocks passes one key to both.
264func ValidPage(page string) bool {
265	if page == "" || len(page) > MaxPageLen {
266		return false
267	}
268	if strings.HasPrefix(page, "/") || strings.HasSuffix(page, "/") ||
269		strings.Contains(page, "//") || !strings.Contains(page, "/") {
270		return false
271	}
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 false
279		}
280	}
281	return true
282}
283
284// 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 shown
288// back to its author: silently rewriting what somebody wrote is worse than
289// telling them it was refused. Everything else is allowed and escaped at
290// render time, since a validator and an escaper protect against different
291// mistakes.
292func ValidBody(body string) bool {
293	if len(body) > MaxBodyLen || strings.TrimSpace(body) == "" {
294		return false
295	}
296	for i := 0; i < len(body); i++ {
297		c := body[i]
298		if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f {
299			return false
300		}
301	}
302	return true
303}
304
305// Block renders the embeddable widget: the page's threads, then the button
306// that opens a new one.
307//
308// realmPath is the realm that owns Post and Reply, given as the full package
309// path from its gnomod.toml module line. It is a parameter and not a constant
310// 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.Link
325				// escapes markdown but NOT a pipe (sanitize.InlineText
326				// leaves it; only ui.Cell rewrites it), so a body used as
327				// 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 a
330				// 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	}
338
339	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}
348
349// 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}
353
354// PageURL is the gnoweb path of a page's full thread list.
355func PageURL(realmPath, page string) string {
356	return RealmURL(realmPath) + ":page/" + page
357}
358
359// 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 is
362// the rest of it, so this is a prefix strip and not a hostname this package
363// has to know.
364func RealmURL(realmPath string) string {
365	if i := strings.Index(realmPath, "/"); i >= 0 {
366		return realmPath[i:]
367	}
368	return "/" + realmPath
369}