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}