Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}