1// Package reactions is an emoji reaction board.2//3// Each topic collects emoji reactions. A caller may register at most one4// reaction per topic; the emoji they picked increments a per-topic per-emoji5// tally. Render lists every topic with its emoji tallies and total count.6package reactions78import (9 "sort"10 "strconv"11 "strings"1213 "chain"14 "chain/runtime/unsafe"1516 "gno.land/p/nt/avl/v0"17)1819// topic holds the tallies and the set of callers who already reacted.20type topic struct {21 name string22 tallies *avl.Tree // emoji (string) -> count (int)23 voters *avl.Tree // caller address (string) -> struct{}24 total int25}2627// topics maps topic name -> *topic, kept in an avl.Tree for deterministic order.28var topics = avl.NewTree()2930func getOrPanic(name string) *topic {31 v := topics.Get(name)32 if v == nil {33 panic("topic does not exist: " + name)34 }35 return v.(*topic)36}3738func newTopic(name string) *topic {39 t := &topic{40 name: name,41 tallies: avl.NewTree(),42 voters: avl.NewTree(),43 }44 topics.Set(name, t)45 return t46}4748// CreateTopic registers a new empty topic. It is optional: React auto-creates49// a topic if it does not exist yet. Panics if the topic already exists or the50// name is empty.51func CreateTopic(cur realm, name string) {52 name = strings.TrimSpace(name)53 if name == "" {54 panic("topic name must not be empty")55 }56 if topics.Has(name) {57 panic("topic already exists: " + name)58 }59 newTopic(name)60 chain.Emit("TopicCreated", "topic", name)61}6263// React adds the caller's reaction (an emoji) to a topic. Each caller may react64// at most once per topic. The topic is created on demand if missing. Panics on65// empty input or when the caller has already reacted to this topic.66func React(cur realm, name, emoji string) {67 name = strings.TrimSpace(name)68 emoji = strings.TrimSpace(emoji)69 if name == "" {70 panic("topic name must not be empty")71 }72 if emoji == "" {73 panic("emoji must not be empty")74 }7576 caller := unsafe.PreviousRealm().Address().String()7778 var t *topic79 if v := topics.Get(name); v != nil {80 t = v.(*topic)81 } else {82 t = newTopic(name)83 }8485 if t.voters.Has(caller) {86 panic("caller already reacted to this topic: " + name)87 }88 t.voters.Set(caller, struct{}{})8990 count := 091 if v := t.tallies.Get(emoji); v != nil {92 count = v.(int)93 }94 t.tallies.Set(emoji, count+1)95 t.total++9697 chain.Emit("Reacted", "topic", name, "emoji", emoji)98}99100// TopicCount returns the number of topics on the board.101func TopicCount() int {102 return topics.Size()103}104105// emojiTally is used to sort a topic's tallies deterministically.106type emojiTally struct {107 emoji string108 count int109}110111// byCountDesc sorts tallies by count descending, then emoji ascending.112type byCountDesc []emojiTally113114func (s byCountDesc) Len() int { return len(s) }115func (s byCountDesc) Swap(i, j int) { s[i], s[j] = s[j], s[i] }116func (s byCountDesc) Less(i, j int) bool {117 if s[i].count != s[j].count {118 return s[i].count > s[j].count119 }120 return s[i].emoji < s[j].emoji121}122123func (t *topic) sortedTallies() []emojiTally {124 out := make([]emojiTally, 0, t.tallies.Size())125 t.tallies.Iterate("", "", func(key string, value interface{}) bool {126 out = append(out, emojiTally{emoji: key, count: value.(int)})127 return false128 })129 sort.Stable(byCountDesc(out))130 return out131}132133// tallyRow renders a topic's tallies as e.g. "👍 3 ❤️ 5 🎉 1".134func (t *topic) tallyRow() string {135 if t.tallies.Size() == 0 {136 return "_no reactions yet_"137 }138 rows := t.sortedTallies()139 parts := make([]string, 0, len(rows))140 for _, r := range rows {141 parts = append(parts, r.emoji+" "+strconv.Itoa(r.count))142 }143 return strings.Join(parts, " ")144}145146// Render returns a Markdown view of the board. If path is a non-empty topic147// name, only that topic is shown; otherwise all topics are listed.148func Render(path string) string {149 path = strings.TrimSpace(strings.Trim(path, "/"))150151 if path != "" {152 v := topics.Get(path)153 if v == nil {154 return "# Reaction Board\n\nTopic not found: `" + path + "`\n"155 }156 t := v.(*topic)157 var sb strings.Builder158 sb.WriteString("# Reaction Board — " + t.name + "\n\n")159 sb.WriteString(t.tallyRow() + "\n\n")160 sb.WriteString("**Total reactions:** " + strconv.Itoa(t.total) + "\n")161 return sb.String()162 }163164 var sb strings.Builder165 sb.WriteString("# Reaction Board\n\n")166 if topics.Size() == 0 {167 sb.WriteString("_No topics yet. Call `React(cur, topic, emoji)` to start._\n")168 return sb.String()169 }170171 grand := 0172 topics.Iterate("", "", func(key string, value interface{}) bool {173 t := value.(*topic)174 grand += t.total175 sb.WriteString("## " + t.name + "\n\n")176 sb.WriteString(t.tallyRow() + "\n\n")177 sb.WriteString("Total: " + strconv.Itoa(t.total) + "\n\n")178 return false179 })180181 sb.WriteString("---\n\n")182 sb.WriteString("**Topics:** " + strconv.Itoa(topics.Size()) +183 " · **Reactions:** " + strconv.Itoa(grand) + "\n")184 return sb.String()185}186CreateTopic(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string)
React(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string, emoji string)
Render(path string) string
TopicCount() int
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.