reactions.gno
11.35 Kb · 334 lines
1// Package reactions is the engine behind an embeddable reaction block: the
2// one-click "👍 3 · 🔥 1" strip a realm drops into its own Render, keyed on
3// the PAGE it is showing rather than on the realm that stores it.
4//
5// That indirection is the whole idea, and it is the one the web already
6// settled on: a single hosted widget, embedded verbatim everywhere, deciding
7// what to show from the page identifier it is handed. One realm holds every
8// tally, every host realm ships the same two lines, and no host realm stores
9// anything.
10//
11// # The model
12//
13// A [Board] maps a page key to a [Page]. A page holds three things and
14// deliberately nothing else:
15//
16// - a count per palette key,
17// - the CURRENT reaction of each address, so a tally counts addresses and
18// not clicks, and so a reader can change their mind,
19// - the last reaction: which key, from whom, at what height.
20//
21// There is no text anywhere, which is the point. Text needs moderation;
22// a closed palette of six emoji does not.
23//
24// # Keys are names, not glyphs
25//
26// A reaction is stored and addressed as an ASCII name ("up", "fire"), and the
27// glyph is presentation. A name survives a URL query parameter, an avl key and
28// a markdown escaper without a single encoding question, and the palette can
29// gain a glyph without rewriting stored state.
30//
31// # Page keys
32//
33// A page key is a package path (the full one, chain domain included, as
34// unsafe.CurrentRealm().PkgPath() reports it). [ValidPage] is what bounds this
35// package's exposure: a key is path-shaped, ASCII, and at most [MaxPageLen]
36// bytes, so a stored key can never be a markdown payload, a bidi run or an
37// unbounded blob. This package only ever puts it in a URL; a realm that shows
38// the key as text escapes it there, because a validator and an escaper protect
39// against different mistakes.
40package reactions
41
42import (
43 "strconv"
44 "strings"
45
46 "gno.land/p/moul/kit/ui/v0"
47 "gno.land/p/moul/md/v0"
48 "gno.land/p/nt/avl/v0"
49)
50
51// MaxPageLen is the longest page key accepted. Comfortably past the longest
52// real package path (the deepest in moul/gno-contracts is 34 bytes) and short
53// enough that a key can never be a payload.
54const MaxPageLen = 120
55
56// Board is a set of pages, each with its own tally. The zero value is not
57// usable; call [NewBoard].
58type Board struct {
59 pages *avl.Tree // page key -> *Page
60}
61
62// NewBoard returns an empty board.
63func NewBoard() *Board { return &Board{pages: avl.NewTree()} }
64
65// Page is one page's tally.
66//
67// Every read method is nil-safe and answers as if the page were empty, so a
68// caller can hand [Board.Page]'s result straight to [Block] without checking.
69type Page struct {
70 counts *avl.Tree // palette key -> int
71 by *avl.Tree // address string -> palette key
72 lastKey string
73 lastWho address
74 lastAt int64
75}
76
77// React records who's reaction on page, replacing whatever they had there
78// before. It reports whether anything was stored: false means the page key or
79// the palette key was rejected, and nothing changed.
80//
81// height is passed in rather than read from the chain, so this package stays
82// testable without a realm and so the caller decides what "when" means.
83func (b *Board) React(page string, who address, key string, height int64) bool {
84 if !ValidPage(page) || !Valid(key) {
85 return false
86 }
87 p := b.page(page)
88
89 // Replacing a reaction decrements the old bucket first. Without this a
90 // reader who clicks twice counts twice, and the tally stops meaning
91 // "how many addresses", which is the only thing it is allowed to mean.
92 if prev := p.Of(who); prev != "" {
93 if prev == key {
94 // Same key again: still record it as the last reaction, since the
95 // click happened, but leave the counts alone.
96 p.lastKey, p.lastWho, p.lastAt = key, who, height
97 return true
98 }
99 p.add(prev, -1)
100 }
101 p.by.Set(who.String(), key)
102 p.add(key, 1)
103 p.lastKey, p.lastWho, p.lastAt = key, who, height
104 return true
105}
106
107// Unreact removes who's reaction from page. It reports whether there was one.
108//
109// The last-reaction line is deliberately NOT rewound: it records an event that
110// happened, and recomputing it would mean walking every address on the page.
111// [Page.Last] therefore keeps naming a reaction nobody holds any more, which is
112// the honest answer to "what happened here last". What is shown is a separate
113// decision, and [Summary] makes it: a page with no reactors left renders as
114// empty, not as a withdrawn reaction.
115func (b *Board) Unreact(page string, who address) bool {
116 p := b.Page(page)
117 if p == nil {
118 return false
119 }
120 prev := p.Of(who)
121 if prev == "" {
122 return false
123 }
124 p.by.Remove(who.String())
125 p.add(prev, -1)
126 return true
127}
128
129// Page returns the tally for a page, or nil when nothing was ever recorded
130// there. The nil is safe to use: every [Page] read method handles it.
131func (b *Board) Page(page string) *Page {
132 v := b.pages.Get(page)
133 if v == nil {
134 return nil
135 }
136 return v.(*Page)
137}
138
139// page returns the tally for a page, creating it if needed.
140func (b *Board) page(key string) *Page {
141 if p := b.Page(key); p != nil {
142 return p
143 }
144 p := &Page{counts: avl.NewTree(), by: avl.NewTree()}
145 b.pages.Set(key, p)
146 return p
147}
148
149// Pages reports how many pages have ever been reacted to.
150func (b *Board) Pages() int { return b.pages.Size() }
151
152// Each visits every page in key order until fn returns true.
153func (b *Board) Each(fn func(page string, p *Page) bool) {
154 b.pages.Iterate("", "", func(key string, value any) bool {
155 return fn(key, value.(*Page))
156 })
157}
158
159// add changes a bucket by delta, dropping it when it reaches zero so an
160// emptied page leaves no residue behind.
161func (p *Page) add(key string, delta int) {
162 n := p.Count(key) + delta
163 if n <= 0 {
164 p.counts.Remove(key)
165 return
166 }
167 p.counts.Set(key, n)
168}
169
170// Count reports how many addresses currently hold this reaction.
171func (p *Page) Count(key string) int {
172 if p == nil {
173 return 0
174 }
175 v := p.counts.Get(key)
176 if v == nil {
177 return 0
178 }
179 return v.(int)
180}
181
182// Total is the number of addresses reacting to this page. It equals the sum of
183// every bucket, because an address holds at most one reaction.
184func (p *Page) Total() int {
185 if p == nil {
186 return 0
187 }
188 return p.by.Size()
189}
190
191// Of returns who's current reaction key, or "" when they have none.
192func (p *Page) Of(who address) string {
193 if p == nil {
194 return ""
195 }
196 v := p.by.Get(who.String())
197 if v == nil {
198 return ""
199 }
200 return v.(string)
201}
202
203// Last returns the most recent reaction: its palette key, who made it, and the
204// height it was made at. The key is "" when the page has never been reacted to.
205//
206// It can name a reaction that is no longer counted, because [Board.Unreact]
207// does not rewind it. That is the honest answer to "what happened here last",
208// which is the question the line under the block asks.
209func (p *Page) Last() (key string, who address, height int64) {
210 if p == nil {
211 return "", "", 0
212 }
213 return p.lastKey, p.lastWho, p.lastAt
214}
215
216// ValidPage reports whether page is an acceptable key.
217//
218// The rule is "a gnoweb render path, and nothing that is not one": at least one
219// "/", no empty element, no leading or trailing "/", and only lowercase ASCII
220// letters, digits, ".", "-", "_", ":" and "/". That excludes every character a
221// markdown escaper exists for, plus every byte above ASCII, so a stored key
222// cannot carry a bidi run, a zero-width joiner or an image.
223//
224// Two of the exclusions are STRUCTURAL and not about markdown, so widening this
225// set is not free. gnoweb parses a render path as `<path>[:<args>][$<webargs>]`
226// and cuts on "$" BEFORE it cuts on ":" (gno.land/pkg/gnoweb/weburl/url.go,
227// ParseFromURL, read 2026-09-28), so a "$" in a key would truncate it on the
228// way back in, and a "%" would be eaten by the unescape that follows. ":" is
229// safe only because that second cut takes the FIRST colon, which leaves the
230// rest of the key intact however many it carries.
231//
232// ":" is in the set because a host realm keying the block per article wants the
233// path it already serves: "gno.land/r/you/blog:hello-world".
234func ValidPage(page string) bool {
235 if page == "" || len(page) > MaxPageLen {
236 return false
237 }
238 if strings.HasPrefix(page, "/") || strings.HasSuffix(page, "/") ||
239 strings.Contains(page, "//") || !strings.Contains(page, "/") {
240 return false
241 }
242 for i := 0; i < len(page); i++ {
243 c := page[i]
244 switch {
245 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
246 case c == '/' || c == '.' || c == '-' || c == '_' || c == ':':
247 default:
248 return false
249 }
250 }
251 return true
252}
253
254// Block renders the embeddable widget: the clickable palette with its counts,
255// then one line saying what happened here last.
256//
257// realmPath is the realm that owns React, given as the full package path from
258// its gnomod.toml module line. It is a parameter and not a constant because
259// this package is the engine and not the deployment: a second reactions realm,
260// on another chain or in a test, renders through the same code.
261//
262// page appears only in URL positions here: as a txlink query argument, which
263// txlink percent-encodes, and inside [PageURL]. Nothing renders it as text, and
264// [ValidPage]'s charset is what keeps the [PageURL] form safe. A realm showing
265// the key itself must still run it through ui.Inline.
266//
267// The result ends with a single newline and contains exactly one blank line,
268// so it drops into a host realm's Render between two sections without a
269// separator of its own.
270func Block(realmPath, page string, p *Page) string {
271 var b strings.Builder
272 for i, key := range order {
273 if i > 0 {
274 b.WriteString(" ")
275 }
276 title := palette[key]
277 if n := p.Count(key); n > 0 {
278 title += " " + strconv.Itoa(n)
279 }
280 b.WriteString(ui.ActionIn(realmPath, title, "React", "page", page, "key", key))
281 }
282 b.WriteString("\n\n")
283 b.WriteString(md.Italic(Summary(realmPath, page, p)))
284 b.WriteString("\n")
285 return b.String()
286}
287
288// Summary is the one line under the block: the last reaction, and a link to
289// the page's own view on the reactions realm. Plain markdown, no wrapper, so a
290// caller can put it somewhere else.
291func Summary(realmPath, page string, p *Page) string {
292 key, who, _ := p.Last()
293 n := p.Total()
294
295 // Two different ways to have nothing to say, and both have to be checked.
296 // key == "" is a page nobody ever touched. n == 0 is a page whose reactors
297 // have all left: [Board.Unreact] deliberately does not rewind the last
298 // reaction, so key still names one. Gating on key alone would print
299 // "last <emoji> by <addr> - 0 reactions" above an empty strip, which
300 // contradicts itself and keeps attributing a reaction its author took back.
301 if key == "" || n == 0 {
302 return "no reactions yet"
303 }
304 label := strconv.Itoa(n) + " reaction"
305 if n != 1 {
306 label += "s"
307 }
308 return "last " + palette[key] + " by " + ui.Addr(who) + " · " +
309 md.Link(label, PageURL(realmPath, page))
310}
311
312// RealmURL is the gnoweb path of a realm given as a package path.
313//
314// The chain domain is the first element of a package path and a gnoweb path is
315// the rest of it. Derived here rather than read from chain/runtime so this
316// package keeps no chain import of its own and the rule stays in one place.
317// Returns "" for something that is not a package path.
318func RealmURL(realmPath string) string {
319 i := strings.Index(realmPath, "/")
320 if i < 0 {
321 return ""
322 }
323 return realmPath[i:]
324}
325
326// PageURL is the link to one page's view on the reactions realm: [RealmURL]
327// with the page as the gnoweb render argument after a colon.
328func PageURL(realmPath, page string) string {
329 base := RealmURL(realmPath)
330 if base == "" {
331 return ""
332 }
333 return base + ":" + page
334}