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

home.gno

5.86 Kb · 225 lines
  1// Package home is a personal home realm. gno.land also shows it as the
  2// profile page at /u/<name>, which always calls Render("") and reads its
  3// links against /u/<name>: every link here is absolute.
  4package home
  5
  6import (
  7	"chain/runtime"
  8	"strconv"
  9	"strings"
 10
 11	"gno.land/p/nt/avl/v0"
 12)
 13
 14const (
 15	homeTab      = "home"
 16	workTab      = "work"
 17	blogTab      = "blog"
 18	guestbookTab = "guestbook"
 19
 20	maxTabs       = 8
 21	maxTabName    = 20
 22	maxPageBytes  = 16 * 1024 // so all pages together stay under 128 KB
 23	maxLineRunes  = 140
 24	maxBadges     = 6
 25	maxSlugBytes  = 60
 26	maxTitleRunes = 100
 27	pageSize      = 20
 28	latestPosts   = 3
 29	seqWidth      = 10
 30
 31	errUnauthorized = "home: unauthorized"
 32	errMasterKey    = "home: needs the master key, not a session key"
 33	errOwnerOnly    = "home: owner only"
 34	errPendingOnly  = "home: pending owner only"
 35	errInvalidInput = "home: invalid input: "
 36	errSigned       = "home: already signed"
 37	errNoEntry      = "home: no such entry"
 38	errNoTab        = "home: no such tab"
 39	errNoPost       = "home: no such post"
 40	errTabsRequired = "home: home, blog and guestbook tabs are required"
 41	errUgnotOnly    = "home: send ugnot only"
 42)
 43
 44type profileData struct {
 45	name    string
 46	tagline string
 47}
 48
 49type badge struct {
 50	label string
 51	value string
 52	url   string
 53	row   bool // starts a new row
 54}
 55
 56type nowLine struct {
 57	text   string
 58	height int64
 59}
 60
 61type entry struct {
 62	msg    string
 63	height int64
 64	hidden bool
 65}
 66
 67// post dates are UTC days of the block time, "2006-01-02".
 68type post struct {
 69	title     string
 70	body      string
 71	published string
 72	edited    string // "" until the first edit
 73	seq       string // key in postOrder
 74}
 75
 76type tipStats struct {
 77	count int
 78	total int64
 79}
 80
 81var (
 82	owner        address
 83	pendingOwner address // set by TransferOwnership, cleared by AcceptOwnership
 84	base         string  // "/r/<name>/home"; every generated link starts with it
 85	profile      profileData
 86	badges       []badge
 87	now          nowLine
 88	tabs         []string          // tab order: home first, guestbook last
 89	pages        map[string]string // markdown per tab
 90	waves        int               // wave count; anyone can wave again
 91	entries      *avl.Tree         // address -> *entry
 92	order        *avl.Tree         // zero-padded sequence -> address
 93	posts        *avl.Tree         // slug -> *post
 94	postOrder    *avl.Tree         // zero-padded sequence -> slug
 95	postSeq      int               // last post sequence; never reused
 96	tips         tipStats
 97	updated      int64 // block height of the last owner write
 98)
 99
100// init records the deployer as owner. Under mainnet's approval flow it runs
101// as the submitter, not the approver. No AssertOriginCall: init isn't a call.
102func init(cur realm) {
103	owner = callerOf(cur)
104	base = strings.TrimPrefix(cur.PkgPath(), runtime.ChainDomain())
105	setDefaults()
106}
107
108// setDefaults writes the initial state (content.gno holds the personal part).
109func setDefaults() {
110	profile = defaultProfile()
111	badges = defaultBadges()
112	now = nowLine{}
113	pendingOwner = ""
114	tabs = []string{homeTab, workTab, blogTab, guestbookTab}
115	pages = map[string]string{
116		homeTab:      defaultHome(),
117		workTab:      defaultWork,
118		blogTab:      defaultBlog,
119		guestbookTab: defaultGuestbook,
120	}
121	waves = 0
122	entries = avl.NewTree()
123	order = avl.NewTree()
124	posts = avl.NewTree()
125	postOrder = avl.NewTree()
126	postSeq = 0
127	tips = tipStats{}
128	updated = runtime.ChainHeight()
129}
130
131// callerOf is the account guard: a live cur and a direct call from an
132// account. It must not call AssertOriginCall, which refuses every call from
133// inside a named helper.
134func callerOf(cur realm) address {
135	if !cur.IsCurrent() || !cur.Previous().IsUserCall() {
136		panic(errUnauthorized)
137	}
138	return cur.Previous().Address()
139}
140
141// assertOwner is the owner guard.
142func assertOwner(cur realm) {
143	if callerOf(cur) != owner {
144		panic(errOwnerOnly)
145	}
146}
147
148// Owner returns the current owner.
149func Owner() address {
150	return owner
151}
152
153// Render shows one tab, or one post at blog/<slug>. An unknown path shows
154// Home and an unknown post shows Blog. The path is only compared against
155// tab names or looked up as a slug, and never written into the page.
156func Render(path string) string {
157	tab, query, _ := strings.Cut(path, "?")
158	if slug, ok := strings.CutPrefix(tab, blogTab+"/"); ok {
159		if p, ok := posts.Get(slug).(*post); ok {
160			return frame("", postMD(p)) // not a tab: no tab is bold
161		}
162		tab = blogTab
163	}
164	if _, ok := pages[tab]; !ok {
165		tab = homeTab
166	}
167	return frame(tab, expand(pages[tab], tab, parsePage(query)))
168}
169
170// frame puts the tab bar above a page and the footer below it.
171func frame(tab, md string) string {
172	return tabBar(tab) + "\n\n" + md + "\n\n" + footer()
173}
174
175func tabURL(t string) string {
176	if t == homeTab {
177		return base
178	}
179	return base + ":" + t
180}
181
182// tabLabel capitalizes a tab name; names are ASCII by validName.
183func tabLabel(t string) string {
184	return strings.ToUpper(t[:1]) + t[1:]
185}
186
187// tabBar draws every tab as a linked pill, the current one in brand green.
188// The current tab is linked too, because gnoweb shows an unlinked image as
189// a block and the row would break. Blog stays out until there is a post.
190func tabBar(current string) string {
191	parts := make([]string, 0, len(tabs))
192	for _, t := range tabs {
193		s := segSoft(tabLabel(t))
194		switch {
195		case t == current:
196			s = segOn(tabLabel(t))
197		case t == blogTab && posts.Size() == 0:
198			continue // no posts yet
199		}
200		parts = append(parts, pillLink(tabLabel(t), tabURL(t), tabH, 0, s))
201	}
202	return strings.Join(parts, " ")
203}
204
205func footer() string {
206	return "---\n\nOwner " + userLink(owner) +
207		" · updated at block " + strconv.FormatInt(updated, 10) +
208		" · [Source](" + base + "$source)\n"
209}
210
211// parsePage reads page=N from the query; anything invalid is page 1.
212func parsePage(query string) int {
213	for _, kv := range strings.Split(query, "&") {
214		k, v, _ := strings.Cut(kv, "=")
215		if k != "page" {
216			continue
217		}
218		n, err := strconv.Atoi(v)
219		if err != nil || n < 1 {
220			return 1
221		}
222		return n
223	}
224	return 1
225}