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

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("[![" + mdText(m.handle) + "](" + posterURI(c) + ")](" + 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}