/p/moul/reactions/v0
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:

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