render.gno
14.38 Kb · 460 lines
1package campaigns
2
3import (
4 "chain"
5 "net/url"
6 "strconv"
7 "strings"
8
9 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/campaign/v0"
10 "gno.land/p/moul/txlink/v0"
11 "gno.land/p/nt/avl/v0"
12 "gno.land/p/nt/markdown/sanitize/v0"
13 "gno.land/p/nt/ufmt/v0"
14 "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
15 "gno.land/r/nt/grc20reg/v0"
16)
17
18const (
19 realmPath = "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/campaigns"
20 realmURL = "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/campaigns"
21 repURL = "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
22 pageSize = 20
23)
24
25// Render serves the campaigns:
26//
27// "" every campaign (?status=open|closed|all, ?tag=x, ?page=N)
28// <id> a campaign and its claims (?claims=pending|approved|rejected|all, ?page=N)
29// u/<address> campaigns an address posted, reviews or claimed
30// post how to post a campaign
31func Render(path string) string {
32 route, query := splitQuery(path)
33 parts := strings.Split(route, "/")
34 switch {
35 case parts[0] == "api":
36 return renderAPI(route, query)
37 case route == "":
38 return renderBoard(query)
39 case route == "post":
40 return renderPost()
41 case len(parts) == 2 && parts[0] == "u":
42 return renderUser(address(parts[1]), pageOf(query))
43 case len(parts) == 1:
44 id, err := strconv.ParseUint(strings.TrimPrefix(parts[0], "c"), 10, 64)
45 if err != nil {
46 return notFound()
47 }
48 return renderCampaign(id, query)
49 }
50 return notFound()
51}
52
53func renderBoard(q url.Values) string {
54 status := q.Get("status")
55 if status == "" {
56 status = "open"
57 }
58 tag := strings.ToLower(q.Get("tag"))
59 page := pageOf(q)
60
61 var b strings.Builder
62 b.WriteString("# Guildhall Campaigns\n\n")
63 b.WriteString("A fixed reward for each person who does a task, up to a set number of people. A reviewer checks each claim before it pays.\n\n")
64 b.WriteString(ufmt.Sprintf("**%d** open · **%d** closed · **%s** in escrow\n\n",
65 statusCount[campaign.StatusOpen], statusCount[campaign.StatusClosed], escrowSummary()))
66 if paused {
67 b.WriteString("> Posting new campaigns is paused by the council. Existing campaigns are unaffected.\n\n")
68 } else {
69 b.WriteString("[+ Post a campaign](" + realmURL + ":post)\n\n")
70 }
71
72 var tabs []string
73 for _, s := range []string{"open", "closed", "all"} {
74 label := s
75 if s == status {
76 label = "**" + s + "**"
77 }
78 link := realmURL + "?status=" + s
79 if tag != "" {
80 link += "&tag=" + url.QueryEscape(tag)
81 }
82 tabs = append(tabs, "["+label+"]("+link+")")
83 }
84 b.WriteString(strings.Join(tabs, " · "))
85 if tag != "" {
86 b.WriteString(" · tag: `" + sanitize.InlineText(tag) + "` ([clear](" + realmURL + "?status=" + url.QueryEscape(status) + "))")
87 }
88 b.WriteString("\n\n")
89
90 list, more := scan(status, tag, (page-1)*pageSize, pageSize)
91 if len(list) == 0 {
92 b.WriteString("_No campaigns here yet._\n")
93 } else {
94 b.WriteString("| # | Campaign | Reward each | Slots paid | Free | Status | Poster |\n|---:|---|---:|---:|---:|---|---|\n")
95 for _, c := range list {
96 b.WriteString(ufmt.Sprintf("| %d | [%s](%s:%d)%s | %s | %d/%d | %d | %s | %s |\n",
97 c.ID, sanitize.TableCell(c.Title), realmURL, c.ID, tagSuffix(c), formatAmount(c, c.Reward),
98 c.Paid, c.Slots, c.FreeSlots(), c.Status.String(), person(c.Poster)))
99 }
100 }
101
102 var nav []string
103 if page > 1 {
104 nav = append(nav, "[← newer]("+boardLink(status, tag, page-1)+")")
105 }
106 if more {
107 nav = append(nav, "[older →]("+boardLink(status, tag, page+1)+")")
108 }
109 if len(nav) > 0 {
110 b.WriteString("\n" + strings.Join(nav, " · ") + "\n")
111 }
112 return b.String()
113}
114
115// scan lists campaigns newest first, filtered by status ("", "all" or a
116// status name) and tag.
117func scan(status, tag string, skip, limit int) (list []*campaign.Campaign, more bool) {
118 want, filtered := campaign.ParseStatus(status)
119 scanned := 0
120 campaigns.ReverseIterate("", "", func(_ string, v any) bool {
121 scanned++
122 if scanned > apiMaxScan {
123 more = true
124 return true
125 }
126 c := v.(*campaign.Campaign)
127 if filtered && c.Status != want {
128 return false
129 }
130 if tag != "" && !hasTag(c, tag) {
131 return false
132 }
133 if skip > 0 {
134 skip--
135 return false
136 }
137 if len(list) == limit {
138 more = true
139 return true
140 }
141 list = append(list, c)
142 return false
143 })
144 return list, more
145}
146
147func renderCampaign(id uint64, q url.Values) string {
148 c := getCampaign(id)
149 if c == nil {
150 return notFound()
151 }
152 var b strings.Builder
153 b.WriteString(ufmt.Sprintf("# Campaign %d: %s\n\n", c.ID, sanitize.InlineText(c.Title)))
154 b.WriteString(ufmt.Sprintf("**%s** · %s each · %d of %d slots paid · posted %s",
155 strings.ToUpper(c.Status.String()), formatAmount(c, c.Reward), c.Paid, c.Slots, c.CreatedAt.UTC().Format("2006-01-02")))
156 if len(c.Tags) > 0 {
157 var ts []string
158 for _, t := range c.Tags {
159 ts = append(ts, "["+t+"]("+realmURL+"?status=all&tag="+url.QueryEscape(t)+")")
160 }
161 b.WriteString(" · " + strings.Join(ts, " "))
162 }
163 b.WriteString("\n\n")
164
165 if c.Description != "" {
166 b.WriteString(sanitize.Block(c.Description) + "\n\n")
167 }
168 if c.Link != "" {
169 b.WriteString("Reference: " + linkOrText(c.Link) + "\n\n")
170 }
171
172 b.WriteString("| | |\n|---|---|\n")
173 b.WriteString("| Posted by | " + person(c.Poster) + " |\n")
174 var revs []string
175 for _, r := range c.Reviewers {
176 revs = append(revs, person(r))
177 }
178 b.WriteString("| Reviewers (any one approves) | " + strings.Join(revs, ", ") + " |\n")
179 b.WriteString(ufmt.Sprintf("| Slots | %d paid · %d awaiting review · %d free · %d refunded |\n", c.Paid, c.Pending, c.FreeSlots(), c.Refunded))
180 if !c.Deadline.IsZero() {
181 b.WriteString("| Claims close | " + c.Deadline.UTC().Format("2006-01-02 15:04 UTC") + " |\n")
182 }
183 b.WriteString("| Escrow | " + formatAmount(c, c.Escrowed()) + " held · " + formatAmount(c, c.PaidOut()) + " paid |\n")
184 b.WriteString(ufmt.Sprintf("| Reputation | each paid claim is a record at %s weight |\n\n", bps(ClaimShareBps)))
185
186 b.WriteString(renderActions(c))
187
188 filter := q.Get("claims")
189 if filter == "" {
190 filter = "all"
191 }
192 page := pageOf(q)
193 var tabs []string
194 for _, s := range []string{"pending", "approved", "rejected", "all"} {
195 label := s
196 if s == filter {
197 label = "**" + s + "**"
198 }
199 tabs = append(tabs, "["+label+"]("+realmURL+":"+idStr(c)+"?claims="+s+")")
200 }
201 b.WriteString(ufmt.Sprintf("## Claims (%d)\n\n", c.ClaimCount()))
202 b.WriteString(strings.Join(tabs, " · ") + "\n\n")
203
204 var st *campaign.ClaimStatus
205 if s, ok := campaign.ParseClaimStatus(filter); ok {
206 st = &s
207 }
208 list, more := c.Claims(st, (page-1)*pageSize, pageSize)
209 if len(list) == 0 {
210 b.WriteString("_No claims here yet._\n")
211 return b.String()
212 }
213 b.WriteString("| # | Claimant | Proof | Status | |\n|---:|---|---|---|---|\n")
214 for _, cl := range list {
215 status := claimStatus(cl)
216 action := ""
217 if cl.Status == campaign.ClaimPending {
218 n := strconv.Itoa(cl.Number)
219 action = "[approve](" + txlink.NewLink("ApproveClaim").AddArgs("id", idStr(c), "claim", n).URL() + ") · " +
220 "[reject](" + txlink.NewLink("RejectClaim").AddArgs("id", idStr(c), "claim", n, "reason", "").URL() + ")"
221 } else if cl.Status == campaign.ClaimRejected && cl.Reason != "" {
222 action = sanitize.TableCell(cl.Reason)
223 }
224 b.WriteString(ufmt.Sprintf("| %d | %s | %s | %s | %s |\n", cl.Number, person(cl.Claimant), linkOrText(cl.Proof), status, action))
225 }
226 if page > 1 || more {
227 var nav []string
228 if page > 1 {
229 nav = append(nav, ufmt.Sprintf("[← newer](%s:%s?claims=%s&page=%d)", realmURL, idStr(c), filter, page-1))
230 }
231 if more {
232 nav = append(nav, ufmt.Sprintf("[older →](%s:%s?claims=%s&page=%d)", realmURL, idStr(c), filter, page+1))
233 }
234 b.WriteString("\n" + strings.Join(nav, " · ") + "\n")
235 }
236 return b.String()
237}
238
239// renderActions lists the transactions anyone might want to send, as gnoweb
240// transaction links. The chain enforces who may call them.
241func renderActions(c *campaign.Campaign) string {
242 if c.Status != campaign.StatusOpen {
243 return ""
244 }
245 var acts []string
246 if c.FreeSlots() > 0 {
247 acts = append(acts, "[Claim a slot]("+txlink.NewLink("Claim").AddArgs("id", idStr(c), "proof", "").URL()+")")
248 }
249 acts = append(acts, "[Close and refund free slots (poster)]("+txlink.NewLink("Close").AddArgs("id", idStr(c)).URL()+")")
250 return "## Actions\n\n" + strings.Join(acts, " · ") + "\n\n"
251}
252
253func renderUser(addr address, page int) string {
254 if !addr.IsValid() {
255 return notFound()
256 }
257 var b strings.Builder
258 b.WriteString("# Campaigns for " + addr.String() + "\n\n")
259 b.WriteString(ufmt.Sprintf("Reputation: **%d** (%s) · [profile](%s:u/%s)\n\n",
260 reputation.Score(addr), reputation.Level(addr), repURL, addr.String()))
261 v := byUser.Get(addr.String())
262 if v == nil {
263 b.WriteString("_Not involved in any campaign yet._\n")
264 return b.String()
265 }
266 ids := v.(*avl.Tree)
267 b.WriteString("| # | Campaign | Role | Reward each | Their claim | Status |\n|---:|---|---|---:|---|---|\n")
268 ids.ReverseIterateByOffset((page-1)*pageSize, pageSize, func(k string, _ any) bool {
269 cv := campaigns.Get(k)
270 if cv == nil {
271 return false
272 }
273 c := cv.(*campaign.Campaign)
274 mine := "-"
275 if cl := c.ClaimOf(addr); cl != nil {
276 mine = ufmt.Sprintf("#%d %s", cl.Number, cl.Status.String())
277 }
278 b.WriteString(ufmt.Sprintf("| %d | [%s](%s:%d) | %s | %s | %s | %s |\n",
279 c.ID, sanitize.TableCell(c.Title), realmURL, c.ID, strings.Join(roles(c, addr), ", "), formatAmount(c, c.Reward), mine, c.Status.String()))
280 return false
281 })
282 if ids.Size() > page*pageSize {
283 b.WriteString(ufmt.Sprintf("\n[older →](%s:u/%s?page=%d)\n", realmURL, addr.String(), page+1))
284 }
285 return b.String()
286}
287
288func renderPost() string {
289 native := txlink.NewLink("PostCampaign").
290 AddArgs("title", "", "description", "", "link", "", "tags", "", "reviewers", "", "reward", "10000000", "slots", "100", "deadlineDays", "30").
291 SetSend("1000000000ugnot").URL()
292 grc := txlink.NewLink("PostCampaignGRC20").
293 AddArgs("tokenKey", "", "title", "", "reward", "", "slots", "", "deadlineDays", "30").URL()
294
295 var b strings.Builder
296 b.WriteString("# Post a campaign\n\n")
297 b.WriteString("## With GNOT\n\n")
298 b.WriteString("[Open the form](" + native + ") and send exactly **reward × slots**.\n\n")
299 b.WriteString("- **reward**: what each approved claim pays, in the smallest unit (`ugnot`; 10 GNOT is `10000000`).\n")
300 b.WriteString("- **slots**: how many claims can be paid, up to " + strconv.Itoa(campaign.MaxSlots) + ".\n")
301 b.WriteString("- **reviewers**: comma-separated addresses; any one of them can approve a claim. Leave empty to review claims yourself.\n")
302 b.WriteString("- **deadlineDays**: stop taking claims after this many days (0 for none). After it, anyone can close the campaign, which refunds the free slots to you.\n\n")
303 b.WriteString("```sh\ngnokey maketx call -pkgpath " + realmPath + " -func PostCampaign \\\n")
304 b.WriteString(" -args \"Write about Guildhall\" -args \"Post about Guildhall on X with a link to it.\" \\\n")
305 b.WriteString(" -args \"https://guildhall.clockwork.gr\" -args \"social\" -args \"\" \\\n")
306 b.WriteString(" -args 10000000 -args 100 -args 30 \\\n")
307 b.WriteString(" -send 1000000000ugnot -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast KEY\n```\n\n")
308 b.WriteString("## With a GRC20 token\n\n")
309 b.WriteString("1. Approve `" + chain.PackageAddress(realmPath).String() + "` on the token for reward × slots.\n")
310 b.WriteString("2. Call [PostCampaignGRC20](" + grc + ") with the token's [grc20reg](/r/nt/grc20reg/v0) key.\n")
311 return b.String()
312}
313
314func claimStatus(cl *campaign.Claim) string {
315 switch cl.Status {
316 case campaign.ClaimApproved:
317 return "✅ paid"
318 case campaign.ClaimRejected:
319 return "✖️ rejected"
320 }
321 return "🔍 in review"
322}
323
324func roles(c *campaign.Campaign, addr address) []string {
325 var out []string
326 if c.Poster == addr {
327 out = append(out, "poster")
328 }
329 if c.IsReviewer(addr) {
330 out = append(out, "reviewer")
331 }
332 if c.ClaimOf(addr) != nil {
333 out = append(out, "claimant")
334 }
335 return out
336}
337
338func hasTag(c *campaign.Campaign, tag string) bool {
339 for _, t := range c.Tags {
340 if t == tag {
341 return true
342 }
343 }
344 return false
345}
346
347func tagSuffix(c *campaign.Campaign) string {
348 if len(c.Tags) == 0 {
349 return ""
350 }
351 return " `" + strings.Join(c.Tags, "` `") + "`"
352}
353
354func person(addr address) string {
355 if addr == "" {
356 return "-"
357 }
358 s := addr.String()
359 short := s
360 if len(s) > 14 {
361 short = s[:8] + "…" + s[len(s)-4:]
362 }
363 return "[" + short + "](" + repURL + ":u/" + s + ")"
364}
365
366func linkOrText(s string) string {
367 if u := sanitize.URL(s); u != "" && (strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "/")) {
368 return "[" + sanitize.LinkTitle(s) + "](" + u + ")"
369 }
370 return sanitize.InlineText(s)
371}
372
373func formatAmount(c *campaign.Campaign, amount int64) string {
374 symbol, decimals := symbolOf(c.Denom, c.IsGRC20)
375 return decimal(amount, decimals) + " " + sanitize.InlineText(symbol)
376}
377
378func symbolOf(denom string, isGRC20 bool) (string, int) {
379 switch {
380 case isGRC20:
381 if tok := grc20reg.Get(denom); tok != nil {
382 return tok.GetSymbol(), tok.GetDecimals()
383 }
384 case denom == "ugnot":
385 return "GNOT", 6
386 }
387 return denom, 0
388}
389
390func escrowSummary() string {
391 var parts []string
392 escrowed.Iterate("", "", func(k string, v any) bool {
393 if amt := v.(int64); amt > 0 {
394 symbol, decimals := symbolOf(k, grc20reg.Get(k) != nil)
395 parts = append(parts, decimal(amt, decimals)+" "+sanitize.InlineText(symbol))
396 }
397 return false
398 })
399 if len(parts) == 0 {
400 return "nothing"
401 }
402 return strings.Join(parts, " + ")
403}
404
405func decimal(amount int64, decimals int) string {
406 if decimals <= 0 {
407 return strconv.FormatInt(amount, 10)
408 }
409 s := strconv.FormatInt(amount, 10)
410 neg := strings.HasPrefix(s, "-")
411 s = strings.TrimPrefix(s, "-")
412 for len(s) <= decimals {
413 s = "0" + s
414 }
415 whole, frac := s[:len(s)-decimals], strings.TrimRight(s[len(s)-decimals:], "0")
416 if neg {
417 whole = "-" + whole
418 }
419 if frac == "" {
420 return whole
421 }
422 return whole + "." + frac
423}
424
425func bps(v int64) string {
426 return decimal(v, 2) + "%"
427}
428
429func idStr(c *campaign.Campaign) string {
430 return strconv.FormatUint(c.ID, 10)
431}
432
433func boardLink(status, tag string, page int) string {
434 l := realmURL + "?status=" + url.QueryEscape(status) + "&page=" + strconv.Itoa(page)
435 if tag != "" {
436 l += "&tag=" + url.QueryEscape(tag)
437 }
438 return l
439}
440
441func splitQuery(path string) (string, url.Values) {
442 route, raw := path, ""
443 if i := strings.Index(path, "?"); i >= 0 {
444 route, raw = path[:i], path[i+1:]
445 }
446 q, _ := url.ParseQuery(raw)
447 return strings.Trim(route, "/"), q
448}
449
450func pageOf(q url.Values) int {
451 p, err := strconv.Atoi(q.Get("page"))
452 if err != nil || p < 1 {
453 return 1
454 }
455 return p
456}
457
458func notFound() string {
459 return "# Not found\n\n[Back to campaigns](" + realmURL + ")\n"
460}