reactions.gno
4.76 Kb · 185 lines
1// Package reactions is an emoji reaction board.
2//
3// Each topic collects emoji reactions. A caller may register at most one
4// reaction per topic; the emoji they picked increments a per-topic per-emoji
5// tally. Render lists every topic with its emoji tallies and total count.
6package reactions
7
8import (
9 "sort"
10 "strconv"
11 "strings"
12
13 "chain"
14 "chain/runtime/unsafe"
15
16 "gno.land/p/nt/avl/v0"
17)
18
19// topic holds the tallies and the set of callers who already reacted.
20type topic struct {
21 name string
22 tallies *avl.Tree // emoji (string) -> count (int)
23 voters *avl.Tree // caller address (string) -> struct{}
24 total int
25}
26
27// topics maps topic name -> *topic, kept in an avl.Tree for deterministic order.
28var topics = avl.NewTree()
29
30func getOrPanic(name string) *topic {
31 v, ok := topics.Get(name)
32 if !ok {
33 panic("topic does not exist: " + name)
34 }
35 return v.(*topic)
36}
37
38func 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 t
46}
47
48// CreateTopic registers a new empty topic. It is optional: React auto-creates
49// a topic if it does not exist yet. Panics if the topic already exists or the
50// 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}
62
63// React adds the caller's reaction (an emoji) to a topic. Each caller may react
64// at most once per topic. The topic is created on demand if missing. Panics on
65// 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 }
75
76 caller := unsafe.PreviousRealm().Address().String()
77
78 var t *topic
79 if v, ok := topics.Get(name); ok {
80 t = v.(*topic)
81 } else {
82 t = newTopic(name)
83 }
84
85 if t.voters.Has(caller) {
86 panic("caller already reacted to this topic: " + name)
87 }
88 t.voters.Set(caller, struct{}{})
89
90 count := 0
91 if v, ok := t.tallies.Get(emoji); ok {
92 count = v.(int)
93 }
94 t.tallies.Set(emoji, count+1)
95 t.total++
96
97 chain.Emit("Reacted", "topic", name, "emoji", emoji)
98}
99
100// TopicCount returns the number of topics on the board.
101func TopicCount() int {
102 return topics.Size()
103}
104
105// emojiTally is used to sort a topic's tallies deterministically.
106type emojiTally struct {
107 emoji string
108 count int
109}
110
111// byCountDesc sorts tallies by count descending, then emoji ascending.
112type byCountDesc []emojiTally
113
114func (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].count
119 }
120 return s[i].emoji < s[j].emoji
121}
122
123func (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 false
128 })
129 sort.Stable(byCountDesc(out))
130 return out
131}
132
133// 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}
145
146// Render returns a Markdown view of the board. If path is a non-empty topic
147// name, only that topic is shown; otherwise all topics are listed.
148func Render(path string) string {
149 path = strings.TrimSpace(strings.Trim(path, "/"))
150
151 if path != "" {
152 v, ok := topics.Get(path)
153 if !ok {
154 return "# Reaction Board\n\nTopic not found: `" + path + "`\n"
155 }
156 t := v.(*topic)
157 var sb strings.Builder
158 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 }
163
164 var sb strings.Builder
165 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 }
170
171 grand := 0
172 topics.Iterate("", "", func(key string, value interface{}) bool {
173 t := value.(*topic)
174 grand += t.total
175 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 false
179 })
180
181 sb.WriteString("---\n\n")
182 sb.WriteString("**Topics:** " + strconv.Itoa(topics.Size()) +
183 " · **Reactions:** " + strconv.Itoa(grand) + "\n")
184 return sb.String()
185}