Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

reactions.gno

4.77 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 := topics.Get(name)
 32	if v == nil {
 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 := topics.Get(name); v != nil {
 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 := t.tallies.Get(emoji); v != nil {
 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 := 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.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}