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

render.gno

7.90 Kb · 257 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", "to join")
 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(joinPrice(c)),
 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	// Finding 325-cost-at-caps-1: this rendered every proposal a crew has
196	// ever had, and proposing is open to every member with no delete path.
197	// Newest first already, so the bound is on where the walk stops.
198	total := len(ids)
199	stop := 0
200	if total > IndexProposals {
201		stop = total - IndexProposals
202	}
203	t := ui.NewTable("#", "proposal", "state", "yes", "no")
204	for i := total - 1; i >= stop; i-- {
205		p, ok := crews.Proposal(ids[i])
206		if !ok {
207			continue
208		}
209		t.Row(
210			md.Link("#"+ids[i].String(), proposalURL(ids[i])),
211			// Cut on a rune boundary first, escape once after: escaping
212			// first and cutting after can strand a lone backslash.
213			ui.Cell(ui.ShortN(p.Text, crew.ExcerptLen, 0)),
214			state(p, now),
215			strconv.FormatInt(p.Yes, 10),
216			strconv.FormatInt(p.No, 10),
217		)
218	}
219	out := t.OrEmpty("Nothing has been proposed yet.")
220	if total > IndexProposals {
221		out += md.Italic("Showing the newest "+strconv.Itoa(IndexProposals)+" of "+
222			strconv.Itoa(total)+" proposals.") + "\n"
223	}
224	return out
225}
226
227// state is the one word a proposal is in at height now.
228func state(p *crew.Proposal, now int64) string {
229	switch {
230	case p.Open(now):
231		return "open until " + strconv.FormatInt(p.Deadline, 10)
232	case !p.Closed:
233		return "awaiting a close"
234	case p.Passed:
235		return "passed"
236	default:
237		return "failed"
238	}
239}
240
241// crewURL is the gnoweb path of one crew.
242func crewURL(id store.ID) string { return realmURL() + ":crew/" + id.String() }
243
244// proposalURL is the gnoweb path of one proposal.
245func proposalURL(id store.ID) string { return realmURL() + ":proposal/" + id.String() }
246
247// realmURL is this realm's gnoweb path. The chain domain is the first element
248// of a package path and a gnoweb path is the rest of it, so this is a prefix
249// strip and not a hostname the realm has to know.
250func realmURL() string {
251	for i := 0; i < len(realmPath); i++ {
252		if realmPath[i] == '/' {
253			return realmPath[i:]
254		}
255	}
256	return "/" + realmPath
257}