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}