Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}