Transaction

9113484098CFA0…71305570632E

Block 405,674 · index 0 · indexed

Summary

Hash
9113484098CFA0D8790B8A4E189F4539C5356077C7B80A3B50C071305570632E
Block
405,674
Size
23661 bytes
Gas used
29,418,205 / 72,707,400
Fee
218122ugnot
Status
success

Messages

#1AddPackagegno.land/r/moul/reactions/v011 arguments
Attached funds
10000000ugnot

Arguments · 11

  1. #1reactions
  2. #2README.md
  3. #3# `gno.land/r/moul/reactions/v0` A reaction block any realm can embed, keyed on the **page** it appears on rather than on the realm that stores it. It is the shape the web settled on for this in 2010: one hosted widget, the same snippet pasted everywhere, deciding what to show from the page identifier it is handed. Here the snippet is two lines of gno. ```go import "gno.land/r/moul/reactions/v0" func Render(path string) string { return body + reactions.RenderBlock() } ``` That is the whole integration. Your realm stores nothing, registers nothing and configures nothing, and redeploying it never loses anyone's reaction. ## Why `RenderBlock` needs no argument A plain read exported by one realm and called by another is **borrowed**: gno opens no realm frame for it, so `unsafe.CurrentRealm()` inside it reports the **caller**. `RenderBlock` uses that as the page key, which is why the same line does the right thing in every realm that pastes it. (Measured on `gnolang/gno@master` 2026-09-22, recorded in this repo's `AGENTS.md`; it is the same mechanism behind `r/moul/config`'s `TopBlock`.) Adding a `cur realm` parameter would silently reverse it and key every embed to this realm, which is exactly why there is none. `RenderBlockFor(page)` is the explicit form: use it from a crossing function, where there is no caller to read off the stack, or to key one block per article on the path you already serve. ```go return body + reactions.RenderBlockFor("gno.land/r/you/blog:" + path) ``` ## What it is, and what it refuses to be Not a comment system. There is no text field anywhere, which is what lets it run with **no moderation at all**: a closed palette of six, one reaction per address per page, changeable and removable. The block shows the tally and who reacted last. That is the feature. | key | | |---|---| | `up` | 👍 | | `heart` | 💜 | | `fire` | 🔥 | | `party` | 🎉 | | `rocket` | 🚀 | | `eyes` | 👀 | `React` aborts on anything else. That is the entire content policy, and it is a compile-time constant rather than a list someone has to curate. ## The surface | | | |---|---| | `React(cur, page, key)` | record the caller's reaction. Reacting again with a different key moves it; with the same key it only updates the "last reaction" line | | `Unreact(cur, page)` | remove it. Aborts when there is none, rather than succeeding silently | | `RenderBlock()` | the embed, keyed on the calling realm | | `RenderBlockFor(page)` | the embed for a named page | | `Count` · `CountOf` · `ReactionOf` · `Last` · `Pages` | reads, for a host realm that wants the numbers without the markdown | `Render("")` is the getting-started page plus the board; `Render("<page>")` is one page's tally, which is where the link under every block goes. ## Two things that are permanent, and were decided on purpose **This realm is public, so it can never be redeployed.** A private realm cannot be imported at all, and being imported is the entire feature. The redeploy that `private = true` would have bought is given up deliberately: the tallies under this path belong to everyone who reacted, not to the deployer, and a redeploy wipes every package-level variable. A fix ships as a `v1` beside this one that keeps reading it, never as a replacement. **The page key is not checked against anything.** A page is just a name: nothing verifies that the realm exists, that it embeds the block, or that the caller has any relationship to it. That is what lets a realm key the block on something finer than itself, and it means the board index is an open list. The index is capped at 25 rows for that reason; every page keeps its own view regardless. ## Not to be confused with [`r/moul/x/daily/reactions`](https://github.com/moul/gno-contracts/tree/main/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. ## Where the code is The engine (pages, tallies, palette, the block markdown) is [`p/moul/reactions/v0`](https://github.com/moul/gno-contracts/tree/main/p/moul/reactions). This realm is the authority half: who may write, what the chain says the height and the caller are, and the two views. First host: [`r/moul/home`](https://github.com/moul/gno-contracts/tree/main/r/moul/home), which renders it as a `:reactions:` layout placeholder. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/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/r/moul/reactions/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/reactions/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/reactions/v0" gno = "0.9" # public: the entire point of this realm is that other realms import it and call # RenderBlock() inside their own Render. A private realm cannot be imported at # all, so `private = true` here would delete the feature rather than trade # against it. # # What private would have bought, redeploy-in-place, is given up deliberately. # This path is permanent and the state under it belongs to everyone who reacted, # not to moul, so a fix ships as a v1 that keeps reading this one rather than as # a redeploy that silently wipes every tally.
  6. #6reactions.gno
  7. #7// Package reactions is a reaction block any realm can embed, keyed on the page // it appears on rather than on the realm that stores it. // // It is the shape the web settled on for this in 2010: one hosted widget, the // same snippet pasted everywhere, deciding what to show from the page // identifier it is handed. Here the snippet is two lines of gno: // // import "gno.land/r/moul/reactions/v0" // // func Render(path string) string { // return body + reactions.RenderBlock() // } // // [RenderBlock] takes no argument because it does not need one: a plain read // exported by a realm and called by another opens no realm frame, so // unsafe.CurrentRealm() inside it reports the CALLER. The host realm's own path // is the page key, and nothing has to be configured, registered or passed. // [RenderBlockFor] is the explicit form, for a realm that renders more than one // page or already holds its path as a constant. // // The host realm stores nothing. Every tally lives here, so a realm can add // reactions without a redeploy the next time it wants to change them, and a // reader's reaction survives the host realm being replaced. // // # What it is not // // Not a comment system. There is no text field anywhere, which is what makes // it possible to run with no moderation at all: six emoji, one reaction per // address per page, changeable and removable. The page shows the tally and who // reacted last. That is the whole feature. package reactions import ( "chain" "chain/runtime" "chain/runtime/unsafe" rx "gno.land/p/moul/reactions/v0" ) // realmPath is this realm's own path, the one its gnomod.toml module line // declares. It is written out rather than read from unsafe.CurrentRealm(), // which reports the CALLER in every read this realm exports and would therefore // build every link against whichever realm embedded the block. const realmPath = "gno.land/r/moul/reactions/v0" // board holds every page's tally. // // A redeploy would wipe it, which is why this realm is not private (see // gnomod.toml): the state here belongs to everyone who reacted, and the only // honest way to change this realm is a v1 beside it. var board = rx.NewBoard() // React records the caller's reaction to page. // // page is a package path, the full one: "gno.land/r/moul/home". It does not // have to be a realm that embeds the block, and nothing checks that it exists. // A page is just a name, which is what lets a realm key the block on something // finer than itself. // // key is one of the palette names, not a glyph: "up", "heart", "fire", "party", // "rocket", "eyes". Render("") lists them with their emoji. // // One reaction per address per page. Reacting again with a different key moves // it; reacting with the same key changes nothing but the "last reaction" line. func React(cur realm, page, key string) { if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } if !rx.Valid(key) { panic("reactions: no such reaction: " + key) } if !rx.ValidPage(page) { panic("reactions: not a page key: " + page) } who := unsafe.PreviousRealm().Address() board.React(page, who, key, runtime.ChainHeight()) chain.Emit("React", "page", page, "key", key, "who", who.String()) } // Unreact removes the caller's reaction from page. It aborts when there is // none, rather than succeeding silently, so a misspelled page key is visible // instead of looking like a no-op. func Unreact(cur realm, page string) { if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } who := unsafe.PreviousRealm().Address() if !board.Unreact(page, who) { panic("reactions: nothing to remove on " + page) } chain.Emit("Unreact", "page", page, "who", who.String()) } // RenderBlock returns the block for the realm CALLING it, which is the form a // host realm embeds. // // It has no cur realm parameter on purpose. A plain read is borrowed: gno opens // no realm frame for it, so unsafe.CurrentRealm() reports the caller's path and // the block keys itself with no argument. Adding a cur realm here would // silently reverse that and key every embed to this realm instead. // // Calling it from inside this realm therefore keys on this realm, which is // never what a caller wants; the views below use the unexported blockFor. func RenderBlock() string { return blockFor(unsafe.CurrentRealm().PkgPath()) } // RenderBlockFor is [RenderBlock] for a named page. Use it from a crossing // function, where there is no caller to read off the stack, or when one realm // renders several pages. func RenderBlockFor(page string) string { return blockFor(page) } // blockFor is the shared body, so no exported read of this realm calls another // and picks up the wrong path. func blockFor(page string) string { return rx.Block(realmPath, page, board.Page(page)) } // Count reports how many addresses have a reaction on page. func Count(page string) int { return board.Page(page).Total() } // CountOf reports how many addresses hold one particular reaction on page. func CountOf(page, key string) int { return board.Page(page).Count(key) } // ReactionOf returns who's current reaction key on page, or "" when they have // none. A host realm can use it to say "you reacted" without reading the tree. func ReactionOf(page string, who address) string { return board.Page(page).Of(who) } // Last returns the most recent reaction on page: the palette key, the address, // and the height. The key is "" when the page has never been reacted to. func Last(page string) (key string, who address, height int64) { return board.Page(page).Last() } // Pages reports how many pages have ever been reacted to. func Pages() int { return board.Pages() }
  8. #8reactions_test.gno
  9. #9package reactions import ( "strconv" "strings" "testing" rx "gno.land/p/moul/reactions/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) const homePage = "gno.land/r/moul/home" var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") ) // reset clears realm state. Realm globals live for the whole test binary, and // the Example runs after every Test, so everything that renders starts here. func reset() { board = rx.NewBoard() } func TestReactAndUnreact(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) React(cross(cur), homePage, rx.KeyUp) testing.SetRealm(testing.NewUserRealm(bob)) React(cross(cur), homePage, rx.KeyFire) uassert.Equal(t, 2, Count(homePage)) uassert.Equal(t, 1, CountOf(homePage, rx.KeyUp)) uassert.Equal(t, rx.KeyUp, ReactionOf(homePage, alice)) uassert.Equal(t, 1, Pages()) key, who, _ := Last(homePage) uassert.Equal(t, rx.KeyFire, key) uassert.Equal(t, bob, who) testing.SetRealm(testing.NewUserRealm(alice)) Unreact(cross(cur), homePage) uassert.Equal(t, 1, Count(homePage)) uassert.Equal(t, "", ReactionOf(homePage, alice)) } func TestReadsOnAnUntouchedPageAreZero(t *testing.T) { reset() uassert.Equal(t, 0, Count("gno.land/r/nobody/here")) uassert.Equal(t, 0, CountOf("gno.land/r/nobody/here", rx.KeyUp)) uassert.Equal(t, "", ReactionOf("gno.land/r/nobody/here", alice)) key, _, _ := Last("gno.land/r/nobody/here") uassert.Equal(t, "", key) } func TestReactRefusals(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "reactions: no such reaction: poop", func() { React(cross(cur), homePage, "poop") }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "reactions: not a page key: not a path", func() { React(cross(cur), "not a path", rx.KeyUp) }) uassert.Equal(t, 0, Pages(), "a refused reaction creates no page") } // TestUnreactAbortsRatherThanNoOp is the deliberate asymmetry: a misspelled // page key on Unreact has to be visible, or a reader thinks they removed // something they did not. func TestUnreactAbortsRatherThanNoOp(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "reactions: nothing to remove on "+homePage, func() { Unreact(cross(cur), homePage) }) } // TestRenderBlockForIsWhatTheBlockLinksTo pins the property a host realm // depends on: every link in the embedded block points at THIS realm, not at // the realm that embedded it, and carries the page key as an argument. func TestRenderBlockForIsWhatTheBlockLinksTo(t *testing.T) { reset() got := RenderBlockFor(homePage) uassert.True(t, strings.Contains(got, "(/r/moul/reactions/v0$help&func=React&"), "the txlink targets this realm") uassert.True(t, strings.Contains(got, "page=gno.land%2Fr%2Fmoul%2Fhome"), "and carries the host page as the parameter") uassert.False(t, strings.Contains(got, "/r/moul/home$help"), "never the host realm, which has no React to call") } // TestRenderBlockKeysOnTheCaller is the borrowed-read rule this realm's whole // ergonomics rest on: RenderBlock has no cur realm parameter, so gno opens no // frame for it and unsafe.CurrentRealm() reports whoever called. // // In-package there is no other realm to call from, so what this pins is the // half that can be pinned here: RenderBlock agrees with RenderBlockFor when // the caller IS this realm. The cross-realm half is measured in AGENTS.md // (2026-09-22) and is the same mechanism r/moul/config's TopBlock uses. func TestRenderBlockKeysOnTheCaller(t *testing.T) { reset() uassert.Equal(t, RenderBlockFor(realmPath), RenderBlock()) uassert.NotEqual(t, RenderBlockFor(homePage), RenderBlock(), "and a different page is a different block") } func TestRenderPageView(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) React(cross(cur), homePage, rx.KeyUp) got := Render(homePage) uassert.True(t, strings.HasPrefix(got, "# gno\\.land/r/moul/home\n"), "the heading is the page key, escaped: ui.Inline backslashes the dot") uassert.True(t, strings.Contains(got, "Last reaction: \U0001F44D by ")) uassert.True(t, strings.Contains(got, "Remove my reaction")) uassert.True(t, strings.Contains(got, "[The board](/r/moul/reactions/v0)")) } func TestRenderRejectsAPathThatIsNotAPageKey(t *testing.T) { reset() got := Render("[click](http://e.vil)") uassert.True(t, strings.HasPrefix(got, "# Not a page\n")) uassert.False(t, strings.Contains(got, "](http://e.vil)"), "a render path is escaped before it is shown back") } // TestRenderHomeShowsTheBoard uses uassert rather than an Example because the // page contains consecutive blank lines, which gno collapses inside an // // Output: block. func TestRenderHomeShowsTheBoard(cur realm, t *testing.T) { reset() uassert.True(t, strings.Contains(Render(""), "*No page has been reacted to yet.*")) testing.SetRealm(testing.NewUserRealm(alice)) React(cross(cur), homePage, rx.KeyParty) got := Render("") uassert.True(t, strings.Contains(got, "| [gno\\.land/r/moul/home](/r/moul/reactions/v0:gno.land/r/moul/home) | 1 |"), "the cell text is escaped, the URL is not: they are different contexts") uassert.True(t, strings.Contains(got, "import \"gno.land/r/moul/reactions/v0\""), "the homepage is the getting-started doc, and the snippet is copy-pasteable") for _, k := range rx.Keys() { uassert.True(t, strings.Contains(got, "| `"+k+"` | "+rx.Emoji(k)+" |"), k) } } // TestBoardIndexIsCapped: the number of pages is unbounded, the index is not. func TestBoardIndexIsCapped(cur realm, t *testing.T) { reset() for i := 0; i < maxListed+3; i++ { testing.SetRealm(testing.NewUserRealm(alice)) // Zero-padded, because avl keys sort as strings and ufmt has no width // flags: "p9" would otherwise sort between "p10" and "p11". n := strconv.Itoa(i) if len(n) < 2 { n = "0" + n } React(cross(cur), "gno.land/r/spam/p"+n, rx.KeyUp) } uassert.Equal(t, maxListed+3, Pages()) got := Render("") uassert.Equal(t, maxListed, strings.Count(got, "(/r/moul/reactions/v0:gno.land/r/spam/")) uassert.True(t, strings.Contains(got, "28 pages in total, 25 shown.")) }
  10. #10render.gno
  11. #11package reactions import ( "strconv" "strings" "gno.land/p/moul/kit/ui/v0" rx "gno.land/p/moul/reactions/v0" ) // maxListed caps the board index. The number of pages is unbounded (anyone can // react to any key), and a Render that grows with it would eventually be too // expensive to serve. The cap is on the INDEX only: every page keeps its own // view at /r/moul/reactions/v0:<page>, whether or not it is listed here. const maxListed = 25 // Render serves two views: // // "" the getting-started page: how to embed the block, plus the board // "<page>" one page's tally, where <page> is a package path // // The page view's path is the key itself, so the link under any embedded block // is just this realm's path, a colon, and the page. That is why a page key may // contain a colon of its own: "gno.land/r/you/blog:hello" reads back whole. func Render(path string) string { if path == "" { return renderHome() } if !rx.ValidPage(path) { return "# Not a page\n\n" + ui.Inline(path) + " is not a page key: a key is a " + "package path, lowercase ASCII, at most " + strconv.Itoa(rx.MaxPageLen) + " bytes.\n\n[Back to the board](" + selfURL() + ")\n" } return renderPage(path) } // selfURL is this realm's own gnoweb path, derived from realmPath the same way // every link in the block is, so there is one rule and not two. A function and // not a package-level var: a package-level var in a realm is realm STATE, // persisted and re-read on every call, which is a strange thing for a string // the compiler could have folded. func selfURL() string { return rx.RealmURL(realmPath) } func renderHome() string { var b strings.Builder b.WriteString("# Reactions\n\n") b.WriteString("A reaction block any realm can embed. One line in your `Render`, no state ") b.WriteString("on your side, and readers get one-click reactions keyed on the page they ") b.WriteString("are looking at.\n\n") b.WriteString("It is not a comment system, on purpose. There is no text field anywhere, ") b.WriteString("which is what lets it run with no moderation: a closed palette, one ") b.WriteString("reaction per address per page, changeable and removable.\n\n") b.WriteString("## It looks like this\n\n") b.WriteString(blockFor(realmPath)) b.WriteString("\nThat block is live and it is this realm's own page. Click one.\n\n") b.WriteString("## Add it to your realm\n\n") b.WriteString("```go\n") b.WriteString("import \"" + realmPath + "\"\n\n") b.WriteString("func Render(path string) string {\n") b.WriteString("\treturn body + reactions.RenderBlock()\n") b.WriteString("}\n") b.WriteString("```\n\n") b.WriteString("That is the whole integration. `RenderBlock` takes no argument because it ") b.WriteString("does not need one: a plain read exported by one realm and called by ") b.WriteString("another opens no realm frame, so it sees YOUR path and uses it as the ") b.WriteString("page key. Nothing to register, nothing to configure.\n\n") b.WriteString("For one block per article rather than one per realm, name the page ") b.WriteString("yourself with the path you already serve:\n\n") b.WriteString("```go\n") b.WriteString("return body + reactions.RenderBlockFor(\"gno.land/r/you/blog:\" + path)\n") b.WriteString("```\n\n") b.WriteString("Reactions are recorded here, not in your realm, so redeploying yours ") b.WriteString("never loses them.\n\n") b.WriteString("## The palette\n\n") t := ui.NewTable("key", "") for _, k := range rx.Keys() { t.Row("`"+k+"`", rx.Emoji(k)) } b.WriteString(t.String()) b.WriteString("\nSix, and the set is closed. `React` aborts on anything else, which is ") b.WriteString("the entire moderation policy.\n\n") b.WriteString("## The board\n\n") b.WriteString(renderBoard()) return b.String() } // renderBoard lists pages in key order, not by activity. Key order is the order // the tree already walks, so the listing is stable between identical calls; // ranking would need a second index maintained on every write, which is a lot // of machinery for a table nobody navigates by. func renderBoard() string { n := board.Pages() if n == 0 { return ui.Empty("No page has been reacted to yet.") } t := ui.NewTable("page", "reactions", "last") board.Each(func(page string, p *rx.Page) bool { key, who, _ := p.Last() last := "-" if key != "" { last = rx.Emoji(key) + " " + ui.Addr(who) } t.Row( "["+ui.Cell(page)+"]("+rx.PageURL(realmPath, page)+")", strconv.Itoa(p.Total()), last, ) return t.Len() >= maxListed }) out := t.String() if n > maxListed { out += "\n" + strconv.Itoa(n) + " pages in total, " + strconv.Itoa(maxListed) + " shown. Every page has its own view at `" + selfURL() + ":<page>`.\n" } return out } func renderPage(page string) string { p := board.Page(page) var b strings.Builder b.WriteString("# " + ui.Inline(page) + "\n\n") b.WriteString(blockFor(page)) b.WriteString("\n") if p.Total() == 0 { b.WriteString(ui.Empty("Nobody is reacting to this page right now.")) } else { t := ui.NewTable("", "reaction", "addresses") for _, k := range rx.Keys() { if c := p.Count(k); c > 0 { t.Row(rx.Emoji(k), "`"+k+"`", strconv.Itoa(c)) } } b.WriteString(t.String()) } if key, who, height := p.Last(); key != "" { b.WriteString("\nLast reaction: " + rx.Emoji(key) + " by " + ui.Addr(who) + " at height " + strconv.FormatInt(height, 10) + ".\n") } b.WriteString("\n" + ui.ActionIn(realmPath, "Remove my reaction", "Unreact", "page", page)) b.WriteString(" · [The board](" + selfURL() + ")\n") return b.String() }

Result log

msg:0,success:true,log:,events:[]

← Back to block 405,674