render.gno
28.62 Kb · 930 lines
1package commondao
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/p/jeronimoalbi/pager/v0"
9 "gno.land/p/moul/md/v0"
10 "gno.land/p/moul/mdtable/v0"
11 "gno.land/p/moul/realmpath/v0"
12 "gno.land/p/nt/addrset/v0"
13 "gno.land/p/nt/commondao/v0"
14 "gno.land/p/nt/markdown/sanitize/v0"
15 "gno.land/p/nt/mux/v0"
16 "gno.land/p/nt/ufmt/v0"
17
18 "gno.land/r/sys/users"
19)
20
21const dateFormat = "Mon, 02 Jan 2006 03:04pm MST"
22
23// trustedMarkdownBody marks proposal definitions whose Body returns
24// markdown the definition assembled itself (escaping any embedded user
25// fields with md.EscapeText). Those bodies are rendered verbatim. Every
26// other body — including the execution kind, whose Body is the proposer's
27// raw text — is treated as user-supplied and escaped by the renderer. A
28// definition from an imported package cannot implement this realm-local
29// marker either, so it is escaped too. The render layer is the single escape
30// point: definitions return raw text and opt out only when they render their
31// own markup.
32type trustedMarkdownBody interface {
33 isTrustedMarkdownBody()
34}
35
36func Render(path string) string {
37 router := mux.NewRouter()
38 router.HandleFunc("", renderHome)
39 router.HandleFunc("{daoID}", renderDAO)
40 router.HandleFunc("{daoID}/settings", renderSettings)
41 router.HandleFunc("{daoID}/bylaws", renderBylaws)
42 router.HandleFunc("{daoID}/proposals", renderProposalsList)
43 router.HandleFunc("{daoID}/proposals/{proposalID}", renderProposal)
44 router.HandleFunc("{daoID}/proposals/{proposalID}/vote/{address}", renderProposalVote)
45 return router.Render(path)
46}
47
48func renderHome(res *mux.ResponseWriter, req *mux.Request) {
49 res.Write(md.H1("Common DAO"))
50 res.Write(md.HorizontalRule())
51 res.Write(ufmt.Sprintf(
52 md.Paragraph("This realm can be used to create CommonDAO instances based on %s package."),
53 md.Link("commondao", "/p/nt/commondao/v0/"),
54 ))
55
56 // Nothing listed: render just the header. pager.New reports a total of
57 // zero as "invalid page number" for every page including page 1, which
58 // its own Picker links to.
59 if listed.Size() == 0 {
60 return
61 }
62
63 // Paginate over the LISTED set, not every DAO: listing is opt-in and
64 // off by default, so a window over all DAOs could be entirely unlisted
65 // — rendering an empty page that also drops the pager (the len==0 guard
66 // below), stranding the listed DAOs on unreachable later pages.
67 pages, err := pager.New(req.RawPath, listed.Size(), pager.WithPageSize(10))
68 if err != nil {
69 // Its own block: an unterminated error line glues itself onto
70 // whatever is written next (renderCouncil returns into renderDAO,
71 // which goes straight on to write the Treasury heading).
72 res.Write(md.Paragraph(md.EscapeText(err.Error())))
73 return
74 }
75
76 var items []string
77 listed.ReverseIterateByOffset(pages.Offset(), pages.PageSize(), func(_ string, v any) bool {
78 if dao := getDAO(v.(uint64)); dao != nil {
79 items = append(items, md.Link(dao.Name(), daoURL(dao.ID())))
80 }
81 return false
82 })
83
84 if len(items) == 0 {
85 return
86 }
87
88 res.Write(md.Paragraph("Here is a list of some of the DAOs that were created:"))
89 res.Write(md.Paragraph(md.BulletList(items)))
90
91 if pages.HasPages() {
92 res.Write(md.Paragraph(pager.Picker(pages)))
93 }
94}
95
96func renderDAO(res *mux.ResponseWriter, req *mux.Request) {
97 dao := mustGetDAOFromRequest(req)
98
99 // Render header messages
100 if dao.IsDeleted() {
101 res.Write(md.Blockquote("⚠ This DAO has been dissolved"))
102 // A dissolved DAO with no live proper ancestor can never move funds
103 // again (its own council is gone and no ancestor can claw back), so
104 // warn that late deposits to its treasury address are unrecoverable.
105 if !hasLiveProperAncestor(dao) {
106 res.Write(md.Blockquote("⚠ No live ancestor DAO remains: coins sent to this treasury address can no longer be recovered."))
107 }
108 }
109
110 // Render header (Charter: purpose is required, description optional)
111 res.Write(md.H1(md.EscapeText(dao.Name())))
112 res.Write(md.Paragraph(md.Bold("Purpose:") + " " + md.EscapeText(dao.Purpose())))
113 if desc := dao.Description(); desc != "" {
114 res.Write(md.Paragraph(md.EscapeText(desc)))
115 }
116
117 // Render main menu
118 menu := []string{
119 md.Link("View Proposals", daoProposalsURL(dao.ID())),
120 md.Link("View Settings", settingsURL(dao.ID())),
121 }
122
123 if set := bylawsView(dao.ID()); set != nil && set.Size() > 0 {
124 menu = append(menu, md.Link("View Bylaws & Mandates", bylawsURL(dao.ID())))
125 }
126
127 if parentDAO := dao.Parent(); parentDAO != nil {
128 menu = append(menu, md.Link("Go to Parent DAO", daoURL(parentDAO.ID())))
129 }
130
131 res.Write(md.Paragraph(strings.Join(menu, " • ")))
132 res.Write(md.HorizontalRule())
133
134 // Render council
135 council := dao.Council()
136 if council.Size() == 0 {
137 res.Write(md.Paragraph(md.Bold("⚠ The DAO has no council members")))
138 } else {
139 renderCouncil(res, req.RawPath, council)
140 }
141
142 renderTreasury(res, dao)
143
144 // Render organization tree
145 if dao.ChildrenCount() > 0 {
146 r := parseRealmPath(req.RawPath)
147 dissolvedVisible := r.Query.Has("dissolved") || dao.IsDeleted()
148 res.Write(md.H2("Tree"))
149
150 // Render toggle only when DAO is not dissolved,
151 // otherwise render the whole tree when DAO is dissolved
152 if !dao.IsDeleted() {
153 var toggleLink string
154 if dissolvedVisible {
155 r.Query.Del("dissolved")
156 toggleLink = md.Link("hide", r.String())
157 } else {
158 r.Query.Add("dissolved", "")
159 toggleLink = md.Link("show", r.String())
160 }
161
162 res.Write(md.Paragraph("Dissolved: " + toggleLink))
163 }
164
165 renderTree(res, dao, "", dissolvedVisible)
166 }
167
168 // Render latest proposals
169 if dao.ActiveProposalsSize() > 0 {
170 res.Write(md.H2("Latest Proposals"))
171 dao.IterateActiveProposals(0, 3, true, func(p *commondao.Proposal) bool {
172 renderProposalsListItem(res, dao, p)
173 return false
174 })
175 }
176
177 // Render proposal creation links
178 if !dao.IsDeleted() {
179 renderCreateProposalSection(dao, res)
180 }
181}
182
183func renderCreateProposalSection(dao *commondao.CommonDAO, res *mux.ResponseWriter) {
184 daoID := dao.ID()
185
186 // One entry per link-encodable catalog kind, in display order (the
187 // execution kind has no entry: its closure cannot be encoded in a
188 // help link). Only the kinds registered on the DAO are rendered.
189 entries := []struct {
190 kind string
191 text string
192 }{
193 {
194 kindText,
195 md.Paragraph(textProposalLink(daoID)) +
196 md.Paragraph(
197 "This type of proposal is also known as text proposal which can be used for example "+
198 "to get consensus on initiatives without actually making any change on-chain.",
199 ),
200 },
201 {
202 kindCouncilUpdate,
203 md.Paragraph(updateCouncilLink(daoID)) +
204 md.Paragraph(
205 "This type of proposal can be used to add new council members to this DAO "+
206 "and also to remove existing ones.",
207 ) +
208 md.Paragraph(
209 "A single proposal allows new council members to be added and any number of existing ones "+
210 "removed within the same proposal.",
211 ),
212 },
213 {
214 kindAncestorCouncilUpdate,
215 md.Paragraph(ancestorCouncilUpdateLink(daoID)) +
216 md.Paragraph(
217 "This type of proposal lets this DAO, as an ancestor, add or "+
218 "remove council members of one of its descendant DAOs — the "+
219 "rescue path for a descendant whose council is stuck or empty.",
220 ),
221 },
222 {
223 kindDissolve,
224 md.Paragraph(dissolveSubDAOLink(daoID)) +
225 md.Paragraph(
226 "This type of proposal can be used to dissolve DAOs and SubDAOs.",
227 ) +
228 md.Paragraph(
229 "Dissolving a DAO can't be undone, once the dissolution proposal passes "+
230 "and is executed DAO will be readonly.",
231 ),
232 },
233 {
234 kindTreasurySpend,
235 md.Paragraph(treasurySpendLink(daoID)) +
236 md.Paragraph(
237 "This type of proposal sends coins from the DAO's own treasury "+
238 "when it passes and is executed.",
239 ),
240 },
241 {
242 kindTreasuryClawback,
243 md.Paragraph(treasuryClawbackLink(daoID)) +
244 md.Paragraph(
245 "This type of proposal sweeps a descendant DAO's treasury to "+
246 "the descendant's parent DAO.",
247 ),
248 },
249 {
250 kindTreasuryFreeze,
251 md.Paragraph(treasuryFreezeLink(daoID)) +
252 md.Paragraph(
253 "This type of proposal freezes or unfreezes a descendant DAO's "+
254 "treasury. While frozen, no funds can leave the treasury.",
255 ),
256 },
257 {
258 kindSubDAO,
259 md.Paragraph(newSubDAOLink(daoID)) +
260 md.Paragraph(
261 "This type of proposal is used to create SubDAOs, "+
262 "which are used to create tree based DAOs.",
263 ),
264 },
265 {
266 kindManageKinds,
267 md.Paragraph(registerKindLink(daoID)+" • "+deregisterKindLink(daoID)) +
268 md.Paragraph(
269 "This type of proposal registers one of the realm's catalog "+
270 "proposal kinds on this DAO by name, or deregisters a kind. "+
271 "Deregistering a kind blocks new proposals of that kind while "+
272 "in-flight ones still vote and execute. The manage-kinds kind "+
273 "itself cannot be deregistered.",
274 ),
275 },
276 {
277 kindAmendBylaws,
278 md.Paragraph(amendBylawsLink(daoID)) +
279 md.Paragraph(
280 "This type of proposal adds, amends or removes one of the DAO's "+
281 "bylaws documents with a diff patch pinned to the current "+
282 "document text (the mandates folder is reserved: mandates are "+
283 "set from above, not by the council). Build the payload with "+
284 "the AmendBylawsPayload query function.",
285 ),
286 },
287 }
288
289 var cols []string
290 for _, e := range entries {
291 if dao.HasKind(e.kind) {
292 cols = append(cols, e.text)
293 }
294 }
295
296 if len(cols) == 0 {
297 return
298 }
299
300 res.Write(md.H2("Create Proposal"))
301 res.Write(md.Paragraph("These are the proposal supported by this DAO:"))
302 // Wrap at 3 per row: gnoweb renders at most 4 columns per row, and a
303 // DAO with every kind registered has up to ten cards.
304 res.Write(md.ColumnsN(cols, 3, false))
305}
306
307func renderTreasury(res *mux.ResponseWriter, dao *commondao.CommonDAO) {
308 res.Write(md.H2("Treasury"))
309 res.Write(md.BulletItem("Address: " + md.EscapeText(string(dao.Address()))))
310
311 balance := treasuryBalance(dao)
312 if balance.IsZero() {
313 res.Write(md.BulletItem("Balance: (empty)"))
314 } else {
315 res.Write(md.BulletItem("Balance: " + md.EscapeText(balance.String())))
316 }
317
318 if dao.IsTreasuryFrozen() {
319 // Blockquote, not a bold paragraph: a paragraph directly after the
320 // balance bullet is a lazy continuation of that list item, so the
321 // warning would render inside the bullet instead of as its own
322 // block. Matches the dissolved-DAO banners.
323 frozen := "⚠ The treasury is frozen: no proposal can move funds until a proper ancestor DAO unfreezes it."
324 if dao.HasKind(kindExecution) {
325 // Freeze gates the realm's own spending paths. A closure from a
326 // past execution proposal may hold a retained banker, which
327 // reaches the bank keeper without passing through them.
328 frozen += " This DAO has the execution kind registered, so a" +
329 " capability retained by an earlier execution proposal could" +
330 " still move funds; freeze alone is not containment here."
331 }
332 res.Write(md.Blockquote(frozen))
333 }
334}
335
336func renderCouncil(res *mux.ResponseWriter, path string, council *addrset.ReadonlySet) {
337 pages, err := pager.New(path, council.Size(), pager.WithPageQueryParam("members"), pager.WithPageSize(8))
338 if err != nil {
339 // Its own block: an unterminated error line glues itself onto
340 // whatever is written next (renderCouncil returns into renderDAO,
341 // which goes straight on to write the Treasury heading).
342 res.Write(md.Paragraph(md.EscapeText(err.Error())))
343 return
344 }
345
346 table := mdtable.Table{Headers: []string{"Council"}}
347 council.IterateByOffset(pages.Offset(), pages.PageSize(), func(addr address) bool {
348 table.Append([]string{userLink(addr)})
349 return false
350 })
351
352 res.Write(md.Paragraph(table.String()))
353
354 if pages.HasPages() {
355 res.Write(md.Paragraph(pager.Picker(pages)))
356 }
357}
358
359func renderTree(res *mux.ResponseWriter, dao *commondao.CommonDAO, indent string, showDissolved bool) {
360 daoLink := md.Link(dao.Name(), daoURL(dao.ID()))
361 if dao.IsDeleted() {
362 // Strikethough dissolved DAO names
363 daoLink = md.Strikethrough(daoLink)
364 }
365
366 res.Write(indent + md.BulletItem(daoLink))
367
368 indent += " "
369 dao.IterateChildren(func(subDAO *commondao.CommonDAO) bool {
370 if showDissolved || !subDAO.IsDeleted() {
371 renderTree(res, subDAO, indent, showDissolved)
372 }
373 return false
374 })
375}
376
377func renderSettings(res *mux.ResponseWriter, req *mux.Request) {
378 dao := mustGetDAOFromRequest(req)
379
380 // Render header
381 res.Write(md.H1(md.EscapeText(dao.Name()) + ": Settings"))
382
383 // Render main menu
384 res.Write(md.Paragraph(goToDAOLink(dao.ID())))
385 res.Write(md.HorizontalRule())
386
387 // Render info
388 table := mdtable.Table{Headers: []string{"Setting", "Value"}}
389 table.Append([]string{"Listed", strconv.FormatBool(isListed(dao.ID()))})
390 table.Append([]string{"Max active proposals", strconv.Itoa(dao.MaxActiveProposals())})
391 table.Append([]string{"Proposal kinds", md.EscapeText(strings.Join(dao.KindNames(), ", "))})
392
393 res.Write(md.H2("Info"))
394 res.Write(table.String())
395}
396
397func renderBylaws(res *mux.ResponseWriter, req *mux.Request) {
398 dao := mustGetDAOFromRequest(req)
399
400 // Render header
401 res.Write(md.H1(md.EscapeText(dao.Name()) + ": Bylaws & Mandates"))
402
403 // Render main menu
404 res.Write(md.Paragraph(goToDAOLink(dao.ID())))
405 res.Write(md.HorizontalRule())
406
407 set := bylawsView(dao.ID())
408 if set == nil || set.Size() == 0 {
409 res.Write(md.Paragraph("This DAO has no bylaws or mandates documents."))
410 return
411 }
412
413 paths := set.List("")
414 pages, err := pager.New(req.RawPath, len(paths), pager.WithPageSize(5))
415 if err != nil {
416 // Its own block: an unterminated error line glues itself onto
417 // whatever is written next (renderCouncil returns into renderDAO,
418 // which goes straight on to write the Treasury heading).
419 res.Write(md.Paragraph(md.EscapeText(err.Error())))
420 return
421 }
422
423 // Documents are multi-line plaintext with council-controlled content:
424 // the path is escaped inline and the text goes through sanitize.Block,
425 // which preserves paragraph structure (an inline escape would fold the
426 // whole document to one line) while escaping block-level hazards. The
427 // hash is what an amendment payload must pin (AmendBylawsPayload reads
428 // it for you).
429 // Clamp the offset: pager.New rejects page 0 and pages past the end but
430 // accepts a negative page, and unlike the tree-backed pagers this one
431 // indexes a slice — a raw negative offset panics the whole render.
432 start := pages.Offset()
433 if start < 0 {
434 start = 0
435 }
436 for i := start; i < len(paths) && i < start+pages.PageSize(); i++ {
437 path := paths[i]
438 text, _ := set.Get(path)
439 res.Write(md.H2(md.EscapeText(path)))
440 res.Write(md.Paragraph("sha256: " + set.Hash(path)))
441 res.Write(sanitize.Block(text))
442 }
443
444 if pages.HasPages() {
445 res.Write(md.Paragraph(pager.Picker(pages)))
446 }
447}
448
449func renderProposalsList(res *mux.ResponseWriter, req *mux.Request) {
450 dao := mustGetDAOFromRequest(req)
451
452 // Render header
453 res.Write(md.H1(md.EscapeText(dao.Name()) + ": Proposals"))
454
455 // Render main menu
456 res.Write(md.Paragraph(goToDAOLink(dao.ID())))
457 res.Write(md.HorizontalRule())
458
459 // Render proposals
460 if dao.ActiveProposalsSize() == 0 && dao.FinishedProposalsSize() == 0 {
461 res.Write(md.Paragraph(md.Bold("⚠ The DAO has no proposals")))
462 return
463 }
464
465 size := dao.ActiveProposalsSize()
466 iterate := dao.IterateActiveProposals
467 renderFinished := req.Query.Has("finished")
468 if renderFinished {
469 size = dao.FinishedProposalsSize()
470 iterate = dao.IterateFinishedProposals
471 }
472
473 var viewLink, sortLink string
474
475 r := parseRealmPath(req.RawPath)
476 r.Query.Del("page") // a view switch resets to page 1 (the other view may have fewer pages)
477 if renderFinished {
478 r.Query.Del("finished")
479 viewLink = md.Link("active", r.String())
480 } else {
481 r.Query.Add("finished", "")
482 viewLink = md.Link("finished", r.String())
483 }
484
485 r = parseRealmPath(req.RawPath)
486 r.Query.Del("page") // a sort switch resets to page 1
487 reverseSort := r.Query.Get("order") != "asc"
488 if reverseSort {
489 r.Query.Set("order", "asc")
490 sortLink = md.Link("oldest", r.String())
491 } else {
492 r.Query.Set("order", "desc")
493 sortLink = md.Link("newest", r.String())
494 }
495
496 res.Write(md.Paragraph("View: " + viewLink + " • Sort by: " + sortLink))
497
498 // The empty view is handled before the pager: pager.New reports a total
499 // of zero as "invalid page number" for every page including page 1,
500 // which the other view's Picker links to — so switching into an empty
501 // view would answer with an error instead of the empty-state message.
502 if size == 0 {
503 if renderFinished {
504 res.Write(md.Paragraph("Currently there are no finished proposals"))
505 } else {
506 res.Write(md.Paragraph("Currently there are no active proposals"))
507 }
508 return
509 }
510
511 pages, err := pager.New(req.RawPath, size, pager.WithPageSize(8))
512 if err != nil {
513 res.Write(md.Paragraph(md.EscapeText(err.Error())))
514 return
515 }
516
517 iterate(pages.Offset(), pages.PageSize(), reverseSort, func(p *commondao.Proposal) bool {
518 renderProposalsListItem(res, dao, p)
519 return false
520 })
521
522 // Render pager
523 if pages.HasPages() {
524 res.Write(md.HorizontalRule())
525 res.Write(pager.Picker(pages))
526 }
527}
528
529func renderProposalsListItem(res *mux.ResponseWriter, dao *commondao.CommonDAO, p *commondao.Proposal) {
530 def := p.Definition()
531 record := p.VotingRecord()
532
533 // Render title
534 res.Write(ufmt.Sprintf("**[#%d %s](%s)** \n", p.ID(), md.EscapeText(def.Title()), proposalURL(dao.ID(), p.ID())))
535
536 // Render details
537 res.Write(ufmt.Sprintf("Created by %s \n", userLink(p.Creator())))
538 res.Write(ufmt.Sprintf("Voting ends on %s \n", p.VotingDeadline().UTC().Format(dateFormat)))
539
540 // Render status
541 status := []string{
542 ufmt.Sprintf("Votes: **%d**", record.Size()),
543 ufmt.Sprintf("Status: **%s**", string(p.Status())),
544 }
545
546 // Render actions
547 if isVotingPeriodActive(p) {
548 status = append(status, voteLink(dao.ID(), p.ID()))
549 }
550
551 if isExecutionAllowed(p) {
552 status = append(status, executeLink(dao.ID(), p.ID()))
553 }
554
555 res.Write(md.Paragraph(strings.Join(status, " • ")))
556}
557
558func renderProposal(res *mux.ResponseWriter, req *mux.Request) {
559 dao := mustGetDAOFromRequest(req)
560 p := mustGetProposalFromRequest(req, dao)
561
562 // Check that proposal has no issues
563 if err := p.Validate(); err != nil {
564 // Escape the error text (the bold label is ours): a definition's
565 // Validate may embed user-supplied values in its message.
566 res.Write(md.Blockquote("⚠ **ERROR**: " + md.EscapeText(err.Error())))
567 }
568
569 votingActive := isVotingPeriodActive(p)
570 if votingActive {
571 res.Write(
572 md.Blockquote("Voting ends on " + md.Bold(p.VotingDeadline().UTC().Format(dateFormat))),
573 )
574 }
575
576 def := p.Definition()
577
578 // Render header
579 res.Write(md.H1("#" + strconv.FormatUint(p.ID(), 10) + " " + md.EscapeText(def.Title())))
580
581 // Render main menu
582 items := []string{goToDAOLink(dao.ID())}
583 if votingActive {
584 items = append(items, voteLink(dao.ID(), p.ID()))
585 }
586
587 if isExecutionAllowed(p) {
588 items = append(items, executeLink(dao.ID(), p.ID()))
589 }
590
591 res.Write(md.Paragraph(strings.Join(items, " • ")))
592 res.Write(md.HorizontalRule())
593
594 // Render details
595 res.Write(md.H2("Details"))
596 res.Write(md.BulletItem("Proposer: " + userLink(p.Creator())))
597 res.Write(md.BulletItem("Submit Time: " + p.CreatedAt().UTC().Format(time.RFC1123)))
598
599 record := p.VotingRecord()
600 if p.Status() == commondao.StatusActive {
601 // Vote settles the outcome early — it flips a proposal to passed or
602 // dismissed the moment the tally allows — so while voting is open a
603 // still-active proposal is undecided, and "pending" is the honest
604 // label ("fail" would read as a prediction of defeat on a proposal
605 // nobody has voted on yet). Once the deadline has passed no further
606 // vote is accepted and Execute's re-tally dismisses an undecided
607 // proposal, so there "pending" would be the misleading one.
608 //
609 // The decided arms are reachable in principle: /p/ honors a
610 // non-constant Threshold (see ProposalDefinition.Threshold), though
611 // every definition in this realm returns a constant.
612 switch p.ExpectedOutcome() {
613 case commondao.OutcomePassed:
614 res.Write(md.BulletItem("Expected Outcome: **pass** ☑"))
615 case commondao.OutcomeDismissed:
616 res.Write(md.BulletItem("Expected Outcome: **dismiss** ☒"))
617 default:
618 if votingActive {
619 res.Write(md.BulletItem("Expected Outcome: **pending** ⏳"))
620 } else {
621 res.Write(md.BulletItem("Expected Outcome: **dismiss** ☒"))
622 }
623 }
624 }
625
626 statusItem := "Status: " + md.Bold(string(p.Status()))
627 if reason := p.StatusReason(); reason != "" {
628 statusItem += " • " + md.Italic(md.EscapeText(reason))
629 }
630 res.Write(md.BulletItem(statusItem))
631
632 // Render proposal body. Bodies are raw user text and escaped here by
633 // default; only definitions that assemble their own markdown (marked
634 // trustedMarkdownBody) are rendered verbatim.
635 if body := def.Body(); body != "" {
636 res.Write(md.H2("Description"))
637 if _, trusted := def.(trustedMarkdownBody); !trusted {
638 // Inline escape, deliberately: it neutralizes inline markup and
639 // links in proposer-supplied text. sanitize.Block would preserve
640 // the body's line structure but also its inline links — a
641 // phishing vector on a page councils read before voting. The
642 // cost is that a multi-line body renders as one line; a
643 // structure-preserving fix must escape inline per line rather
644 // than switch to Block (see z_10_b, which pins the escaping).
645 body = md.EscapeText(body)
646 }
647 res.Write(md.Paragraph(body))
648 }
649
650 // Render voting stats and votes
651 if record.Size() > 0 {
652 renderProposalStats(res, record)
653 renderProposalVotes(res, req.RawPath, dao, p)
654 }
655}
656
657func renderProposalStats(res *mux.ResponseWriter, record commondao.ReadonlyVotingRecord) {
658 totalCount := float64(record.Size())
659 table := mdtable.Table{Headers: []string{"Vote Choices", "Percentage of Votes"}}
660
661 record.IterateVotesCount(func(c commondao.VoteChoice, voteCount int) bool {
662 // A changed vote leaves a zeroed counter behind; skip it so the
663 // table shows only choices with live votes.
664 if voteCount == 0 {
665 return false
666 }
667
668 percentage := float64(voteCount*100) / totalCount
669 table.Append([]string{md.EscapeText(string(c)), strconv.FormatFloat(percentage, 'f', 2, 64) + "%"})
670 return false
671 })
672
673 res.Write(md.H2("Stats"))
674 res.Write(md.Paragraph(table.String()))
675}
676
677func renderProposalVotes(res *mux.ResponseWriter, path string, dao *commondao.CommonDAO, p *commondao.Proposal) {
678 res.Write(md.H2("Votes")) // Render title here so it appears before any pager errors
679
680 record := p.VotingRecord()
681 pages, err := pager.New(path, record.Size(), pager.WithPageQueryParam("votes"), pager.WithPageSize(5))
682 if err != nil {
683 // Its own block: an unterminated error line glues itself onto
684 // whatever is written next (renderCouncil returns into renderDAO,
685 // which goes straight on to write the Treasury heading).
686 res.Write(md.Paragraph(md.EscapeText(err.Error())))
687 return
688 }
689
690 table := mdtable.Table{Headers: []string{"Users", "Votes"}}
691 record.Iterate(pages.Offset(), pages.PageSize(), false, func(v commondao.Vote) bool {
692 voteDetails := md.Link(string(v.Choice()), voteURL(dao.ID(), p.ID(), v.Address()))
693 if v.Reason() != "" {
694 voteDetails += " with a reason"
695 }
696
697 table.Append([]string{userLink(v.Address()), voteDetails})
698 return false
699 })
700
701 res.Write(ufmt.Sprintf("Total number of votes: **%d**\n", record.Size()))
702 res.Write(md.Paragraph(table.String()))
703
704 if pages.HasPages() {
705 res.Write(md.Paragraph(pager.Picker(pages)))
706 }
707}
708
709func renderProposalVote(res *mux.ResponseWriter, req *mux.Request) {
710 // Resolve the DAO and proposal before validating the address, so a bad
711 // daoID is reported as such instead of being blamed on the address, and
712 // write the header first so every branch below is a navigable page
713 // rather than a bare dead-end string.
714 dao := mustGetDAOFromRequest(req)
715 p := mustGetProposalFromRequest(req, dao)
716
717 links := []string{
718 goToDAOLink(dao.ID()),
719 goToProposalLink(dao.ID(), p.ID()),
720 }
721
722 res.Write(md.H1(ufmt.Sprintf("Vote: Proposal #%d", p.ID())))
723 res.Write(md.Paragraph(strings.Join(links, " • ")))
724 res.Write(md.HorizontalRule())
725
726 member := address(req.GetVar("address"))
727 if !member.IsValid() {
728 res.Write(md.Paragraph("Invalid address."))
729 return
730 }
731
732 v, found := p.VotingRecord().GetVote(member)
733 if !found {
734 // Distinguish the two ways a vote can be absent: a member who has
735 // not voted yet is an ordinary state, an outsider is not.
736 if p.Electorate().Has(member) {
737 res.Write(md.Paragraph("This council member has not voted on this proposal yet."))
738 } else {
739 res.Write(md.Paragraph("This account is not a member of the proposal's electorate."))
740 }
741 return
742 }
743
744 res.Write(md.H2("Details"))
745 res.Write(md.BulletItem("User: " + userLink(v.Address())))
746 res.Write(md.BulletItem("Vote: " + md.EscapeText(string(v.Choice()))))
747
748 if v.Reason() != "" {
749 res.Write(md.H2("Reason"))
750 // Inline escape for the same reason as a proposal body: a voter's
751 // reason is untrusted text, so its inline links must not render.
752 res.Write(md.Paragraph(md.EscapeText(v.Reason())))
753 }
754}
755
756func mustGetDAOFromRequest(req *mux.Request) *commondao.CommonDAO {
757 rawID := req.GetVar("daoID")
758 daoID, err := strconv.ParseUint(rawID, 10, 64)
759 if err != nil {
760 panic("invalid DAO ID")
761 }
762
763 return mustGetDAO(daoID)
764}
765
766func mustGetProposalFromRequest(req *mux.Request, dao *commondao.CommonDAO) *commondao.Proposal {
767 rawID := req.GetVar("proposalID")
768 proposalID, err := strconv.ParseUint(rawID, 10, 64)
769 if err != nil {
770 panic("invalid proposal ID")
771 }
772
773 p := dao.GetProposal(proposalID)
774 if p == nil {
775 panic("proposal not found")
776 }
777 return p
778}
779
780func parseRealmPath(path string) *realmpath.Request {
781 r := realmpath.Parse(path)
782 r.Realm = string(realmLink)
783 return r
784}
785
786func voteLink(daoID, proposalID uint64) string {
787 return md.Link("Vote", realmLink.Call(
788 "Vote",
789 "daoID", strconv.FormatUint(daoID, 10),
790 "proposalID", strconv.FormatUint(proposalID, 10),
791 "vote", "",
792 "reason", "",
793 ))
794}
795
796func executeLink(daoID, proposalID uint64) string {
797 return md.Link("Execute", realmLink.Call(
798 "Execute",
799 "daoID", strconv.FormatUint(daoID, 10),
800 "proposalID", strconv.FormatUint(proposalID, 10),
801 ))
802}
803
804func textProposalLink(daoID uint64) string {
805 return ufmt.Sprintf("[General Proposal](%s)", realmLink.Call(
806 "CreateTextProposal",
807 "daoID", strconv.FormatUint(daoID, 10),
808 "title", "",
809 "body", "",
810 "votingDays", "7",
811 ))
812}
813
814func updateCouncilLink(daoID uint64) string {
815 return md.Link("Update Council", realmLink.Call(
816 "CreateCouncilUpdateProposal",
817 "daoID", strconv.FormatUint(daoID, 10),
818 "newMembers", "",
819 "removeMembers", "",
820 ))
821}
822
823func ancestorCouncilUpdateLink(daoID uint64) string {
824 return md.Link("Ancestor Council Update", realmLink.Call(
825 "CreateAncestorCouncilUpdateProposal",
826 "daoID", strconv.FormatUint(daoID, 10),
827 "targetID", "",
828 "newMembers", "",
829 "removeMembers", "",
830 ))
831}
832
833func newSubDAOLink(daoID uint64) string {
834 return md.Link("New SubDAO", realmLink.Call(
835 "CreateSubDAOProposal",
836 "daoID", strconv.FormatUint(daoID, 10),
837 "name", "",
838 "purpose", "",
839 "description", "",
840 "members", "",
841 ))
842}
843
844func dissolveSubDAOLink(daoID uint64) string {
845 return md.Link("Dissolve DAO", realmLink.Call(
846 "CreateDissolutionProposal",
847 "daoID", strconv.FormatUint(daoID, 10),
848 "destination", "",
849 ))
850}
851
852func treasuryClawbackLink(daoID uint64) string {
853 return md.Link("Treasury Clawback", realmLink.Call(
854 "CreateTreasuryClawbackProposal",
855 "daoID", strconv.FormatUint(daoID, 10),
856 "targetID", "",
857 ))
858}
859
860func treasuryFreezeLink(daoID uint64) string {
861 return md.Link("Treasury Freeze", realmLink.Call(
862 "CreateTreasuryFreezeProposal",
863 "daoID", strconv.FormatUint(daoID, 10),
864 "targetID", "",
865 "frozen", "true",
866 ))
867}
868
869func treasurySpendLink(daoID uint64) string {
870 return md.Link("Treasury Spend", realmLink.Call(
871 "CreateTreasurySpendProposal",
872 "daoID", strconv.FormatUint(daoID, 10),
873 "to", "",
874 "denom", "ugnot",
875 "amount", "",
876 ))
877}
878
879func registerKindLink(daoID uint64) string {
880 return md.Link("Register Proposal Kind", realmLink.Call(
881 "CreateRegisterKindProposal",
882 "daoID", strconv.FormatUint(daoID, 10),
883 "kindName", "",
884 ))
885}
886
887func deregisterKindLink(daoID uint64) string {
888 return md.Link("Deregister Proposal Kind", realmLink.Call(
889 "CreateDeregisterKindProposal",
890 "daoID", strconv.FormatUint(daoID, 10),
891 "kindName", "",
892 ))
893}
894
895func amendBylawsLink(daoID uint64) string {
896 return md.Link("Amend Bylaws", realmLink.Call(
897 "CreateAmendBylawsProposal",
898 "daoID", strconv.FormatUint(daoID, 10),
899 "payload", "",
900 ))
901}
902
903func goToDAOLink(daoID uint64) string {
904 return md.Link("Go to DAO", daoURL(daoID))
905}
906
907func goToProposalLink(daoID, proposalID uint64) string {
908 return md.Link("Go to Proposal", proposalURL(daoID, proposalID))
909}
910
911func userLink(addr address) string {
912 user := users.ResolveAddress(addr)
913 if user != nil {
914 return user.RenderLink("")
915 }
916 return addr.String()
917}
918
919func isVotingPeriodActive(p *commondao.Proposal) bool {
920 return p.Status() == commondao.StatusActive && time.Now().Before(p.VotingDeadline())
921}
922
923func isExecutionAllowed(p *commondao.Proposal) bool {
924 // Early passed proposals can be executed right away; active proposals
925 // can be finalized once their voting deadline passes.
926 if p.Status() == commondao.StatusPassed {
927 return true
928 }
929 return p.Status() == commondao.StatusActive && !time.Now().Before(p.VotingDeadline())
930}