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

v0 source pure

Package reactions is the engine behind an embeddable reaction block: the one-click "👍 3 · 🔥 1" strip a realm drops in...

Readme View source

gno.land/p/moul/reactions/v0

The engine behind an embeddable reaction block: pages, tallies, a closed emoji palette, and the markdown that shows them. No realm state, no chain reads, no authority decisions. The realm on top owns all three.

Live realm: gno.land/r/moul/reactions/v0.

The model

A Board maps a page key to a Page. A page holds exactly three things:

a count per palette key what the strip shows
the current reaction of each address so a tally counts addresses and not clicks, and a reader can change their mind or take it back
the last reaction which key, from whom, at what height

There is no text field anywhere. That is what makes the whole thing runnable with no moderation: reactions come from a closed set of six, so there is nothing to take down.

 1b := reactions.NewBoard()
 2b.React("gno.land/r/moul/home", caller, reactions.KeyFire, height) // false if rejected
 3b.Unreact("gno.land/r/moul/home", caller)                          // false if there was none
 4
 5p := b.Page("gno.land/r/moul/home") // nil when untouched, and nil is safe to use
 6p.Count(reactions.KeyFire)          // 1
 7p.Total()                           // addresses reacting, not clicks
 8p.Of(caller)                        // "fire", or "" for none
 9key, who, height := p.Last()
10
11md := reactions.Block(realmPath, page, p) // the whole widget

Every Page read is nil-safe and answers as if the page were empty, so a host realm renders the block without ever checking whether the page exists.

Three decisions worth knowing before you use it

A reaction key is an ASCII name, not a glyph. "up", not 👍. A name survives a URL query parameter, an avl key and a markdown escaper with no encoding question anywhere, and the palette can change a glyph without rewriting stored state. Emoji(key) is the presentation half.

No glyph in the palette uses U+FE0F or a zero-width joiner. The link title goes through ui.Inline, which strips exactly those, so a glyph needing a variation selector would come out the other side as its monochrome form. ❤️ is the casualty; heart is 💜, a codepoint that is colour on its own. Pinned by TestEmojiSurviveTheEscaper.

Unreact does not rewind the last-reaction line. It records an event that happened, and recomputing it would mean walking every address on the page, so Page.Last keeps naming a reaction nobody holds any more.

What is shown is a separate decision, and Summary makes it: a page whose reactors have all left renders as empty, not as a withdrawn reaction. Gating on the last key alone would print "last 👍 by g1… · 0 reactions" above an empty strip, which contradicts itself. TestSummaryGoesEmptyWhenEveryoneLeaves pins the pair.

Page keys

A page key is a package path, and ValidPage is what bounds this package's exposure: at least one /, no empty element, and only lowercase ASCII letters, digits, ., -, _, : and /, up to MaxPageLen bytes. That excludes every character a markdown escaper exists for, plus every byte above ASCII, so a stored key can never be a bidi run, a zero-width joiner or an image.

: is in the set so a host realm can key one block per article on the path it already serves: gno.land/r/you/blog:hello-world.

This package only ever puts a key in a URL. A realm that shows the key as text escapes it there; ValidPage and ui.Inline protect against different mistakes.

Not to be confused with

r/moul/x/daily/reactions is an older, unrelated experiment: a single self-contained board with its own "topics", no embedding, and no way for a reader to change or remove a reaction. Nothing is shared between the two.


Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.

Dependency graph:

gno.land/p/moul/reactions/v0 dependency graph

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.

Overview

Package reactions is the engine behind an embeddable reaction block: the one-click "👍 3 · 🔥 1" strip a realm drops into its own Render, keyed on the PAGE it is showing rather than on the realm that stores it.

That indirection is the whole idea, and it is the one the web already settled on: a single hosted widget, embedded verbatim everywhere, deciding what to show from the page identifier it is handed. One realm holds every tally, every host realm ships the same two lines, and no host realm stores anything.

The model

A Board maps a page key to a Page. A page holds three things and deliberately nothing else:

  • a count per palette key,
  • the CURRENT reaction of each address, so a tally counts addresses and not clicks, and so a reader can change their mind,
  • the last reaction: which key, from whom, at what height.

There is no text anywhere, which is the point. Text needs moderation; a closed palette of six emoji does not.

Keys are names, not glyphs

A reaction is stored and addressed as an ASCII name ("up", "fire"), and the glyph is presentation. A name survives a URL query parameter, an avl key and a markdown escaper without a single encoding question, and the palette can gain a glyph without rewriting stored state.

Page keys

A page key is a package path (the full one, chain domain included, as unsafe.CurrentRealm().PkgPath() reports it). ValidPage is what bounds this package's exposure: a key is path-shaped, ASCII, and at most MaxPageLen bytes, so a stored key can never be a markdown payload, a bidi run or an unbounded blob. This package only ever puts it in a URL; a realm that shows the key as text escapes it there, because a validator and an escaper protect against different mistakes.

Constants 2

const KeyUp, KeyHeart, KeyFire, KeyParty, KeyRocket, KeyEyes

 1const (
 2	// KeyUp is the default, first in the strip.
 3	KeyUp = "up"
 4	// KeyHeart is affection rather than agreement.
 5	KeyHeart = "heart"
 6	// KeyFire is "this is good".
 7	KeyFire = "fire"
 8	// KeyParty is a congratulation.
 9	KeyParty = "party"
10	// KeyRocket is "ship it".
11	KeyRocket = "rocket"
12	// KeyEyes is "I am watching this", the only one that is not praise.
13	KeyEyes = "eyes"
14)
source

The palette: the closed set of reactions, and the only moderation policy this package has.

It is closed on purpose. A free-text reaction is a comment, a comment needs moderation, and moderation is the thing worth not building. Six is enough to say something and few enough to fit on one line on a phone.

Every glyph here is a SINGLE codepoint with no variation selector (no U+FE0F) and no zero-width joiner. That is a constraint, not a coincidence: these strings pass through a markdown escaper that strips zero-width and bidi characters, and a glyph that needs U+FE0F to render would come out the other side as its bare monochrome form. ❤️ is the obvious casualty, which is why "heart" is 💜 (U+1F49C), a codepoint that is a colour emoji on its own.

const MaxPageLen

1const MaxPageLen = 120
source

MaxPageLen is the longest page key accepted. Comfortably past the longest real package path (the deepest in moul/gno-contracts is 34 bytes) and short enough that a key can never be a payload.

Functions 9

func Block

1func Block(realmPath, page string, p *Page) string
source

Block renders the embeddable widget: the clickable palette with its counts, then one line saying what happened here last.

realmPath is the realm that owns React, given as the full package path from its gnomod.toml module line. It is a parameter and not a constant because this package is the engine and not the deployment: a second reactions realm, on another chain or in a test, renders through the same code.

page appears only in URL positions here: as a txlink query argument, which txlink percent-encodes, and inside PageURL. Nothing renders it as text, and ValidPage's charset is what keeps the PageURL form safe. A realm showing the key itself must still run it through ui.Inline.

The result ends with a single newline and contains exactly one blank line, so it drops into a host realm's Render between two sections without a separator of its own.

func Emoji

1func Emoji(key string) string
source

Emoji returns the glyph for a palette key, or "" when the key is not one.

func Keys

1func Keys() []string
source

Keys returns the palette keys in display order. The returned slice is a copy, so a caller cannot reorder the palette for everyone else.

func PageURL

1func PageURL(realmPath, page string) string
source

PageURL is the link to one page's view on the reactions realm: RealmURL with the page as the gnoweb render argument after a colon.

func RealmURL

1func RealmURL(realmPath string) string
source

RealmURL is the gnoweb path of a realm given as a package path.

The chain domain is the first element of a package path and a gnoweb path is the rest of it. Derived here rather than read from chain/runtime so this package keeps no chain import of its own and the rule stays in one place. Returns "" for something that is not a package path.

func Summary

1func Summary(realmPath, page string, p *Page) string
source

Summary is the one line under the block: the last reaction, and a link to the page's own view on the reactions realm. Plain markdown, no wrapper, so a caller can put it somewhere else.

func Valid

1func Valid(key string) bool
source

Valid reports whether key names a reaction in the palette.

func ValidPage

1func ValidPage(page string) bool
source

ValidPage reports whether page is an acceptable key.

The rule is "a gnoweb render path, and nothing that is not one": at least one "/", no empty element, no leading or trailing "/", and only lowercase ASCII letters, digits, ".", "-", "_", ":" and "/". That excludes every character a markdown escaper exists for, plus every byte above ASCII, so a stored key cannot carry a bidi run, a zero-width joiner or an image.

Two of the exclusions are STRUCTURAL and not about markdown, so widening this set is not free. gnoweb parses a render path as `<path>[:<args>][$<webargs>]` and cuts on "$" BEFORE it cuts on ":" (gno.land/pkg/gnoweb/weburl/url.go, ParseFromURL, read 2026-09-28), so a "$" in a key would truncate it on the way back in, and a "%" would be eaten by the unescape that follows. ":" is safe only because that second cut takes the FIRST colon, which leaves the rest of the key intact however many it carries.

":" is in the set because a host realm keying the block per article wants the path it already serves: "gno.land/r/you/blog:hello-world".

func NewBoard

1func NewBoard() *Board
source

NewBoard returns an empty board.

Types 2

type Board

struct
1type Board struct {
2	pages *avl.Tree // page key -> *Page
3}
source

Board is a set of pages, each with its own tally. The zero value is not usable; call NewBoard.

Methods on Board

func Each

method on Board
1func (b *Board) Each(fn func(page string, p *Page) bool)
source

Each visits every page in key order until fn returns true.

func Page

method on Board
1func (b *Board) Page(page string) *Page
source

Page returns the tally for a page, or nil when nothing was ever recorded there. The nil is safe to use: every Page read method handles it.

func Pages

method on Board
1func (b *Board) Pages() int
source

Pages reports how many pages have ever been reacted to.

func React

method on Board
1func (b *Board) React(page string, who address, key string, height int64) bool
source

React records who's reaction on page, replacing whatever they had there before. It reports whether anything was stored: false means the page key or the palette key was rejected, and nothing changed.

height is passed in rather than read from the chain, so this package stays testable without a realm and so the caller decides what "when" means.

func Unreact

method on Board
1func (b *Board) Unreact(page string, who address) bool
source

Unreact removes who's reaction from page. It reports whether there was one.

The last-reaction line is deliberately NOT rewound: it records an event that happened, and recomputing it would mean walking every address on the page. Page.Last therefore keeps naming a reaction nobody holds any more, which is the honest answer to "what happened here last". What is shown is a separate decision, and Summary makes it: a page with no reactors left renders as empty, not as a withdrawn reaction.

type Page

struct
1type Page struct {
2	counts  *avl.Tree // palette key -> int
3	by      *avl.Tree // address string -> palette key
4	lastKey string
5	lastWho address
6	lastAt  int64
7}
source

Page is one page's tally.

Every read method is nil-safe and answers as if the page were empty, so a caller can hand Board.Page's result straight to Block without checking.

Methods on Page

func Count

method on Page
1func (p *Page) Count(key string) int
source

Count reports how many addresses currently hold this reaction.

func Last

method on Page
1func (p *Page) Last() (key string, who address, height int64)
source

Last returns the most recent reaction: its palette key, who made it, and the height it was made at. The key is "" when the page has never been reacted to.

It can name a reaction that is no longer counted, because Board.Unreact does not rewind it. That is the honest answer to "what happened here last", which is the question the line under the block asks.

func Of

method on Page
1func (p *Page) Of(who address) string
source

Of returns who's current reaction key, or "" when they have none.

func Total

method on Page
1func (p *Page) Total() int
source

Total is the number of addresses reacting to this page. It equals the sum of every bucket, because an address holds at most one reaction.

Imports 5

Source Files 5