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("&", "&", "<", "<", ">", ">", "\"", """, "'", "'").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}