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

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}