Search Apps Documentation Source Content File Folder Download Copy Actions Download

quotes.gno

5.01 Kb · 181 lines
  1// Package quotes is a community "Quote of the Moment" board realm.
  2//
  3// Anyone can submit a quote via the exported crossing function AddQuote.
  4// Each quote records its text, the address of the caller who added it, and
  5// the block height at submission time. Render deterministically picks a
  6// "featured" quote by the current block height (a stable index into the
  7// list), shows it as a blockquote, then lists every quote with its author
  8// and id below.
  9package quotes
 10
 11import (
 12	"strconv"
 13	"strings"
 14
 15	"chain"
 16	"chain/runtime"
 17)
 18
 19// quote is a single board entry. Author is stored as a plain string address
 20// (realm values are ephemeral and must never be persisted).
 21type quote struct {
 22	id     int
 23	text   string
 24	author string // bech32 address of the submitter, or "" for seeded quotes
 25	height int64  // block height at submission (0 for seeded quotes)
 26}
 27
 28// board is the persistent realm state. A plain slice is fine here: the list
 29// only ever grows by append and Render walks it in insertion order, which is
 30// deterministic.
 31var board []quote
 32
 33// init seeds the board so a fresh deploy renders nicely.
 34func init() {
 35	seed := []string{
 36		"The unexamined life is not worth living.",
 37		"We suffer more often in imagination than in reality.",
 38		"Waste no more time arguing about what a good person should be. Be one.",
 39		"He who is brave is free.",
 40	}
 41	for _, s := range seed {
 42		board = append(board, quote{
 43			id:     len(board),
 44			text:   s,
 45			author: "", // seeded
 46			height: 0,
 47		})
 48	}
 49}
 50
 51// AddQuote is the exported, state-mutating entry point. It follows the gno 0.9
 52// interrealm crossing convention: the first parameter is `cur realm`. Callers
 53// invoke it as AddQuote(cross(cur), "...") over a MsgCall.
 54func AddQuote(cur realm, text string) {
 55	// Authentication primitive: reject a stale/forged realm value before
 56	// deriving any caller identity from it.
 57	if !cur.IsCurrent() {
 58		panic("spoofed realm: cur is not the live crossing frame")
 59	}
 60
 61	if err := validate(text); err != nil {
 62		panic(err)
 63	}
 64
 65	author := cur.Previous().Address().String()
 66	h := runtime.ChainHeight()
 67
 68	board = append(board, quote{
 69		id:     len(board),
 70		text:   text,
 71		author: author,
 72		height: h,
 73	})
 74
 75	chain.Emit("QuoteAdded",
 76		"id", strconv.Itoa(len(board)-1),
 77		"author", author,
 78		"height", strconv.FormatInt(h, 10),
 79	)
 80}
 81
 82// maxQuoteLen caps quote length to keep storage costs bounded and Render tidy.
 83const maxQuoteLen = 280
 84
 85// validate enforces basic content rules. Returns an error describing the
 86// problem, or nil when the text is acceptable.
 87func validate(text string) error {
 88	if len(text) == 0 {
 89		return errEmpty
 90	}
 91	if len(text) > maxQuoteLen {
 92		return errTooLong
 93	}
 94	return nil
 95}
 96
 97// Sentinel errors for validation. Kept as package-level vars so tests can
 98// compare against them directly.
 99var (
100	errEmpty   = quoteError("quote text must not be empty")
101	errTooLong = quoteError("quote text exceeds " + strconv.Itoa(maxQuoteLen) + " characters")
102)
103
104// quoteError is a trivial string-backed error type (the VM has no host
105// stdlib errors package convenience beyond this).
106type quoteError string
107
108func (e quoteError) Error() string { return string(e) }
109
110// featuredIndex deterministically picks which quote is "of the moment" for a
111// given block height. Pure function of (height, count) so it is fully
112// deterministic and easy to test.
113func featuredIndex(height int64, count int) int {
114	if count <= 0 {
115		return -1
116	}
117	// Guard against a negative height (should not happen on-chain, but keeps
118	// the modulo well-defined).
119	if height < 0 {
120		height = -height
121	}
122	return int(height % int64(count))
123}
124
125// Render produces the Markdown page shown in gnoweb. It is NOT a crossing
126// function (no `cur realm` parameter) — it is a read-only view.
127func Render(path string) string {
128	count := len(board)
129	if count == 0 {
130		return "# Quote of the Moment\n\n_No quotes yet. Be the first to add one._\n"
131	}
132
133	h := runtime.ChainHeight()
134	idx := featuredIndex(h, count)
135	featured := board[idx]
136
137	var b strings.Builder
138	b.WriteString("# Quote of the Moment\n\n")
139
140	// Featured quote as a blockquote.
141	b.WriteString("> ")
142	b.WriteString(featured.text)
143	b.WriteString("\n>\n> — ")
144	b.WriteString(authorLabel(featured))
145	b.WriteString(" (#")
146	b.WriteString(strconv.Itoa(featured.id))
147	b.WriteString(")\n\n")
148
149	b.WriteString("_Featured pick rotates with block height ")
150	b.WriteString(strconv.FormatInt(h, 10))
151	b.WriteString(" — index ")
152	b.WriteString(strconv.Itoa(idx))
153	b.WriteString(" of ")
154	b.WriteString(strconv.Itoa(count))
155	b.WriteString("._\n\n")
156
157	// Full list.
158	b.WriteString("## All quotes (")
159	b.WriteString(strconv.Itoa(count))
160	b.WriteString(")\n\n")
161	for _, q := range board {
162		b.WriteString("- **#")
163		b.WriteString(strconv.Itoa(q.id))
164		b.WriteString("** ")
165		b.WriteString(q.text)
166		b.WriteString(" — _")
167		b.WriteString(authorLabel(q))
168		b.WriteString("_\n")
169	}
170
171	return b.String()
172}
173
174// authorLabel renders a human-friendly author string. Seeded quotes (empty
175// author) show as the house account.
176func authorLabel(q quote) string {
177	if q.author == "" {
178		return "the board"
179	}
180	return q.author
181}