render.gno
10.81 Kb · 326 lines
1package reputation
2
3import (
4 "net/url"
5 "strconv"
6 "strings"
7
8 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/trust/v0"
9 "gno.land/p/nt/markdown/sanitize/v0"
10 "gno.land/p/nt/ufmt/v0"
11)
12
13const (
14 realmURL = "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
15 pageSize = 20
16 boardSize = 25
17)
18
19// Render serves the reputation pages:
20//
21// "" leaderboard and latest records
22// u/<address> contributor profile
23// record/<id> a single record
24// records every record, newest first (?page=N)
25// seeds seed reviewers
26// about scoring rules
27func Render(path string) string {
28 route, query := splitQuery(path)
29 parts := strings.Split(route, "/")
30 switch {
31 case parts[0] == "api":
32 return renderAPI(route, query)
33 case route == "":
34 return renderHome()
35 case len(parts) == 2 && parts[0] == "u":
36 return renderProfile(address(parts[1]))
37 case len(parts) == 2 && parts[0] == "record":
38 id, err := strconv.ParseUint(parts[1], 10, 64)
39 if err != nil {
40 return notFound()
41 }
42 return renderRecord(id)
43 case route == "records":
44 return renderRecords(pageOf(query))
45 case route == "seeds":
46 return renderSeeds()
47 case route == "about":
48 return renderAbout()
49 }
50 return notFound()
51}
52
53func renderHome() string {
54 var b strings.Builder
55 b.WriteString("# Guildhall Reputation\n\n")
56 b.WriteString("Reputation earned from paid, reviewed work. Every point traces back to a record, and every record to the people who vouched for it.\n\n")
57 b.WriteString(ufmt.Sprintf("**%d** records · **%d** ranked contributors · **%d** seed reviewers · [scoring rules](%s:about) · [all records](%s:records)\n\n",
58 records.Size(), leaderboard.Size(), seeds.Size(), realmURL, realmURL))
59
60 b.WriteString("## Leaderboard\n\n")
61 top := Top(boardSize)
62 if len(top) == 0 {
63 b.WriteString("_No reputation has been earned yet. Complete a [bounty](/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounties) to be the first._\n\n")
64 } else {
65 b.WriteString("| # | Contributor | Score | Level | Work | Reviews |\n|---:|---|---:|---|---:|---:|\n")
66 for i, s := range top {
67 b.WriteString(ufmt.Sprintf("| %d | %s | %d | %s | %d | %d |\n",
68 i+1, userLink(s.Addr), s.Score, trust.Level(s.Score), CompletedCount(s.Addr), ReviewCount(s.Addr)))
69 }
70 b.WriteString("\n")
71 }
72
73 b.WriteString("## Latest records\n\n")
74 b.WriteString(recordTable(latest(0, 10)))
75 b.WriteString("\n## Use it in your realm\n\n")
76 b.WriteString("```go\nimport \"gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation\"\n\nreputation.AssertScore(cur.Previous().Address(), 300)\n```\n")
77 return b.String()
78}
79
80func renderProfile(addr address) string {
81 if !addr.IsValid() {
82 return notFound()
83 }
84 var b strings.Builder
85 b.WriteString(ufmt.Sprintf("# %s\n\n", addr.String()))
86 p := getProfile(addr)
87 if p == nil {
88 b.WriteString("_No recorded work or reviews yet._\n\n")
89 b.WriteString(ufmt.Sprintf("[Browse open bounties](/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounties?status=open) · [Leaderboard](%s)\n", realmURL))
90 return b.String()
91 }
92
93 rank := "unranked"
94 if r := Rank(addr); r > 0 {
95 rank = "#" + strconv.Itoa(r)
96 }
97 seed := ""
98 if IsSeed(addr) {
99 seed = " · seed reviewer"
100 }
101 b.WriteString(ufmt.Sprintf("**%d** points · %s · %s%s\n\n", p.Score, trust.Level(p.Score), rank, seed))
102 b.WriteString(ufmt.Sprintf("- Completed work: **%d**\n- Reviews given: **%d**\n", len(p.Records), len(p.Reviews)))
103 if p.earned != nil && p.earned.Size() > 0 {
104 var parts []string
105 p.earned.Iterate("", "", func(k string, v any) bool {
106 parts = append(parts, formatAmount(v.(int64), k))
107 return false
108 })
109 b.WriteString("- Earned: " + strings.Join(parts, ", ") + "\n")
110 }
111 if !p.First.IsZero() {
112 b.WriteString("- Active since: " + p.First.Format("2006-01-02") + "\n")
113 }
114 b.WriteString(ufmt.Sprintf("- Bounties: [%s](/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounties:u/%s)\n\n", "view", addr.String()))
115
116 b.WriteString("## Work\n\n")
117 b.WriteString(recordTable(RecordsOf(addr, 0, pageSize)))
118 b.WriteString("\n## Reviews\n\n")
119 b.WriteString(recordTable(ReviewsOf(addr, 0, pageSize)))
120 return b.String()
121}
122
123func renderRecord(id uint64) string {
124 r := getRecord(id)
125 if r == nil {
126 return notFound()
127 }
128 var b strings.Builder
129 b.WriteString(ufmt.Sprintf("# Record #%d\n\n", r.ID))
130 if r.Voided {
131 b.WriteString("> **Voided:** " + sanitize.InlineText(r.VoidReason) + "\n\n")
132 }
133 b.WriteString("**" + sanitize.InlineText(r.Title) + "**\n\n")
134 b.WriteString("| | |\n|---|---|\n")
135 b.WriteString("| Worker | " + userLink(r.Worker) + " |\n")
136 b.WriteString("| Funded by | " + userLink(r.Poster) + " |\n")
137 b.WriteString("| Paid | " + formatAmount(r.Amount, r.Denom) + " |\n")
138 if r.ShareBps < trust.MaxBps {
139 b.WriteString(ufmt.Sprintf("| Share of milestone | %s |\n", bpsPercent(r.ShareBps)))
140 }
141 b.WriteString(ufmt.Sprintf("| Points | %d |\n", r.Points))
142 if r.Link != "" {
143 b.WriteString("| Work | " + linkOrText(r.Link) + " |\n")
144 }
145 b.WriteString("| Source | " + sourceLink(r) + " |\n")
146 b.WriteString("| Released by | " + userLink(r.SignedBy) + " |\n")
147 b.WriteString(ufmt.Sprintf("| Block | %d · %s |\n", r.Height, r.At.UTC().Format("2006-01-02 15:04 UTC")))
148 if r.Arbitrated {
149 b.WriteString("| Settled by | arbitration |\n")
150 }
151
152 b.WriteString("\n## Vouched for by\n\n| Reviewer | Trust at approval |\n|---|---:|\n")
153 for i, rv := range r.Reviewers {
154 t := int64(0)
155 if i < len(r.ReviewerTrust) {
156 t = r.ReviewerTrust[i]
157 }
158 b.WriteString("| " + userLink(rv) + " | " + bpsPercent(t) + " |\n")
159 }
160 return b.String()
161}
162
163func renderRecords(page int) string {
164 var b strings.Builder
165 b.WriteString("# All records\n\n")
166 b.WriteString(recordTable(latest((page-1)*pageSize, pageSize)))
167 b.WriteString("\n" + pager("records", page, records.Size()))
168 return b.String()
169}
170
171func renderSeeds() string {
172 var b strings.Builder
173 b.WriteString("# Seed reviewers\n\n")
174 b.WriteString("Seeds carry full trust when they approve work. Everyone else earns trust by completing work that trusted people approved. ")
175 b.WriteString(ufmt.Sprintf("Seeds are curated by %s.\n\n", userLink(Admin())))
176 if seeds.Size() == 0 {
177 b.WriteString("_No seeds yet._\n")
178 return b.String()
179 }
180 seeds.Iterate("", "", func(k string, _ any) bool {
181 b.WriteString("- " + userLink(address(k)) + "\n")
182 return false
183 })
184 return b.String()
185}
186
187func renderAbout() string {
188 p := params
189 var b strings.Builder
190 b.WriteString("# How reputation is scored\n\n")
191 b.WriteString("1. Reputation is only created when escrowed funds are released for finished work. Writing records is restricted to allowlisted realms:\n")
192 writers.Iterate("", "", func(k string, _ any) bool {
193 b.WriteString(" - [" + k + "](" + strings.TrimPrefix(k, "gno.land") + ")\n")
194 return false
195 })
196 b.WriteString(ufmt.Sprintf("2. A record is worth up to **%d** points, scaled by the average trust of the reviewers who approved it.\n", p.Base))
197 b.WriteString(ufmt.Sprintf("3. Seed reviewers carry full trust. Anyone else carries trust in proportion to their own score, reaching full trust at **%d** points.\n", p.TrustThreshold))
198 b.WriteString(ufmt.Sprintf("4. Reviewers with no score carry **%s** trust, so a ring of fresh accounts approving each other earns nothing.\n", bpsPercent(p.TrustFloorBps)))
199 b.WriteString(ufmt.Sprintf("5. Work funded by the worker themselves is scaled to **%s**. Nobody can vouch for their own work.\n", bpsPercent(p.SelfFundedFactorBps)))
200 b.WriteString("6. When a dispute ends in a split, the record's points scale with the share the arbiter awarded.\n")
201 b.WriteString("7. Points are fixed when the record is written. The Guildhall admin may void a fraudulent record, which removes its points.\n\n")
202 b.WriteString("## Levels\n\n| Score | Level |\n|---:|---|\n| 0 | Newcomer |\n| 1+ | Apprentice |\n| 300+ | Journeyman |\n| 800+ | Artisan |\n| 2000+ | Master |\n| 5000+ | Grandmaster |\n")
203 return b.String()
204}
205
206func recordTable(rs []Record) string {
207 if len(rs) == 0 {
208 return "_Nothing here yet._\n"
209 }
210 var b strings.Builder
211 b.WriteString("| # | Work | Worker | Paid | Points |\n|---:|---|---|---:|---:|\n")
212 for _, r := range rs {
213 title := sanitize.TableCell(r.Title)
214 if r.Voided {
215 title = "~~" + title + "~~"
216 }
217 b.WriteString(ufmt.Sprintf("| [%d](%s:record/%d) | %s | %s | %s | %d |\n",
218 r.ID, realmURL, r.ID, title, userLink(r.Worker), formatAmount(r.Amount, r.Denom), r.Points))
219 }
220 return b.String()
221}
222
223func latest(offset, limit int) []Record {
224 var out []Record
225 records.ReverseIterateByOffset(offset, limit, func(_ string, v any) bool {
226 out = append(out, copyRecord(v.(*Record)))
227 return false
228 })
229 return out
230}
231
232func userLink(addr address) string {
233 if addr == "" {
234 return "-"
235 }
236 s := addr.String()
237 short := s
238 if len(s) > 14 {
239 short = s[:8] + "…" + s[len(s)-4:]
240 }
241 return "[" + short + "](" + realmURL + ":u/" + s + ")"
242}
243
244func sourceLink(r *Record) string {
245 path := strings.TrimPrefix(r.Source, "gno.land")
246 if r.Source == BountiesPath && r.Ref != "" {
247 id := r.Ref
248 if i := strings.Index(id, "/"); i >= 0 {
249 id = id[:i]
250 }
251 return "[bounty " + sanitize.InlineText(r.Ref) + "](" + path + ":" + url.PathEscape(id) + ")"
252 }
253 return "[" + r.Source + "](" + path + ")"
254}
255
256func linkOrText(s string) string {
257 if u := sanitize.URL(s); u != "" && (strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "/")) {
258 return "[" + sanitize.LinkTitle(s) + "](" + u + ")"
259 }
260 return sanitize.InlineText(s)
261}
262
263func formatAmount(amount int64, denom string) string {
264 if denom == "ugnot" {
265 return decimal(amount, 6) + " GNOT"
266 }
267 return strconv.FormatInt(amount, 10) + " " + sanitize.InlineText(denom)
268}
269
270func decimal(amount int64, decimals int) string {
271 s := strconv.FormatInt(amount, 10)
272 neg := strings.HasPrefix(s, "-")
273 s = strings.TrimPrefix(s, "-")
274 for len(s) <= decimals {
275 s = "0" + s
276 }
277 whole, frac := s[:len(s)-decimals], strings.TrimRight(s[len(s)-decimals:], "0")
278 if neg {
279 whole = "-" + whole
280 }
281 if frac == "" {
282 return whole
283 }
284 return whole + "." + frac
285}
286
287func bpsPercent(bps int64) string {
288 return decimal(bps, 2) + "%"
289}
290
291func pager(route string, page, total int) string {
292 pages := (total + pageSize - 1) / pageSize
293 if pages <= 1 {
294 return ""
295 }
296 var parts []string
297 if page > 1 {
298 parts = append(parts, ufmt.Sprintf("[← newer](%s:%s?page=%d)", realmURL, route, page-1))
299 }
300 parts = append(parts, ufmt.Sprintf("page %d of %d", page, pages))
301 if page < pages {
302 parts = append(parts, ufmt.Sprintf("[older →](%s:%s?page=%d)", realmURL, route, page+1))
303 }
304 return strings.Join(parts, " · ") + "\n"
305}
306
307func splitQuery(path string) (string, url.Values) {
308 route, raw := path, ""
309 if i := strings.Index(path, "?"); i >= 0 {
310 route, raw = path[:i], path[i+1:]
311 }
312 q, _ := url.ParseQuery(raw)
313 return strings.Trim(route, "/"), q
314}
315
316func pageOf(q url.Values) int {
317 p, err := strconv.Atoi(q.Get("page"))
318 if err != nil || p < 1 {
319 return 1
320 }
321 return p
322}
323
324func notFound() string {
325 return "# Not found\n\n[Back to the leaderboard](" + realmURL + ")\n"
326}