render.gno
11.13 Kb · 410 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 renderBoardListMenu(res, req)
129 res.Write(md.HorizontalRule())
130
131 if gListedBoardsByID.Size() == 0 {
132 res.Write(md.H3("Currently there are no boards"))
133 res.Write("Be the first to " + md.Link("create a new board", createBoardURI) + "!")
134 return
135 }
136
137 p := newClampedPager(req.RawPath, gListedBoardsByID.Size(), pageSizeDefault)
138
139 render := func(_ string, v any) bool {
140 board := v.(*boards.Board)
141 userLink := userLink(board.Creator)
142 date := board.CreatedAt.Format(dateFormat)
143
144 res.Write(md.H6(md.Link(board.Name, makeBoardURI(board))))
145 res.Write("Created by " + userLink + " on " + date + ", #" + board.ID.String() + " \n")
146
147 status := strconv.Itoa(board.Threads.Size()) + " threads"
148 if board.Readonly {
149 status += ", read-only"
150 }
151
152 res.Write(md.Bold(status) + "\n\n")
153 return false
154 }
155
156 res.Write("Sort by: ")
157 link, desc := sortToggleLink(req.RawPath)
158 res.Write(link + "\n\n")
159 if desc {
160 gListedBoardsByID.ReverseIterateByOffset(p.Offset(), p.PageSize(), render)
161 } else {
162 gListedBoardsByID.IterateByOffset(p.Offset(), p.PageSize(), render)
163 }
164
165 if p.HasPages() {
166 res.Write(md.HorizontalRule())
167 res.Write(pager.Picker(p))
168 }
169}
170
171func renderBoardListMenu(res *mux.ResponseWriter, req *mux.Request) {
172 res.Write(md.Link("Create Board", createBoardURI))
173 res.Write(" • ")
174 res.Write(md.Link("List Admin Users", adminUsersURI))
175 res.Write(" • ")
176 res.Write(md.Link("Help", helpURI))
177 res.Write("\n\n")
178}
179
180func renderCreateBoard(res *mux.ResponseWriter, _ *mux.Request) {
181 form := mdform.New("exec", "CreateBoard")
182 form.Input(
183 "name",
184 "placeholder", "Board name",
185 "required", "true",
186 )
187 form.Radio(
188 "listed",
189 "true",
190 "checked", "true",
191 "description", "Should board be publicly listed?",
192 )
193 form.Radio(
194 "listed",
195 "false",
196 )
197 form.Radio(
198 "open",
199 "true",
200 "description", "Should anyone be allowed to create threads and comments?",
201 )
202 form.Radio(
203 "open",
204 "false",
205 "checked", "true",
206 )
207
208 res.Write(md.H1("Boards: Create Board"))
209 res.Write(md.Link("← Back to boards", gRealmPath) + "\n\n")
210 res.Write(
211 md.Paragraph(
212 "Boards are by default listed by the realm but they can optionally " +
213 "be created so they are only found by their URL.",
214 ),
215 )
216 res.Write(
217 md.Paragraph(
218 "They can also be created to be open so anyone is allowed to create " +
219 "new threads and also to comment on any thread within the open board.",
220 ),
221 )
222 res.Write(form.String())
223 res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to boards", gRealmPath) + "\n")
224}
225
226func renderMembers(res *mux.ResponseWriter, req *mux.Request) {
227 boardID := boards.ID(0)
228 perms := gPerms
229 name := req.GetVar("board")
230 if name != "" {
231 board, found := gBoards.GetByName(name)
232 if !found {
233 res.Write(md.H3("Board not found"))
234 return
235 }
236
237 boardID = board.ID
238 perms = board.Permissions
239
240 res.Write(md.H1(board.Name + " Members"))
241 res.Write(md.H3("These are the board members"))
242 } else {
243 res.Write(md.H1("Admin Users"))
244 res.Write(md.H3("These are the admin users of the realm"))
245 }
246
247 // Create a pager with a small page size to reduce
248 // the number of username lookups per page.
249 p := newClampedPager(req.RawPath, perms.UsersCount(), pageSizeDefault)
250
251 table := mdtable.Table{
252 Headers: []string{"Member", "Role", "Actions"},
253 }
254
255 perms.IterateUsers(p.Offset(), p.PageSize(), func(u boards.User) bool {
256 actions := []string{
257 md.Link("remove", RealmLink.Call(
258 "RemoveMember",
259 "boardID", boardID.String(),
260 "member", u.Address.String(),
261 )),
262 md.Link("change role", RealmLink.Call(
263 "ChangeMemberRole",
264 "boardID", boardID.String(),
265 "member", u.Address.String(),
266 "role", "",
267 )),
268 }
269
270 table.Append([]string{
271 userLink(u.Address),
272 rolesToString(u.Roles),
273 strings.Join(actions, " • "),
274 })
275 return false
276 })
277 res.Write(table.String())
278
279 if p.HasPages() {
280 res.Write("\n" + pager.Picker(p))
281 }
282}
283
284func renderInvites(res *mux.ResponseWriter, req *mux.Request) {
285 name := req.GetVar("board")
286 board, found := gBoards.GetByName(name)
287 if !found {
288 res.Write(md.H3("Board not found"))
289 return
290 }
291
292 res.Write(md.H1(board.Name + " Invite Requests"))
293
294 requests, found := getInviteRequests(board.ID)
295 if !found || requests.Size() == 0 {
296 res.Write(md.H3("Board has no invite requests"))
297 return
298 }
299
300 p := newClampedPager(req.RawPath, requests.Size(), pageSizeDefault)
301
302 table := mdtable.Table{
303 Headers: []string{"User", "Request Date", "Actions"},
304 }
305
306 res.Write(md.H3("These users have requested to be invited to the board"))
307 requests.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool {
308 actions := []string{
309 md.Link("accept", RealmLink.Call(
310 "AcceptInvite",
311 "boardID", board.ID.String(),
312 "user", addr,
313 )),
314 md.Link("revoke", RealmLink.Call(
315 "RevokeInvite",
316 "boardID", board.ID.String(),
317 "user", addr,
318 )),
319 }
320
321 table.Append([]string{
322 userLink(address(addr)),
323 v.(time.Time).Format(dateFormat),
324 strings.Join(actions, " • "),
325 })
326 return false
327 })
328
329 res.Write(table.String())
330
331 if p.HasPages() {
332 res.Write("\n" + pager.Picker(p))
333 }
334}
335
336func renderBannedUsers(res *mux.ResponseWriter, req *mux.Request) {
337 name := req.GetVar("board")
338 board, found := gBoards.GetByName(name)
339 if !found {
340 res.Write(md.H3("Board not found"))
341 return
342 }
343
344 res.Write(md.H1(board.Name + " Banned Users"))
345
346 banned, found := getBannedUsers(board.ID)
347 if !found || banned.Size() == 0 {
348 res.Write(md.H3("Board has no banned users"))
349 return
350 }
351
352 p := newClampedPager(req.RawPath, banned.Size(), pageSizeDefault)
353
354 table := mdtable.Table{
355 Headers: []string{"User", "Banned Until", "Actions"},
356 }
357
358 res.Write(md.H3("These users have been banned from the board"))
359 banned.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool {
360 table.Append([]string{
361 userLink(address(addr)),
362 v.(time.Time).Format(dateFormat),
363 md.Link("unban", RealmLink.Call(
364 "Unban",
365 "boardID", board.ID.String(),
366 "user", addr,
367 "reason", "",
368 )),
369 })
370 return false
371 })
372
373 res.Write(table.String())
374
375 if p.HasPages() {
376 res.Write("\n" + pager.Picker(p))
377 }
378}
379
380func infoAlert(title, msg string) string {
381 header := strings.TrimSpace("[!INFO] " + title)
382 return md.Blockquote(header + "\n" + msg)
383}
384
385func rolesToString(roles []boards.Role) string {
386 if len(roles) == 0 {
387 return ""
388 }
389
390 names := make([]string, len(roles))
391 for i, r := range roles {
392 names[i] = string(r)
393 }
394 return strings.Join(names, ", ")
395}
396
397func menuURL(name string) string {
398 // TODO: Menu URL works because no other GET arguments are being used
399 return "?menu=" + name
400}
401
402func getCurrentMenu(rawURL string) string {
403 _, rawQuery, found := strings.Cut(rawURL, "?")
404 if !found {
405 return ""
406 }
407
408 query, _ := url.ParseQuery(rawQuery)
409 return query.Get("menu")
410}