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.

reactions.gno

11.42 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/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/ui/v0"
 47	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/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}