package boards2 import ( "net/url" "strconv" "strings" "time" "gno.land/p/gnoland/boards" "gno.land/p/jeronimoalbi/mdform" "gno.land/p/jeronimoalbi/pager" "gno.land/p/leon/svgbtn" "gno.land/p/moul/md" "gno.land/p/moul/mdtable" "gno.land/p/nt/markdown/foreign/v0" "gno.land/p/nt/mux/v0" ) const ( pageSizeDefault = 6 pageSizeReplies = 10 // pageSizeFlat is the page size of the flat "all comments" view (?flat=1). // Each comment renders exactly one block (no recursion); with // the OP (and any repost-source body) the per-page total stays well under // maxRenderedBodies, so a flat page can't hit the render cap. pageSizeFlat = 50 ) // maxRenderedBodies caps user bodies wrapped in per page // render, kept a margin under gnoweb's per-render foreign-block cap // (foreign.MaxBlocksPerRender, sourced from chain/markdown so it can't // drift from the renderer). On reaching it the tree stops descending // and shows a truncation notice instead of letting the renderer blank // comments past the cap. The margin absorbs the OP, repost source // bodies, and chrome. // // Computed lazily (a func, not a package-level var) so it tracks the // native cap across chain upgrades: a var initializer runs once at // realm-init and persists, freezing a stale value if the underlying // MaxBlocksPerRender ever changes; recomputing per render re-reads it. func maxRenderedBodies() int { return foreign.MaxBlocksPerRender() - 10 } // sortToggleLink builds the asc/desc sort-toggle link for path. The label // names the DESTINATION order (what you get by clicking), not the order you're // currently viewing, and ?page= is reset since page N of one order is a // different slice in the other. It reports whether the CURRENT order is // descending, so callers can drive their own (signed-count or reverse-iterate) // pagination. Other query params (e.g. flat=1) are preserved. func sortToggleLink(path string) (link string, desc bool) { r := parseRealmPath(path) desc = r.Query.Get("order") == "desc" r.Query.Del("page") if desc { r.Query.Set("order", "asc") return md.Link("oldest first", r.String()), true } r.Query.Set("order", "desc") return md.Link("newest first", r.String()), false } const menuManageBoard = "manageBoard" var ( createBoardURI = gRealmPath + ":create-board" adminUsersURI = gRealmPath + ":admin-users" helpURI = gRealmPath + ":help" ) func Render(path string) string { var ( b strings.Builder router = mux.NewRouter() ) router.HandleFunc("", renderBoardsList) router.HandleFunc("help", renderHelp) router.HandleFunc("admin-users", renderMembers) router.HandleFunc("create-board", renderCreateBoard) router.HandleFunc("{board}", renderBoard) router.HandleFunc("{board}/members", renderMembers) router.HandleFunc("{board}/invites", renderInvites) router.HandleFunc("{board}/banned-users", renderBannedUsers) router.HandleFunc("{board}/create-thread", renderCreateThread) router.HandleFunc("{board}/invite-member", renderInviteMember) router.HandleFunc("{board}/{thread}", renderThread) router.HandleFunc("{board}/{thread}/flag", renderFlagPost) router.HandleFunc("{board}/{thread}/flagging-reasons", renderFlaggingReasonsPost) router.HandleFunc("{board}/{thread}/reply", renderReplyPost) router.HandleFunc("{board}/{thread}/edit", renderEditThread) router.HandleFunc("{board}/{thread}/repost", renderRepostThread) router.HandleFunc("{board}/{thread}/{reply}", renderReply) router.HandleFunc("{board}/{thread}/{reply}/flag", renderFlagPost) router.HandleFunc("{board}/{thread}/{reply}/flagging-reasons", renderFlaggingReasonsPost) router.HandleFunc("{board}/{thread}/{reply}/reply", renderReplyPost) router.HandleFunc("{board}/{thread}/{reply}/edit", renderEditReply) router.NotFoundHandler = func(res *mux.ResponseWriter, _ *mux.Request) { res.Write(md.Blockquote("Path not found")) } // Render common realm header before resolving render path if Notice != "" { b.WriteString(infoAlert("Notice", Notice)) } // Render view for current path b.WriteString(router.Render(path)) return b.String() } func renderHelp(res *mux.ResponseWriter, _ *mux.Request) { res.Write(md.H1("Boards Help")) if Help != "" { res.Write(Help) return } link := RealmLink.Call("SetHelp", "content", "") res.Write(md.H3("Help content has not been uploaded")) res.Write("Do you want to " + md.Link("upload boards help", link) + "?") } func renderBoardsList(res *mux.ResponseWriter, req *mux.Request) { res.Write(md.H1("Boards")) renderBoardListMenu(res, req) res.Write(md.HorizontalRule()) if gListedBoardsByID.Size() == 0 { res.Write(md.H3("Currently there are no boards")) res.Write("Be the first to " + md.Link("create a new board", createBoardURI) + "!") return } p := newClampedPager(req.RawPath, gListedBoardsByID.Size(), pageSizeDefault) render := func(_ string, v any) bool { board := v.(*boards.Board) userLink := userLink(board.Creator) date := board.CreatedAt.Format(dateFormat) res.Write(md.H6(md.Link(board.Name, makeBoardURI(board)))) res.Write("Created by " + userLink + " on " + date + ", #" + board.ID.String() + " \n") status := strconv.Itoa(board.Threads.Size()) + " threads" if board.Readonly { status += ", read-only" } res.Write(md.Bold(status) + "\n\n") return false } res.Write("Sort by: ") link, desc := sortToggleLink(req.RawPath) res.Write(link + "\n\n") if desc { gListedBoardsByID.ReverseIterateByOffset(p.Offset(), p.PageSize(), render) } else { gListedBoardsByID.IterateByOffset(p.Offset(), p.PageSize(), render) } if p.HasPages() { res.Write(md.HorizontalRule()) res.Write(pager.Picker(p)) } } func renderBoardListMenu(res *mux.ResponseWriter, req *mux.Request) { res.Write(md.Link("Create Board", createBoardURI)) res.Write(" • ") res.Write(md.Link("List Admin Users", adminUsersURI)) res.Write(" • ") res.Write(md.Link("Help", helpURI)) res.Write("\n\n") } func renderCreateBoard(res *mux.ResponseWriter, _ *mux.Request) { form := mdform.New("exec", "CreateBoard") form.Input( "name", "placeholder", "Board name", "required", "true", ) form.Radio( "listed", "true", "checked", "true", "description", "Should board be publicly listed?", ) form.Radio( "listed", "false", ) form.Radio( "open", "true", "description", "Should anyone be allowed to create threads and comments?", ) form.Radio( "open", "false", "checked", "true", ) res.Write(md.H1("Boards: Create Board")) res.Write(md.Link("← Back to boards", gRealmPath) + "\n\n") res.Write( md.Paragraph( "Boards are by default listed by the realm but they can optionally " + "be created so they are only found by their URL.", ), ) res.Write( md.Paragraph( "They can also be created to be open so anyone is allowed to create " + "new threads and also to comment on any thread within the open board.", ), ) res.Write(form.String()) res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to boards", gRealmPath) + "\n") } func renderMembers(res *mux.ResponseWriter, req *mux.Request) { boardID := boards.ID(0) perms := gPerms name := req.GetVar("board") if name != "" { board, found := gBoards.GetByName(name) if !found { res.Write(md.H3("Board not found")) return } boardID = board.ID perms = board.Permissions res.Write(md.H1(board.Name + " Members")) res.Write(md.H3("These are the board members")) } else { res.Write(md.H1("Admin Users")) res.Write(md.H3("These are the admin users of the realm")) } // Create a pager with a small page size to reduce // the number of username lookups per page. p := newClampedPager(req.RawPath, perms.UsersCount(), pageSizeDefault) table := mdtable.Table{ Headers: []string{"Member", "Role", "Actions"}, } perms.IterateUsers(p.Offset(), p.PageSize(), func(u boards.User) bool { actions := []string{ md.Link("remove", RealmLink.Call( "RemoveMember", "boardID", boardID.String(), "member", u.Address.String(), )), md.Link("change role", RealmLink.Call( "ChangeMemberRole", "boardID", boardID.String(), "member", u.Address.String(), "role", "", )), } table.Append([]string{ userLink(u.Address), rolesToString(u.Roles), strings.Join(actions, " • "), }) return false }) res.Write(table.String()) if p.HasPages() { res.Write("\n" + pager.Picker(p)) } } func renderInvites(res *mux.ResponseWriter, req *mux.Request) { name := req.GetVar("board") board, found := gBoards.GetByName(name) if !found { res.Write(md.H3("Board not found")) return } res.Write(md.H1(board.Name + " Invite Requests")) requests, found := getInviteRequests(board.ID) if !found || requests.Size() == 0 { res.Write(md.H3("Board has no invite requests")) return } p := newClampedPager(req.RawPath, requests.Size(), pageSizeDefault) table := mdtable.Table{ Headers: []string{"User", "Request Date", "Actions"}, } res.Write(md.H3("These users have requested to be invited to the board")) requests.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool { actions := []string{ md.Link("accept", RealmLink.Call( "AcceptInvite", "boardID", board.ID.String(), "user", addr, )), md.Link("revoke", RealmLink.Call( "RevokeInvite", "boardID", board.ID.String(), "user", addr, )), } table.Append([]string{ userLink(address(addr)), v.(time.Time).Format(dateFormat), strings.Join(actions, " • "), }) return false }) res.Write(table.String()) if p.HasPages() { res.Write("\n" + pager.Picker(p)) } } func renderBannedUsers(res *mux.ResponseWriter, req *mux.Request) { name := req.GetVar("board") board, found := gBoards.GetByName(name) if !found { res.Write(md.H3("Board not found")) return } res.Write(md.H1(board.Name + " Banned Users")) banned, found := getBannedUsers(board.ID) if !found || banned.Size() == 0 { res.Write(md.H3("Board has no banned users")) return } p := newClampedPager(req.RawPath, banned.Size(), pageSizeDefault) table := mdtable.Table{ Headers: []string{"User", "Banned Until", "Actions"}, } res.Write(md.H3("These users have been banned from the board")) banned.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool { table.Append([]string{ userLink(address(addr)), v.(time.Time).Format(dateFormat), md.Link("unban", RealmLink.Call( "Unban", "boardID", board.ID.String(), "user", addr, "reason", "", )), }) return false }) res.Write(table.String()) if p.HasPages() { res.Write("\n" + pager.Picker(p)) } } func infoAlert(title, msg string) string { header := strings.TrimSpace("[!INFO] " + title) return md.Blockquote(header + "\n" + msg) } func rolesToString(roles []boards.Role) string { if len(roles) == 0 { return "" } names := make([]string, len(roles)) for i, r := range roles { names[i] = string(r) } return strings.Join(names, ", ") } func menuURL(name string) string { // TODO: Menu URL works because no other GET arguments are being used return "?menu=" + name } func getCurrentMenu(rawURL string) string { _, rawQuery, found := strings.Cut(rawURL, "?") if !found { return "" } query, _ := url.ParseQuery(rawQuery) return query.Get("menu") }