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

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}