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