Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}