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