render.gno
7.44 Kb · 244 lines
1package crews
2
3import (
4 "strconv"
5
6 "chain/runtime"
7
8 "gno.land/p/moul/kit/num/v0"
9 "gno.land/p/moul/kit/store/v0"
10 "gno.land/p/moul/kit/ui/v0"
11 "gno.land/p/moul/md/v0"
12 "gno.land/p/moul/realmpath/v0"
13 "gno.land/p/moul/x/social/crew/v0"
14 "gno.land/p/moul/xmath/v1"
15)
16
17// Render routes three views: the index, one crew, and one proposal.
18func Render(path string) string {
19 req := realmpath.Parse(path)
20
21 switch req.PathPart(0) {
22 case "":
23 return renderIndex()
24 case "crew":
25 return renderCrew(req.PathPart(1))
26 case "proposal":
27 return renderProposal(req.PathPart(1))
28 default:
29 return md.H1("Not found") + "No such view: " + ui.Inline(req.Path)
30 }
31}
32
33func renderIndex() string {
34 out := md.H1("Crews")
35 out += "Three to fifteen people with a shared pot, a way to decide, and a way to " +
36 "leave with their share. One transaction to start one.\n\n"
37
38 t := ui.NewTable("crews", "proposals", "owed to people who left")
39 t.Row(
40 strconv.Itoa(crews.Len()),
41 strconv.Itoa(crews.ProposalCount()),
42 num.GNOTf(crews.TotalOwed()),
43 )
44 out += t.String()
45
46 out += md.H2("The crews")
47 out += crewTable(crews.List(IndexCrews))
48
49 out += "\n" + ui.Action("Start a crew", "Create", "name", "") + "\n"
50
51 out += md.H2("How a share works")
52 out += md.BulletList([]string{
53 "A share is the vote weight and the claim on the treasury at once. There is no second token.",
54 "Joining mints shares at what a share is worth NOW: " +
55 md.InlineCode("amount * totalShares / treasury") +
56 ", rounded down, so a late joiner cannot buy into value the others built.",
57 "Ragequitting burns every share you hold and credits you " +
58 md.InlineCode("shares * treasury / totalShares") +
59 ", rounded down. Nobody has to agree.",
60 "Both round in the crew's favour. The last member out holds every share, " +
61 "so the treasury still empties to the last ugnot.",
62 "A proposal is advisory text. Passing one records agreement and executes nothing.",
63 })
64
65 out += md.H2("Custody")
66 out += "One realm address holds every crew's treasury, with per-crew accounting " +
67 "inside. A bug in that accounting is a bug across crews, not inside one. " +
68 "The fix is one realm per crew, and it is v1.\n"
69
70 return out
71}
72
73func renderCrew(raw string) string {
74 id, ok := store.ParseID(raw)
75 if !ok {
76 return md.H1("Crews") + "Not a crew id: " + ui.Inline(raw)
77 }
78 c, found := crews.Get(id)
79 if !found {
80 return md.H1("Crews") + "No crew #" + id.String()
81 }
82
83 out := md.H1(ui.Inline(c.Name))
84 out += md.Italic("crew #"+id.String()+" · opened at block "+
85 strconv.FormatInt(c.CreatedAt, 10)) + "\n\n"
86
87 t := ui.NewTable("members", "shares", "treasury", "per share")
88 t.Row(
89 strconv.Itoa(c.MemberCount())+" of "+strconv.Itoa(crew.MaxMembers),
90 strconv.FormatInt(c.TotalShares, 10),
91 num.GNOTf(c.Treasury),
92 num.GNOTf(c.ValuePerShare()),
93 )
94 out += t.String()
95
96 out += md.H2("Members")
97 if c.MemberCount() == 0 {
98 out += ui.Empty("Everybody has left. The next member in pays the opening price again.")
99 } else {
100 m := ui.NewTable("member", "shares", "weight", "worth")
101 for _, who := range c.Members() {
102 shares := c.SharesOf(who)
103 m.Row(
104 ui.Addr(who),
105 strconv.FormatInt(shares, 10),
106 num.Pct(xmath.MulDiv(shares, 10000, c.TotalShares)),
107 num.GNOTf(xmath.MulDiv(shares, c.Treasury, c.TotalShares)),
108 )
109 }
110 out += m.String()
111 }
112
113 out += md.H2("Proposals")
114 out += proposalTable(c.Proposals())
115 out += "\nA proposal is advisory text. Passing one records that the crew agreed " +
116 "by share weight, and executes nothing.\n"
117
118 out += "\n" + ui.Join(" · ",
119 ui.Action("Join", "Join", "crewID", id.String()),
120 ui.Action("Fund", "Fund", "crewID", id.String()),
121 ui.Action("Propose", "Propose", "crewID", id.String(), "text", ""),
122 ui.Action("Ragequit", "Ragequit", "crewID", id.String()),
123 ) + "\n"
124 return out
125}
126
127func renderProposal(raw string) string {
128 pid, ok := store.ParseID(raw)
129 if !ok {
130 return md.H1("Crews") + "Not a proposal id: " + ui.Inline(raw)
131 }
132 p, found := crews.Proposal(pid)
133 if !found {
134 return md.H1("Crews") + "No proposal #" + pid.String()
135 }
136
137 out := md.H1("Proposal #" + pid.String())
138 out += md.Blockquote(ui.Inline(p.Text)) + "\n"
139
140 crewName := "crew #" + p.CrewID.String()
141 if c, ok := crews.Get(p.CrewID); ok {
142 crewName = c.Name
143 }
144 out += md.Italic("by "+ui.Addr(p.Author)+" · in "+
145 md.Link(ui.ShortN(crewName, crew.ExcerptLen, 0), crewURL(p.CrewID))) + "\n\n"
146
147 now := runtime.ChainHeight()
148 t := ui.NewTable("state", "yes", "no", "voters")
149 t.Row(
150 state(p, now),
151 strconv.FormatInt(p.Yes, 10),
152 strconv.FormatInt(p.No, 10),
153 strconv.Itoa(p.Voters()),
154 )
155 out += t.String()
156
157 if p.Open(now) {
158 out += "\n" + ui.Join(" · ",
159 ui.Action("Vote yes", "Vote", "proposalID", pid.String(), "yes", "true"),
160 ui.Action("Vote no", "Vote", "proposalID", pid.String(), "yes", "false"),
161 ) + "\n"
162 } else if !p.Closed {
163 out += "\n" + ui.Action("Close it", "Close", "proposalID", pid.String()) + "\n"
164 }
165 return out
166}
167
168// crewTable lists crews, newest first.
169func crewTable(items []crew.Listing) string {
170 t := ui.NewTable("crew", "members", "shares", "treasury")
171 for _, it := range items {
172 t.Row(
173 // ShortN truncates without escaping and md.Link escapes what it
174 // is given: ui.Inline here would escape twice and render the
175 // backslashes. A pipe is what md.Link's escaper does NOT cover,
176 // which is why crew.ValidName refuses one at write time.
177 md.Link(ui.ShortN(it.Crew.Name, crew.ExcerptLen, 0), crewURL(it.ID)),
178 strconv.Itoa(it.Crew.MemberCount()),
179 strconv.FormatInt(it.Crew.TotalShares, 10),
180 num.GNOTf(it.Crew.Treasury),
181 )
182 }
183 return t.OrEmpty("No crews yet.")
184}
185
186// proposalTable lists a crew's proposals, newest first.
187//
188// The link title is the id and not the text, which is the opposite of
189// crewTable and deliberate: a proposal's text is free prose and may contain a
190// pipe, md.Link's escaper leaves pipes alone, and a pipe in a link title inside
191// a table cell opens a column. So the chrome carries the link and the prose
192// goes through ui.Cell, which is the escaper that knows it is in a table.
193func proposalTable(ids []store.ID) string {
194 now := runtime.ChainHeight()
195 t := ui.NewTable("#", "proposal", "state", "yes", "no")
196 for i := len(ids) - 1; i >= 0; i-- {
197 p, ok := crews.Proposal(ids[i])
198 if !ok {
199 continue
200 }
201 t.Row(
202 md.Link("#"+ids[i].String(), proposalURL(ids[i])),
203 // Cut on a rune boundary first, escape once after: escaping
204 // first and cutting after can strand a lone backslash.
205 ui.Cell(ui.ShortN(p.Text, crew.ExcerptLen, 0)),
206 state(p, now),
207 strconv.FormatInt(p.Yes, 10),
208 strconv.FormatInt(p.No, 10),
209 )
210 }
211 return t.OrEmpty("Nothing has been proposed yet.")
212}
213
214// state is the one word a proposal is in at height now.
215func state(p *crew.Proposal, now int64) string {
216 switch {
217 case p.Open(now):
218 return "open until " + strconv.FormatInt(p.Deadline, 10)
219 case !p.Closed:
220 return "awaiting a close"
221 case p.Passed:
222 return "passed"
223 default:
224 return "failed"
225 }
226}
227
228// crewURL is the gnoweb path of one crew.
229func crewURL(id store.ID) string { return realmURL() + ":crew/" + id.String() }
230
231// proposalURL is the gnoweb path of one proposal.
232func proposalURL(id store.ID) string { return realmURL() + ":proposal/" + id.String() }
233
234// realmURL is this realm's gnoweb path. The chain domain is the first element
235// of a package path and a gnoweb path is the rest of it, so this is a prefix
236// strip and not a hostname the realm has to know.
237func realmURL() string {
238 for i := 0; i < len(realmPath); i++ {
239 if realmPath[i] == '/' {
240 return realmPath[i:]
241 }
242 }
243 return "/" + realmPath
244}