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}