Search Apps Documentation Source Content File Folder Download Copy Actions Download

microblog.gno

4.81 Kb · 188 lines
  1// Package microblog is a public microblog wall for gno.land.
  2//
  3// Anyone can Post a short message (<= maxMsgLen chars). Every post records
  4// the caller address, the message, and the block height at which it was made.
  5// Render displays the wall newest-first, 20 posts per page, with pagination
  6// driven by the Render path (e.g. "?page=2" or "/2").
  7package microblog
  8
  9import (
 10	"chain"
 11	"chain/runtime"
 12	"chain/runtime/unsafe"
 13	"strconv"
 14	"strings"
 15)
 16
 17// maxMsgLen is the maximum length (in bytes) of a single post.
 18const maxMsgLen = 280
 19
 20// pageSize is how many posts a single Render page shows.
 21const pageSize = 20
 22
 23// post is a single microblog entry.
 24type post struct {
 25	Author string // bech32 address of the poster
 26	Msg    string // the message body
 27	Height int64  // block height at which it was posted
 28}
 29
 30// posts is the append-only wall, in chronological (oldest-first) order.
 31// Render reverses this view to show newest-first.
 32var posts []post
 33
 34// Post publishes msg to the wall. It is a crossing function: callers invoke it
 35// with Post(cross(cur), "hello"). It panics if msg is empty or longer than
 36// maxMsgLen bytes.
 37func Post(cur realm, msg string) {
 38	if !cur.IsCurrent() {
 39		panic("spoofed realm: cur is not the live crossing frame")
 40	}
 41	if len(msg) == 0 {
 42		panic("microblog: empty message")
 43	}
 44	if len(msg) > maxMsgLen {
 45		panic("microblog: message too long (max " + strconv.Itoa(maxMsgLen) + " bytes)")
 46	}
 47
 48	author := unsafe.PreviousRealm().Address()
 49
 50	posts = append(posts, post{
 51		Author: author.String(),
 52		Msg:    msg,
 53		Height: runtime.ChainHeight(),
 54	})
 55
 56	chain.Emit("Post", "author", author.String(), "len", strconv.Itoa(len(msg)))
 57}
 58
 59// Count returns the total number of posts on the wall.
 60func Count() int {
 61	return len(posts)
 62}
 63
 64// Render renders the wall as Markdown, newest-first, pageSize posts per page.
 65// The page is parsed from path: "?page=N", "?p=N", "/N", or a bare "N" all
 66// select page N (1-indexed). Anything else defaults to page 1.
 67func Render(path string) string {
 68	total := len(posts)
 69	page := parsePage(path)
 70
 71	var b strings.Builder
 72	b.WriteString("# Microblog Wall\n\n")
 73
 74	if total == 0 {
 75		b.WriteString("_No posts yet. Be the first to post._\n")
 76		return b.String()
 77	}
 78
 79	pages := (total + pageSize - 1) / pageSize
 80	if page < 1 {
 81		page = 1
 82	}
 83	if page > pages {
 84		page = pages
 85	}
 86
 87	b.WriteString(strconv.Itoa(total))
 88	b.WriteString(" posts total · page ")
 89	b.WriteString(strconv.Itoa(page))
 90	b.WriteString(" of ")
 91	b.WriteString(strconv.Itoa(pages))
 92	b.WriteString("\n\n")
 93
 94	// Newest-first: post index total-1 is newest. For page p (1-indexed),
 95	// skip (p-1)*pageSize newest posts, then take up to pageSize.
 96	start := total - 1 - (page-1)*pageSize
 97	end := start - pageSize + 1
 98	if end < 0 {
 99		end = 0
100	}
101
102	for i := start; i >= end; i-- {
103		p := posts[i]
104		b.WriteString("- **")
105		b.WriteString(shortAddr(p.Author))
106		b.WriteString("** · height ")
107		b.WriteString(strconv.FormatInt(p.Height, 10))
108		b.WriteString("\n\n  ")
109		b.WriteString(escapeInline(p.Msg))
110		b.WriteString("\n")
111	}
112
113	b.WriteString("\n")
114	b.WriteString(renderNav(page, pages))
115	return b.String()
116}
117
118// renderNav builds a simple prev/next navigation footer as Markdown links.
119func renderNav(page, pages int) string {
120	var b strings.Builder
121	if page > 1 {
122		b.WriteString("[← newer](?page=")
123		b.WriteString(strconv.Itoa(page - 1))
124		b.WriteString(")")
125	}
126	if page > 1 && page < pages {
127		b.WriteString(" · ")
128	}
129	if page < pages {
130		b.WriteString("[older →](?page=")
131		b.WriteString(strconv.Itoa(page + 1))
132		b.WriteString(")")
133	}
134	if b.Len() == 0 {
135		return ""
136	}
137	return b.String() + "\n"
138}
139
140// parsePage extracts the 1-indexed page number from a Render path. It accepts
141// "?page=N", "?p=N", "/N", and a bare "N". Unrecognized input yields page 1.
142func parsePage(path string) int {
143	path = strings.TrimSpace(path)
144	if path == "" {
145		return 1
146	}
147
148	// Query form: "?page=2&foo=bar" or "?p=2".
149	if idx := strings.IndexByte(path, '?'); idx >= 0 {
150		query := path[idx+1:]
151		for _, part := range strings.Split(query, "&") {
152			kv := strings.SplitN(part, "=", 2)
153			if len(kv) != 2 {
154				continue
155			}
156			if kv[0] == "page" || kv[0] == "p" {
157				if n, err := strconv.Atoi(strings.TrimSpace(kv[1])); err == nil {
158					return n
159				}
160			}
161		}
162		return 1
163	}
164
165	// Path form: "/2" or "2".
166	seg := strings.TrimPrefix(path, "/")
167	if n, err := strconv.Atoi(seg); err == nil {
168		return n
169	}
170	return 1
171}
172
173// shortAddr abbreviates a bech32 address for display: g1abcd…wxyz.
174func shortAddr(addr string) string {
175	if len(addr) <= 12 {
176		return addr
177	}
178	return addr[:6] + "…" + addr[len(addr)-4:]
179}
180
181// escapeInline neutralizes characters that would break the Markdown list item
182// layout (newlines collapse to spaces; leading markers are defused).
183func escapeInline(s string) string {
184	s = strings.ReplaceAll(s, "\r\n", " ")
185	s = strings.ReplaceAll(s, "\n", " ")
186	s = strings.ReplaceAll(s, "\r", " ")
187	return s
188}