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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}