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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

home.gno

8.91 Kb · 285 lines
  1// Package home is David G.'s profile page on gno.land.
  2//
  3// gnoweb renders a profile by calling Render("") on the realm at exactly
  4// /r/<address>/home, so this path carries no /vN suffix.
  5//
  6// The page is a list of sections, each a markdown body the owner edits with
  7// one Set transaction, so changing the text never needs a redeploy. The slot
  8// named "order" lists the sections to show. Six sections are drawn rather
  9// than stored: "hero", the page banner, "banner" and "reviews", the activity
 10// charts, "areachart", the ring of pull requests by area, "game", today's
 11// Gnordle, and "hearts", the heart count and wall.
 12package home
 13
 14import (
 15	"strconv"
 16	"strings"
 17
 18	"chain/runtime"
 19
 20	"gno.land/p/nt/avl/v0"
 21)
 22
 23// admin is the only address allowed to edit sections.
 24const admin = address("g1rayfgklwl0aspz488wvrcrvt7t2quy6q06lgk2")
 25
 26const (
 27	home       = "/r/g1rayfgklwl0aspz488wvrcrvt7t2quy6q06lgk2/home"
 28	orderSlot  = "order"
 29	maxNameLen = 32
 30	// maxPathLen is far above any link the page writes, and turns a long
 31	// crafted link away before the query parser, whose cost grows with it.
 32	maxPathLen = 256
 33)
 34
 35var (
 36	slots  = avl.NewTree() // section name -> markdown body
 37	hearts = avl.NewTree() // address -> block height of the heart
 38)
 39
 40func init() {
 41	for _, s := range defaultSlots {
 42		slots.Set(s.name, s.body)
 43	}
 44}
 45
 46// Set writes one section of the page. Only the admin can call it.
 47//
 48// name is the section to write and body is its markdown. The text sections are
 49// "header", the bio at the top, then "gno", "projects", "peerdev" and
 50// "unrelated"; any other name creates a new section.
 51//
 52// name "order" decides which sections show and in which order. Its body lists
 53// every section name, comma separated, with no spaces. A name left out of the
 54// list is hidden, so copy the current list, from Get("order"), and edit it.
 55//
 56// name "herotext" is the banner's text: "key value" pairs split by ";", with
 57// the keys kicker, the small line on top, title, subtitle, tagline, footline,
 58// the small line at the bottom, and alt, the banner's description for screen
 59// readers. A key left out keeps its current default.
 60//
 61// "hero", "banner", "reviews", "areachart", "game" and "hearts" are drawn by
 62// the page itself: they cannot be set, only placed through "order".
 63//
 64// Examples, as the two fields of the form:
 65//
 66//	name: header
 67//	body: I'm a developer engineer at [Samouraï Coop](https://www.samourai.world/).
 68//
 69//	name: herotext
 70//	body: title David G.; subtitle Developer engineer at Samouraï Coop; footline Open for contribution
 71//
 72//	name: talks
 73//	body: ## Talks
 74//
 75//	name: order
 76//	body: hero,header,activityhead,banner,reviews,gno,areachart,projects,peerdev,unrelated,talks,game,hearts
 77func Set(cur realm, name, body string) {
 78	assertAdmin(cur)
 79	if name == "" || len(name) > maxNameLen || strings.ContainsAny(name, ", \n") {
 80		panic("invalid section name")
 81	}
 82	if drawnSections[name] {
 83		panic("reserved section: " + name)
 84	}
 85	slots.Set(name, body)
 86}
 87
 88// SetActivity refreshes the three charts in one transaction. Only the admin
 89// can call it. Its three arguments are the bodies of the "activity",
 90// "reviewactivity" and "areas" sections, as scripts/activity.py prints them;
 91// an empty argument leaves that chart as it is.
 92func SetActivity(cur realm, activity, reviews, areas string) {
 93	assertAdmin(cur)
 94	for _, s := range []struct{ name, body string }{
 95		{activitySlot, activity},
 96		{reviewActivitySlot, reviews},
 97		{areasSlot, areas},
 98	} {
 99		if s.body != "" {
100			slots.Set(s.name, s.body)
101		}
102	}
103}
104
105// Delete removes one section. Only the admin can call it.
106//
107// name is the section to remove, "talks" for example. Its name can stay in
108// "order": a name with no section shows nothing.
109func Delete(cur realm, name string) {
110	assertAdmin(cur)
111	if _, ok := slots.Remove(name); !ok {
112		panic("no such section: " + name)
113	}
114}
115
116// Get returns a section's markdown, or "" when it does not exist.
117// Get("order") returns the current section list, the one to edit before
118// setting "order".
119func Get(name string) string {
120	if v := slots.Get(name); v != nil {
121		return v.(string)
122	}
123	return ""
124}
125
126// Heart records one heart from the caller. Each address can send exactly one.
127func Heart(cur realm) {
128	from := cur.Previous().Address().String()
129	if hearts.Has(from) {
130		panic("already sent: one heart per address")
131	}
132	hearts.Set(from, runtime.ChainHeight())
133}
134
135// Hearts returns how many distinct addresses sent a heart.
136func Hearts() int { return hearts.Size() }
137
138// HasHeart reports whether addr already sent a heart.
139func HasHeart(addr address) bool { return hearts.Has(addr.String()) }
140
141func assertAdmin(cur realm) {
142	if cur.Previous().Address() != admin {
143		panic("unauthorized")
144	}
145}
146
147// Render serves:
148//
149//	""             the profile page
150//	"wordle/..."   the Gnordle game
151//	"hearts[/<n>]" the addresses that sent a heart, heartsPerPage per page
152func Render(path string) string {
153	if len(path) > maxPathLen {
154		return "# Not found\n\n[Back home](" + home + ")\n"
155	}
156	// Only the game reads a query; elsewhere one is dropped, so a shared link
157	// carrying ?utm_source=... still shows the page.
158	if !strings.HasPrefix(path, "wordle") {
159		path, _, _ = strings.Cut(path, "?")
160	}
161	switch {
162	case path == "":
163		return renderPage()
164	case path == "wordle" || strings.HasPrefix(path, "wordle/") || strings.HasPrefix(path, "wordle?"):
165		return renderWordle(path)
166	case path == "hearts" || strings.HasPrefix(path, "hearts/"):
167		return renderHearts(strings.TrimPrefix(strings.TrimPrefix(path, "hearts"), "/"))
168	}
169	return "# Not found\n\n[Back home](" + home + ")\n"
170}
171
172func renderPage() string {
173	var b strings.Builder
174	for _, name := range strings.Split(Get(orderSlot), ",") {
175		switch name = strings.TrimSpace(name); name {
176		case "":
177		case "hero":
178			b.WriteString(heroImage())
179		case "areachart":
180			b.WriteString(renderAreas())
181		case "banner":
182			b.WriteString(renderChart(activitySlot))
183		case "reviews":
184			b.WriteString(renderChart(reviewActivitySlot))
185		case "game":
186			b.WriteString(renderGameTeaser())
187		case "hearts":
188			b.WriteString(renderHeartsTeaser())
189		default:
190			b.WriteString(Get(name))
191		}
192		b.WriteString("\n\n")
193	}
194	b.WriteString("---\n\nThis page is a contract: [gno.land" + home + "](" + home + "$source).\n")
195	return b.String()
196}
197
198func renderHeartsTeaser() string {
199	return "## Hearts\n\n**" + strconv.Itoa(Hearts()) + "** so far. " +
200		"[Send one](" + home + "$help&func=Heart): one transaction, one per address, " +
201		"forever on chain. [See who did](" + home + ":hearts).\n\n" + renderHeartsWall()
202}
203
204// drawnSections are rendered from code and state, so Set refuses their names.
205var drawnSections = map[string]bool{
206	"hero": true, "banner": true, "reviews": true, "areachart": true, "game": true, "hearts": true,
207}
208
209// heroSlot holds the banner's text, one "key value" pair per field, split by
210// ";": kicker, title, subtitle, tagline, footline and alt, the image's
211// description. A field the section leaves out keeps its value from hero.
212const heroSlot = "herotext"
213
214func currentHero() heroText {
215	h := hero
216	for _, pair := range strings.Split(Get(heroSlot), ";") {
217		key, value, _ := strings.Cut(strings.TrimSpace(pair), " ")
218		value = strings.TrimSpace(value)
219		switch key {
220		case "kicker":
221			h.kicker = value
222		case "title":
223			h.title = value
224		case "subtitle":
225			h.subtitle = value
226		case "tagline":
227			h.tagline = value
228		case "footline":
229			h.footline = value
230		case "alt":
231			h.alt = value
232		}
233	}
234	return h
235}
236
237// mdText keeps s from closing a markdown link or image label.
238func mdText(s string) string {
239	return strings.NewReplacer("[", "(", "]", ")").Replace(s)
240}
241
242// xmlText escapes s for SVG text and attributes.
243func xmlText(s string) string {
244	return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;").Replace(s)
245}
246
247// heartsPerPage keeps one page of the list far under the query gas limit,
248// which a list of every heart would pass at about 15,000 of them.
249const heartsPerPage = 100
250
251func renderHearts(page string) string {
252	n := 1
253	if page != "" {
254		v, err := strconv.Atoi(page)
255		if err != nil || v < 1 {
256			return "# Hearts\n\nThere is no such page.\n\n[Back home](" + home + ")\n"
257		}
258		n = v
259	}
260	pages := (hearts.Size() + heartsPerPage - 1) / heartsPerPage
261	if n > 1 && n > pages {
262		return "# Hearts\n\nThere is no such page.\n\n[Back home](" + home + ")\n"
263	}
264	var b strings.Builder
265	b.WriteString("# Hearts\n\n")
266	if hearts.Size() == 0 {
267		b.WriteString("Nobody yet. [Be the first](" + home + "$help&func=Heart).\n")
268	}
269	hearts.IterateByOffset((n-1)*heartsPerPage, heartsPerPage, func(addr string, h any) bool {
270		b.WriteString("- " + addr + ", block " + strconv.FormatInt(h.(int64), 10) + "\n")
271		return false
272	})
273	if pages > 1 {
274		b.WriteString("\nPage " + strconv.Itoa(n) + " of " + strconv.Itoa(pages) + ".")
275		if n > 1 {
276			b.WriteString(" [Previous](" + home + ":hearts/" + strconv.Itoa(n-1) + ")")
277		}
278		if n < pages {
279			b.WriteString(" [Next](" + home + ":hearts/" + strconv.Itoa(n+1) + ")")
280		}
281		b.WriteString("\n")
282	}
283	b.WriteString("\n[Back home](" + home + ")\n")
284	return b.String()
285}