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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

threads.gno

11.64 Kb · 371 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	"chain/markdown"
 52	"errors"
 53	"strconv"
 54	"strings"
 55
 56	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/store/v0"
 57	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/ui/v0"
 58	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/md/v0"
 59)
 60
 61const (
 62	// MaxPageLen is the longest page key accepted, matching
 63	// gno.land/p/moul/reactions so the two blocks agree on what a page is.
 64	MaxPageLen = 120
 65
 66	// MaxBodyLen is the longest post or reply accepted, in bytes. Long
 67	// enough for a real comment, short enough that one call cannot lock an
 68	// unbounded storage deposit somebody else is paying for.
 69	MaxBodyLen = 1000
 70
 71	// ExcerptLen is how much of a body a listing shows.
 72	ExcerptLen = 60
 73)
 74
 75// The errors a caller can get back. A p/ returns them; the realm decides to
 76// abort.
 77var (
 78	ErrBadPage   = errors.New("threads: not a page key")
 79	ErrBadBody   = errors.New("threads: body is empty, too long, or has control characters")
 80	ErrNoThread  = errors.New("threads: no such thread")
 81	ErrPinIsPast = errors.New("threads: a pin cannot be moved backwards")
 82)
 83
 84// Reply is one answer under a thread.
 85type Reply struct {
 86	Author address
 87	Body   string
 88	At     int64 // block height
 89}
 90
 91// Thread is a root post and everything under it.
 92type Thread struct {
 93	Page    string
 94	Author  address
 95	Body    string
 96	At      int64 // block height
 97	Replies []Reply
 98
 99	// PinnedUntil is the height the thread stops being pinned at. Zero is
100	// never pinned, and a past height is an expired pin: nothing has to
101	// sweep it.
102	PinnedUntil int64
103
104	// repliers is the set of addresses that have replied, so a thread can
105	// be scored on people rather than on messages.
106	repliers map[string]bool
107}
108
109// Pinned reports whether the thread is pinned at height now.
110func (t *Thread) Pinned(now int64) bool { return t != nil && t.PinnedUntil > now }
111
112// Repliers is how many distinct addresses have replied.
113func (t *Thread) Repliers() int {
114	if t == nil {
115		return 0
116	}
117	return len(t.repliers)
118}
119
120// HasReplied reports whether who has already replied to this thread.
121func (t *Thread) HasReplied(who address) bool {
122	if t == nil {
123		return false
124	}
125	return t.repliers[who.String()]
126}
127
128// Board holds every thread, indexed by the page it was posted under.
129type Board struct {
130	threads *store.Store
131	pages   map[string][]store.ID // page key -> ids, oldest first
132}
133
134// NewBoard returns an empty board.
135func NewBoard() *Board {
136	return &Board{threads: store.Named("thread"), pages: map[string][]store.ID{}}
137}
138
139// Post opens a thread on page and returns its id.
140func (b *Board) Post(page string, author address, body string, at int64) (store.ID, error) {
141	if !ValidPage(page) {
142		return 0, ErrBadPage
143	}
144	if !ValidBody(body) {
145		return 0, ErrBadBody
146	}
147	id := b.threads.Add(&Thread{
148		Page:     page,
149		Author:   author,
150		Body:     body,
151		At:       at,
152		repliers: map[string]bool{},
153	})
154	b.pages[page] = append(b.pages[page], id)
155	return id, nil
156}
157
158// Reply appends to a thread and reports whether this author had never replied
159// to it before.
160//
161// That boolean is the mint signal: a realm that pays an author for attention
162// wants distinct people, not distinct messages, and a thread's own author
163// replying to themselves is never new.
164func (b *Board) Reply(id store.ID, author address, body string, at int64) (isNewReplier bool, err error) {
165	t, ok := b.Get(id)
166	if !ok {
167		return false, ErrNoThread
168	}
169	if !ValidBody(body) {
170		return false, ErrBadBody
171	}
172	t.Replies = append(t.Replies, Reply{Author: author, Body: body, At: at})
173
174	key := author.String()
175	if author == t.Author || t.repliers[key] {
176		return false, nil
177	}
178	t.repliers[key] = true
179	return true, nil
180}
181
182// Pin keeps a thread at the top of its page until height until.
183//
184// It refuses to move a pin backwards, so a cheap pin cannot cut short an
185// expensive one, and extends from whichever is later: the current pin or now.
186func (b *Board) Pin(id store.ID, until int64) error {
187	t, ok := b.Get(id)
188	if !ok {
189		return ErrNoThread
190	}
191	if until <= t.PinnedUntil {
192		return ErrPinIsPast
193	}
194	t.PinnedUntil = until
195	return nil
196}
197
198// Get returns a thread by id.
199func (b *Board) Get(id store.ID) (*Thread, bool) {
200	v, ok := b.threads.Get(id)
201	if !ok {
202		return nil, false
203	}
204	return v.(*Thread), true
205}
206
207// Len is how many threads exist, across every page.
208func (b *Board) Len() int { return b.threads.Len() }
209
210// Pages is how many pages have ever been posted on.
211func (b *Board) Pages() int { return len(b.pages) }
212
213// PageLen is how many threads a page holds.
214func (b *Board) PageLen(page string) int { return len(b.pages[page]) }
215
216// Listing is one row of [Board.List]: the thread and the id a link needs.
217type Listing struct {
218	ID     store.ID
219	Thread *Thread
220}
221
222// List returns up to limit threads on page: pinned first, then the rest, both
223// newest first.
224//
225// It is a partition and not a sort. There is no comparator and no tie to
226// break, so two identical calls always produce the same page, which is what a
227// Render needs. limit <= 0 returns everything.
228func (b *Board) List(page string, now int64, limit int) []Listing {
229	ids := b.pages[page]
230	var pinned, rest []Listing
231	for i := len(ids) - 1; i >= 0; i-- {
232		t, ok := b.Get(ids[i])
233		if !ok {
234			continue
235		}
236		item := Listing{ID: ids[i], Thread: t}
237		if t.Pinned(now) {
238			pinned = append(pinned, item)
239		} else {
240			rest = append(rest, item)
241		}
242	}
243	out := append(pinned, rest...)
244	if limit > 0 && len(out) > limit {
245		out = out[:limit]
246	}
247	return out
248}
249
250// Recent returns up to limit threads from every page, newest first. It is what
251// the hosting realm's own homepage shows.
252func (b *Board) Recent(limit int) []Listing {
253	var out []Listing
254	for _, e := range b.threads.PageReverse(1, limit) {
255		out = append(out, Listing{ID: e.ID, Thread: e.Value.(*Thread)})
256	}
257	return out
258}
259
260// ValidPage reports whether page is a usable page key: path-shaped, lowercase
261// ASCII, no leading, trailing or doubled slash, at most [MaxPageLen] bytes.
262//
263// Same rule as gno.land/p/moul/reactions, deliberately: a realm embedding both
264// blocks passes one key to both.
265func ValidPage(page string) bool {
266	if page == "" || len(page) > MaxPageLen {
267		return false
268	}
269	if strings.HasPrefix(page, "/") || strings.HasSuffix(page, "/") ||
270		strings.Contains(page, "//") || !strings.Contains(page, "/") {
271		return false
272	}
273	for i := 0; i < len(page); i++ {
274		c := page[i]
275		switch {
276		case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
277		case c == '/' || c == '.' || c == '-' || c == '_' || c == ':':
278		default:
279			return false
280		}
281	}
282	return true
283}
284
285// ValidBody reports whether body can be stored: non-empty after the render's
286// own stripping and trimming,
287// within [MaxBodyLen], and free of control characters.
288//
289// Control characters are refused rather than stripped because a body is shown
290// back to its author: silently rewriting what somebody wrote is worse than
291// telling them it was refused. Everything else is allowed and escaped at
292// render time, since a validator and an escaper protect against different
293// mistakes.
294func ValidBody(body string) bool {
295	if len(body) > MaxBodyLen || strings.TrimSpace(markdown.StripBidiAndZeroWidth(body)) == "" {
296		return false
297	}
298	for i := 0; i < len(body); i++ {
299		c := body[i]
300		if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f {
301			return false
302		}
303	}
304	return true
305}
306
307// Block renders the embeddable widget: the page's threads, then the button
308// that opens a new one.
309//
310// realmPath is the realm that owns Post and Reply, given as the full package
311// path from its gnomod.toml module line. It is a parameter and not a constant
312// because this package is the engine and not the deployment.
313func Block(realmPath, page string, items []Listing, total int, now int64) string {
314	out := ""
315	if len(items) == 0 {
316		out += ui.Empty("No discussion yet.")
317	} else {
318		t := ui.NewTable("", "#", "thread", "by", "replies")
319		for _, it := range items {
320			mark := ""
321			if it.Thread.Pinned(now) {
322				mark = "📌"
323			}
324			t.Row(
325				mark,
326				// The link title is the id and never the body. md.Link
327				// escapes markdown but NOT a pipe (sanitize.InlineText
328				// leaves it; only ui.Cell rewrites it), so a body used as
329				// a link title inside a table opens a column.
330				md.Link("#"+it.ID.String(), ThreadURL(realmPath, it.ID)),
331				// Cut first, escape second: the other order can strand a
332				// trailing backslash that escapes the chrome after it.
333				ui.Cell(ui.ShortN(it.Thread.Body, ExcerptLen, 0)),
334				ui.Addr(it.Thread.Author),
335				strconv.Itoa(len(it.Thread.Replies)),
336			)
337		}
338		out += t.String()
339	}
340
341	out += "\n" + ui.ActionIn(realmPath, "💬 Post", "Post", "page", page, "body", "")
342	if total > len(items) {
343		out += " · " + md.Link(
344			strconv.Itoa(total)+" threads",
345			PageURL(realmPath, page),
346		)
347	}
348	return out + "\n"
349}
350
351// ThreadURL is the gnoweb path of one thread on the hosting realm.
352func ThreadURL(realmPath string, id store.ID) string {
353	return RealmURL(realmPath) + ":thread/" + id.String()
354}
355
356// PageURL is the gnoweb path of a page's full thread list.
357func PageURL(realmPath, page string) string {
358	return RealmURL(realmPath) + ":page/" + page
359}
360
361// RealmURL is the gnoweb path of a realm given as a package path.
362//
363// The chain domain is the first element of a package path and a gnoweb path is
364// the rest of it, so this is a prefix strip and not a hostname this package
365// has to know.
366func RealmURL(realmPath string) string {
367	if i := strings.Index(realmPath, "/"); i >= 0 {
368		return realmPath[i:]
369	}
370	return "/" + realmPath
371}