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

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}