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

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}