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