crew.gno
8.34 Kb · 301 lines
1package home
2
3import (
4 "strconv"
5 "strings"
6
7 "chain/runtime"
8
9 "gno.land/p/nt/avl/v0"
10)
11
12// The crew roster lists everyone who built with samcrew, whether or not they
13// have a gno.land identity yet.
14//
15// A member belongs to a team (engineering, media, ...) and starts with a
16// public link, usually their GitHub profile. Once they have an on-chain
17// identity, the admin runs LinkProfile: the card then links to their home
18// realm (or /u/<address>), is marked ON CHAIN, and the member can write their
19// own line with SetLine.
20
21const (
22 maxCrew = 96 // each card costs render gas; keeps Render("") far below the query ceiling
23 maxIDLen = 32
24 maxHandleLen = 32
25 maxRoleLen = 40
26 maxLineLen = 160
27)
28
29type member struct {
30 id string // stable key, [a-z0-9._-]
31 handle string // shown on the card
32 role string
33 team string // [a-z0-9._-], groups the cards
34 url string // public link used until the member is on chain
35 addr address
36 home string // "/r/..." home realm, preferred link once set
37 line string
38 order int
39 joined int64
40}
41
42var (
43 crew = avl.NewTree() // id -> *member
44 byAddr = avl.NewTree() // address string -> id, for SetLine
45 nextOrder int
46)
47
48// oneLine rejects text that would break out of its markdown or SVG slot.
49func oneLine(field, s string, max int) {
50 if len(s) > max {
51 panic(field + " too long: max " + strconv.Itoa(max) + " bytes")
52 }
53 if strings.ContainsAny(s, "|\n\r") {
54 panic(field + " must be one line without '|'")
55 }
56}
57
58func validURL(u string) bool {
59 return (strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "/") && !strings.HasPrefix(u, "//")) &&
60 len(u) <= 200 && !strings.ContainsAny(u, " ()<>\"|\\\n\r")
61}
62
63// mdEscape turns member-written text into plain text: every character that
64// markdown could read as a link, image, emphasis, code or HTML is escaped.
65func mdEscape(s string) string {
66 var b strings.Builder
67 for _, r := range s {
68 if strings.ContainsRune("\\`*_[]()<>!#|~{}", r) {
69 b.WriteByte('\\')
70 }
71 b.WriteRune(r)
72 }
73 return b.String()
74}
75
76func addMember(id, handle, role, team, url string) {
77 if len(id) > maxIDLen || !validSlug(id) {
78 panic("invalid member id: want 1-32 bytes of [a-z0-9._-]")
79 }
80 if crew.Has(id) {
81 panic("already in the crew: " + id)
82 }
83 if crew.Size() >= maxCrew {
84 panic("the roster is full: " + strconv.Itoa(maxCrew) + " members")
85 }
86 m := &member{id: id}
87 setIdentity(m, handle, role, team, url)
88 nextOrder++
89 m.order = nextOrder
90 m.joined = runtime.ChainHeight()
91 crew.Set(id, m)
92}
93
94func setIdentity(m *member, handle, role, team, url string) {
95 if handle == "" {
96 panic("handle required")
97 }
98 oneLine("handle", handle, maxHandleLen)
99 oneLine("role", role, maxRoleLen)
100 if len(team) > maxIDLen || !validSlug(team) {
101 panic("invalid team: want 1-32 bytes of [a-z0-9._-]")
102 }
103 if !validURL(url) {
104 panic("link must be https:// or a /path, without spaces or markdown characters")
105 }
106 m.handle, m.role, m.team, m.url = handle, role, team, url
107}
108
109func mustMember(id string) *member {
110 v := crew.Get(id)
111 if v == nil {
112 panic("not in the crew: " + id)
113 }
114 return v.(*member)
115}
116
117// AddMember puts someone on the roster, in a team, linked to a public page.
118func AddMember(cur realm, id, handle, role, team, url string) {
119 assertAdmin(cur)
120 addMember(id, handle, role, team, url)
121}
122
123// UpdateMember changes a member's handle, role, team and public link.
124func UpdateMember(cur realm, id, handle, role, team, url string) {
125 assertAdmin(cur)
126 setIdentity(mustMember(id), handle, role, team, url)
127}
128
129// LinkProfile ties a member to their gno.land identity. home is their home
130// realm path ("/r/..."), or "" to link to /u/<addr>. From then on the member
131// can write their own line with SetLine.
132func LinkProfile(cur realm, id string, addr address, home string) {
133 assertAdmin(cur)
134 m := mustMember(id)
135 if !addr.IsValid() {
136 panic("invalid address")
137 }
138 if home != "" && (!strings.HasPrefix(home, "/r/") || strings.ContainsAny(home, " ()<>\"|\\\n\r")) {
139 panic("home must be a /r/... path")
140 }
141 if other := byAddr.Get(addr.String()); other != nil && other.(string) != id {
142 panic("address already linked to " + other.(string))
143 }
144 if m.addr != "" {
145 byAddr.Remove(m.addr.String())
146 }
147 if m.addr != addr {
148 m.line = "" // a line belongs to the address that wrote it
149 }
150 m.addr, m.home = addr, home
151 byAddr.Set(addr.String(), id)
152}
153
154// ClearLine removes a member's line, for moderation.
155func ClearLine(cur realm, id string) {
156 assertAdmin(cur)
157 mustMember(id).line = ""
158}
159
160// RemoveMember takes someone off the roster.
161func RemoveMember(cur realm, id string) {
162 assertAdmin(cur)
163 m := mustMember(id)
164 if m.addr != "" {
165 byAddr.Remove(m.addr.String())
166 }
167 crew.Remove(id)
168}
169
170// SetLine lets a crew member with a linked profile write their own line. It
171// renders as plain text: markdown in it is escaped.
172func SetLine(cur realm, line string) {
173 caller := cur.Previous().Address()
174 v := byAddr.Get(caller.String())
175 if v == nil {
176 panic("no crew profile linked to " + caller.String())
177 }
178 oneLine("line", line, maxLineLen)
179 mustMember(v.(string)).line = line
180}
181
182// IsMember reports whether addr is linked to a crew profile.
183func IsMember(addr address) bool { return byAddr.Has(addr.String()) }
184
185// CrewSize returns the number of members on the roster.
186func CrewSize() int { return crew.Size() }
187
188// link is where a member's card points: home realm, then on-chain profile,
189// then their public link.
190func (m *member) link() string {
191 switch {
192 case m.home != "":
193 return m.home
194 case m.addr != "":
195 return "/u/" + m.addr.String()
196 }
197 return m.url
198}
199
200// teamTitles names the known teams; any other team shows its raw name.
201var teamTitles = map[string]string{
202 "engineering": "Engineering crew",
203 "media": "Media crew",
204 "alumni": "Alumni",
205}
206
207// teamKinds picks the poster style of a team's cards.
208var teamKinds = map[string]string{
209 "media": "member-media",
210 "alumni": "member-alumni",
211}
212
213// crewTable shows the roster team by team, teams in the order their first
214// member joined, members in the order they joined: one poster each, then the
215// lines they wrote themselves.
216func crewTable() string {
217 if crew.Size() == 0 {
218 return "_The roster is empty._"
219 }
220 members := make([]*member, 0, crew.Size())
221 crew.Iterate("", "", func(_ string, v any) bool {
222 m := v.(*member)
223 i := len(members)
224 members = append(members, m)
225 for i > 0 && members[i-1].order > m.order {
226 members[i] = members[i-1]
227 i--
228 }
229 members[i] = m
230 return false
231 })
232
233 var teams []string
234 byTeam := map[string][]*member{}
235 for _, m := range members {
236 if _, ok := byTeam[m.team]; !ok {
237 teams = append(teams, m.team)
238 }
239 byTeam[m.team] = append(byTeam[m.team], m)
240 }
241
242 var b strings.Builder
243 onChain := 0
244 var counts []string
245 for _, team := range teams {
246 group := byTeam[team]
247 title := teamTitles[team]
248 if title == "" {
249 title = team
250 }
251 counts = append(counts, strconv.Itoa(len(group))+" "+strings.ToLower(strings.TrimSuffix(title, " crew")))
252 b.WriteString("**" + title + "**")
253 if team == "alumni" {
254 b.WriteString(": former crew members who built gno with us.")
255 }
256 b.WriteString("\n\n")
257 kind := teamKinds[team]
258 if kind == "" {
259 kind = "member"
260 }
261 for i := 0; i < len(group); i += cardsPerRow {
262 b.WriteString("<gno-columns>\n")
263 for j := i; j < i+cardsPerRow; j++ {
264 if j > i {
265 b.WriteString("<gno-columns-sep />\n")
266 }
267 if j >= len(group) {
268 continue
269 }
270 m := group[j]
271 n := strconv.Itoa(j + 1)
272 if j < 9 {
273 n = "0" + n
274 }
275 label := strings.ToUpper(strings.TrimSuffix(title, " crew")) + " · " + n
276 if m.addr != "" {
277 label += " · ON CHAIN"
278 onChain++
279 }
280 c := card{kind: kind, title: m.handle, meta: m.role, url: m.link(), label: label}
281 b.WriteString("[ + ")](" + c.url + ")\n")
282 }
283 b.WriteString("</gno-columns>\n\n")
284 }
285 }
286
287 b.WriteString("*" + strconv.Itoa(len(members)) + " samurai (" + strings.Join(counts, ", ") + "), " +
288 strconv.Itoa(onChain) + " on chain so far. A card links to its owner's public page until they have a gno.land profile.*")
289
290 var lines strings.Builder
291 for _, m := range members {
292 if m.line != "" {
293 lines.WriteString("- **[" + mdText(m.handle) + "](" + m.link() + ")**: " + mdEscape(m.line) + "\n")
294 }
295 }
296 if lines.Len() > 0 {
297 b.WriteString("\n\n**In their own words.**\n\n" + lines.String())
298 }
299 b.WriteString("\n*Crew members on chain write their own line with [SetLine](" + webPath + "$help&func=SetLine).*")
300 return b.String()
301}