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

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}