Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}