render.gno
11.28 Kb · 411 lines
1package boards2
2
3import (
4 "net/url"
5 "strconv"
6 "strings"
7 "time"
8
9 "gno.land/p/gnoland/boards"
10 "gno.land/p/jeronimoalbi/mdform"
11 "gno.land/p/jeronimoalbi/pager"
12 "gno.land/p/leon/svgbtn"
13 "gno.land/p/moul/md"
14 "gno.land/p/moul/mdtable"
15 "gno.land/p/nt/markdown/foreign/v0"
16 "gno.land/p/nt/mux/v0"
17)
18
19const (
20 pageSizeDefault = 6
21 pageSizeReplies = 10
22 // pageSizeFlat is the page size of the flat "all comments" view (?flat=1).
23 // Each comment renders exactly one <gno-foreign> block (no recursion); with
24 // the OP (and any repost-source body) the per-page total stays well under
25 // maxRenderedBodies, so a flat page can't hit the render cap.
26 pageSizeFlat = 50
27)
28
29// maxRenderedBodies caps user bodies wrapped in <gno-foreign> per page
30// render, kept a margin under gnoweb's per-render foreign-block cap
31// (foreign.MaxBlocksPerRender, sourced from chain/markdown so it can't
32// drift from the renderer). On reaching it the tree stops descending
33// and shows a truncation notice instead of letting the renderer blank
34// comments past the cap. The margin absorbs the OP, repost source
35// bodies, and chrome.
36//
37// Computed lazily (a func, not a package-level var) so it tracks the
38// native cap across chain upgrades: a var initializer runs once at
39// realm-init and persists, freezing a stale value if the underlying
40// MaxBlocksPerRender ever changes; recomputing per render re-reads it.
41func maxRenderedBodies() int {
42 return foreign.MaxBlocksPerRender() - 10
43}
44
45// sortToggleLink builds the asc/desc sort-toggle link for path. The label
46// names the DESTINATION order (what you get by clicking), not the order you're
47// currently viewing, and ?page= is reset since page N of one order is a
48// different slice in the other. It reports whether the CURRENT order is
49// descending, so callers can drive their own (signed-count or reverse-iterate)
50// pagination. Other query params (e.g. flat=1) are preserved.
51func sortToggleLink(path string) (link string, desc bool) {
52 r := parseRealmPath(path)
53 desc = r.Query.Get("order") == "desc"
54 r.Query.Del("page")
55 if desc {
56 r.Query.Set("order", "asc")
57 return md.Link("oldest first", r.String()), true
58 }
59 r.Query.Set("order", "desc")
60 return md.Link("newest first", r.String()), false
61}
62
63const menuManageBoard = "manageBoard"
64
65var (
66 createBoardURI = gRealmPath + ":create-board"
67 adminUsersURI = gRealmPath + ":admin-users"
68 helpURI = gRealmPath + ":help"
69)
70
71func Render(path string) string {
72 var (
73 b strings.Builder
74 router = mux.NewRouter()
75 )
76
77 router.HandleFunc("", renderBoardsList)
78 router.HandleFunc("help", renderHelp)
79 router.HandleFunc("admin-users", renderMembers)
80 router.HandleFunc("create-board", renderCreateBoard)
81 router.HandleFunc("{board}", renderBoard)
82 router.HandleFunc("{board}/members", renderMembers)
83 router.HandleFunc("{board}/invites", renderInvites)
84 router.HandleFunc("{board}/banned-users", renderBannedUsers)
85 router.HandleFunc("{board}/create-thread", renderCreateThread)
86 router.HandleFunc("{board}/invite-member", renderInviteMember)
87 router.HandleFunc("{board}/{thread}", renderThread)
88 router.HandleFunc("{board}/{thread}/flag", renderFlagPost)
89 router.HandleFunc("{board}/{thread}/flagging-reasons", renderFlaggingReasonsPost)
90 router.HandleFunc("{board}/{thread}/reply", renderReplyPost)
91 router.HandleFunc("{board}/{thread}/edit", renderEditThread)
92 router.HandleFunc("{board}/{thread}/repost", renderRepostThread)
93 router.HandleFunc("{board}/{thread}/{reply}", renderReply)
94 router.HandleFunc("{board}/{thread}/{reply}/flag", renderFlagPost)
95 router.HandleFunc("{board}/{thread}/{reply}/flagging-reasons", renderFlaggingReasonsPost)
96 router.HandleFunc("{board}/{thread}/{reply}/reply", renderReplyPost)
97 router.HandleFunc("{board}/{thread}/{reply}/edit", renderEditReply)
98
99 router.NotFoundHandler = func(res *mux.ResponseWriter, _ *mux.Request) {
100 res.Write(md.Blockquote("Path not found"))
101 }
102
103 // Render common realm header before resolving render path
104 if Notice != "" {
105 b.WriteString(infoAlert("Notice", Notice))
106 }
107
108 // Render view for current path
109 b.WriteString(router.Render(path))
110
111 return b.String()
112}
113
114func renderHelp(res *mux.ResponseWriter, _ *mux.Request) {
115 res.Write(md.H1("Boards Help"))
116 if Help != "" {
117 res.Write(Help)
118 return
119 }
120
121 link := RealmLink.Call("SetHelp", "content", "")
122 res.Write(md.H3("Help content has not been uploaded"))
123 res.Write("Do you want to " + md.Link("upload boards help", link) + "?")
124}
125
126func renderBoardsList(res *mux.ResponseWriter, req *mux.Request) {
127 res.Write(md.H1("Boards"))
128 res.Write(md.Link("chore(boards2): Make public API non-crossing again, move to main folder", "https://github.com/gnolang/gno/pull/5809") + "\n\n")
129 renderBoardListMenu(res, req)
130 res.Write(md.HorizontalRule())
131
132 if gListedBoardsByID.Size() == 0 {
133 res.Write(md.H3("Currently there are no boards"))
134 res.Write("Be the first to " + md.Link("create a new board", createBoardURI) + "!")
135 return
136 }
137
138 p := newClampedPager(req.RawPath, gListedBoardsByID.Size(), pageSizeDefault)
139
140 render := func(_ string, v any) bool {
141 board := v.(*boards.Board)
142 userLink := userLink(board.Creator)
143 date := board.CreatedAt.Format(dateFormat)
144
145 res.Write(md.H6(md.Link(board.Name, makeBoardURI(board))))
146 res.Write("Created by " + userLink + " on " + date + ", #" + board.ID.String() + " \n")
147
148 status := strconv.Itoa(board.Threads.Size()) + " threads"
149 if board.Readonly {
150 status += ", read-only"
151 }
152
153 res.Write(md.Bold(status) + "\n\n")
154 return false
155 }
156
157 res.Write("Sort by: ")
158 link, desc := sortToggleLink(req.RawPath)
159 res.Write(link + "\n\n")
160 if desc {
161 gListedBoardsByID.ReverseIterateByOffset(p.Offset(), p.PageSize(), render)
162 } else {
163 gListedBoardsByID.IterateByOffset(p.Offset(), p.PageSize(), render)
164 }
165
166 if p.HasPages() {
167 res.Write(md.HorizontalRule())
168 res.Write(pager.Picker(p))
169 }
170}
171
172func renderBoardListMenu(res *mux.ResponseWriter, req *mux.Request) {
173 res.Write(md.Link("Create Board", createBoardURI))
174 res.Write(" • ")
175 res.Write(md.Link("List Admin Users", adminUsersURI))
176 res.Write(" • ")
177 res.Write(md.Link("Help", helpURI))
178 res.Write("\n\n")
179}
180
181func renderCreateBoard(res *mux.ResponseWriter, _ *mux.Request) {
182 form := mdform.New("exec", "CreateBoard")
183 form.Input(
184 "name",
185 "placeholder", "Board name",
186 "required", "true",
187 )
188 form.Radio(
189 "listed",
190 "true",
191 "checked", "true",
192 "description", "Should board be publicly listed?",
193 )
194 form.Radio(
195 "listed",
196 "false",
197 )
198 form.Radio(
199 "open",
200 "true",
201 "description", "Should anyone be allowed to create threads and comments?",
202 )
203 form.Radio(
204 "open",
205 "false",
206 "checked", "true",
207 )
208
209 res.Write(md.H1("Boards: Create Board"))
210 res.Write(md.Link("← Back to boards", gRealmPath) + "\n\n")
211 res.Write(
212 md.Paragraph(
213 "Boards are by default listed by the realm but they can optionally " +
214 "be created so they are only found by their URL.",
215 ),
216 )
217 res.Write(
218 md.Paragraph(
219 "They can also be created to be open so anyone is allowed to create " +
220 "new threads and also to comment on any thread within the open board.",
221 ),
222 )
223 res.Write(form.String())
224 res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to boards", gRealmPath) + "\n")
225}
226
227func renderMembers(res *mux.ResponseWriter, req *mux.Request) {
228 boardID := boards.ID(0)
229 perms := gPerms
230 name := req.GetVar("board")
231 if name != "" {
232 board, found := gBoards.GetByName(name)
233 if !found {
234 res.Write(md.H3("Board not found"))
235 return
236 }
237
238 boardID = board.ID
239 perms = board.Permissions
240
241 res.Write(md.H1(board.Name + " Members"))
242 res.Write(md.H3("These are the board members"))
243 } else {
244 res.Write(md.H1("Admin Users"))
245 res.Write(md.H3("These are the admin users of the realm"))
246 }
247
248 // Create a pager with a small page size to reduce
249 // the number of username lookups per page.
250 p := newClampedPager(req.RawPath, perms.UsersCount(), pageSizeDefault)
251
252 table := mdtable.Table{
253 Headers: []string{"Member", "Role", "Actions"},
254 }
255
256 perms.IterateUsers(p.Offset(), p.PageSize(), func(u boards.User) bool {
257 actions := []string{
258 md.Link("remove", RealmLink.Call(
259 "RemoveMember",
260 "boardID", boardID.String(),
261 "member", u.Address.String(),
262 )),
263 md.Link("change role", RealmLink.Call(
264 "ChangeMemberRole",
265 "boardID", boardID.String(),
266 "member", u.Address.String(),
267 "role", "",
268 )),
269 }
270
271 table.Append([]string{
272 userLink(u.Address),
273 rolesToString(u.Roles),
274 strings.Join(actions, " • "),
275 })
276 return false
277 })
278 res.Write(table.String())
279
280 if p.HasPages() {
281 res.Write("\n" + pager.Picker(p))
282 }
283}
284
285func renderInvites(res *mux.ResponseWriter, req *mux.Request) {
286 name := req.GetVar("board")
287 board, found := gBoards.GetByName(name)
288 if !found {
289 res.Write(md.H3("Board not found"))
290 return
291 }
292
293 res.Write(md.H1(board.Name + " Invite Requests"))
294
295 requests, found := getInviteRequests(board.ID)
296 if !found || requests.Size() == 0 {
297 res.Write(md.H3("Board has no invite requests"))
298 return
299 }
300
301 p := newClampedPager(req.RawPath, requests.Size(), pageSizeDefault)
302
303 table := mdtable.Table{
304 Headers: []string{"User", "Request Date", "Actions"},
305 }
306
307 res.Write(md.H3("These users have requested to be invited to the board"))
308 requests.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool {
309 actions := []string{
310 md.Link("accept", RealmLink.Call(
311 "AcceptInvite",
312 "boardID", board.ID.String(),
313 "user", addr,
314 )),
315 md.Link("revoke", RealmLink.Call(
316 "RevokeInvite",
317 "boardID", board.ID.String(),
318 "user", addr,
319 )),
320 }
321
322 table.Append([]string{
323 userLink(address(addr)),
324 v.(time.Time).Format(dateFormat),
325 strings.Join(actions, " • "),
326 })
327 return false
328 })
329
330 res.Write(table.String())
331
332 if p.HasPages() {
333 res.Write("\n" + pager.Picker(p))
334 }
335}
336
337func renderBannedUsers(res *mux.ResponseWriter, req *mux.Request) {
338 name := req.GetVar("board")
339 board, found := gBoards.GetByName(name)
340 if !found {
341 res.Write(md.H3("Board not found"))
342 return
343 }
344
345 res.Write(md.H1(board.Name + " Banned Users"))
346
347 banned, found := getBannedUsers(board.ID)
348 if !found || banned.Size() == 0 {
349 res.Write(md.H3("Board has no banned users"))
350 return
351 }
352
353 p := newClampedPager(req.RawPath, banned.Size(), pageSizeDefault)
354
355 table := mdtable.Table{
356 Headers: []string{"User", "Banned Until", "Actions"},
357 }
358
359 res.Write(md.H3("These users have been banned from the board"))
360 banned.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool {
361 table.Append([]string{
362 userLink(address(addr)),
363 v.(time.Time).Format(dateFormat),
364 md.Link("unban", RealmLink.Call(
365 "Unban",
366 "boardID", board.ID.String(),
367 "user", addr,
368 "reason", "",
369 )),
370 })
371 return false
372 })
373
374 res.Write(table.String())
375
376 if p.HasPages() {
377 res.Write("\n" + pager.Picker(p))
378 }
379}
380
381func infoAlert(title, msg string) string {
382 header := strings.TrimSpace("[!INFO] " + title)
383 return md.Blockquote(header + "\n" + msg)
384}
385
386func rolesToString(roles []boards.Role) string {
387 if len(roles) == 0 {
388 return ""
389 }
390
391 names := make([]string, len(roles))
392 for i, r := range roles {
393 names[i] = string(r)
394 }
395 return strings.Join(names, ", ")
396}
397
398func menuURL(name string) string {
399 // TODO: Menu URL works because no other GET arguments are being used
400 return "?menu=" + name
401}
402
403func getCurrentMenu(rawURL string) string {
404 _, rawQuery, found := strings.Cut(rawURL, "?")
405 if !found {
406 return ""
407 }
408
409 query, _ := url.ParseQuery(rawQuery)
410 return query.Get("menu")
411}