Search Apps Documentation Source Content File Folder Download Copy Actions Download

render_post.gno

14.04 Kb · 549 lines
  1package boards2
  2
  3import (
  4	"strconv"
  5	"strings"
  6
  7	"gno.land/p/gnoland/boards"
  8	"gno.land/p/jeronimoalbi/mdform"
  9	"gno.land/p/leon/svgbtn"
 10	"gno.land/p/moul/md"
 11	"gno.land/p/moul/mdtable"
 12	"gno.land/p/nt/markdown/foreign/v0"
 13	"gno.land/p/nt/mdalert/v0"
 14	"gno.land/p/nt/mux/v0"
 15	"gno.land/p/nt/ufmt/v0"
 16)
 17
 18// renderPost renders a post and (unless it's a leaf or capped) its replies.
 19// desc is the page's sort order, forwarded so a comment's nested inline
 20// children (renderSubReplies) match the order of the view they appear in. It
 21// is unused for the top-level/re-root call (path != "" routes to
 22// renderTopLevelReplies, which derives the order from the path); those
 23// callers pass false.
 24func renderPost(post *boards.Post, path, indent string, levels int, budget *int, desc bool) string {
 25	var b strings.Builder
 26
 27	// Thread reposts might not have a title, if so get title from source thread
 28	title := post.Title
 29	if boards.IsRepost(post) && title == "" {
 30		if board, ok := gBoards.Get(post.OriginalBoardID); ok {
 31			if src, ok := getThread(board, post.ParentID); ok {
 32				title = src.Title
 33			}
 34		}
 35	}
 36
 37	if title != "" { // Replies don't have a title
 38		b.WriteString(md.H2(md.EscapeText(title)))
 39	}
 40
 41	b.WriteString(indent + "\n")
 42	b.WriteString(renderPostContent(post, indent, levels, budget))
 43
 44	if post.Replies.Size() == 0 {
 45		return b.String()
 46	}
 47
 48	// In practice this only fires for the re-rooted view's context-parent,
 49	// which renderPostInner renders with an explicit levels==0. The thread
 50	// recursion does levels-1 in BOTH renderPost and renderTopLevelReplies/
 51	// renderSubReplies, so levels drops by 2 per nesting level and skips 0 —
 52	// i.e. levels does NOT bound depth in the thread view; the render budget
 53	// and the per-node breadth cap (renderSubReplies) are the real bounds.
 54	if levels == 0 {
 55		b.WriteString(indent + "\n")
 56		return b.String()
 57	}
 58
 59	if path != "" {
 60		b.WriteString(renderTopLevelReplies(post, path, indent, levels-1, budget))
 61	} else {
 62		b.WriteString(renderSubReplies(post, indent, levels-1, budget, desc))
 63	}
 64	return b.String()
 65}
 66
 67func renderPostContent(post *boards.Post, indent string, levels int, budget *int) string {
 68	var b strings.Builder
 69
 70	// Author and date header
 71	creatorLink := userLink(post.Creator)
 72	roleBadge := getRoleBadge(post)
 73	date := post.CreatedAt.Format(dateFormat)
 74	b.WriteString(indent)
 75	b.WriteString(md.Bold(creatorLink) + roleBadge + " · " + date)
 76	if !boards.IsThread(post) {
 77		b.WriteString(" " + md.Link("#"+post.ID.String(), makeReplyURI(post)))
 78	}
 79	b.WriteString("  \n")
 80
 81	// Flagged comment should be hidden, but replies still visible (see: #3480)
 82	// Flagged threads will be hidden by render function caller.
 83	if post.Hidden {
 84		link := md.Link("inappropriate", makeFlaggingReasonsURI(post))
 85		b.WriteString(indentBody(indent, "⚠ Reply is hidden as it has been flagged as "+link))
 86		b.WriteString("\n")
 87		return b.String()
 88	}
 89
 90	srcContent, srcPost := renderSourcePost(post, indent, budget)
 91	if boards.IsRepost(post) && srcPost != nil {
 92		msg := ufmt.Sprintf(
 93			"Original thread is %s  \nCreated by %s on %s",
 94			md.Link(srcPost.Title, makeThreadURI(srcPost)),
 95			userLink(srcPost.Creator),
 96			srcPost.CreatedAt.Format(dateFormat),
 97		)
 98
 99		b.WriteString(mdalert.New(mdalert.TypeInfo, "Thread Repost", msg, true).String())
100		b.WriteString("\n")
101	}
102
103	// Render repost body before original thread's body
104	if post.Body != "" {
105		b.WriteString(indentForeignBody(indent, post.Body, budget) + "\n")
106		if srcContent != "" {
107			// Add extra line to separate repost content from original thread content
108			b.WriteString("\n")
109		}
110	}
111
112	b.WriteString(srcContent)
113
114	// Add a newline to separate source deleted message from repost body content
115	if boards.IsRepost(post) && srcPost == nil && len(post.Body) > 0 {
116		b.WriteString("\n\n")
117	}
118
119	// Split thread content and actions
120	if boards.IsThread(post) && !boards.IsRepost(post) {
121		b.WriteString("\n")
122	}
123
124	// Action buttons
125	b.WriteString(indent)
126	if !boards.IsThread(post) { // is comment
127		b.WriteString("  \n")
128		b.WriteString(indent)
129	}
130
131	actions := []string{
132		md.Link("Flag", makeFlagURI(post)),
133	}
134
135	if boards.IsThread(post) {
136		repostAction := md.Link("Repost", makeCreateRepostURI(post))
137		if post.Reposts.Size() > 0 {
138			repostAction += " [" + strconv.Itoa(post.Reposts.Size()) + "]"
139		}
140		actions = append(actions, repostAction)
141	}
142
143	isReadonly := post.Readonly || post.Board.Readonly
144	// A reply doesn't carry the thread's frozen flag (FreezeThread sets
145	// Readonly on the thread post only), so check the enclosing thread too —
146	// otherwise a frozen thread's replies show Reply/Edit/Delete links that
147	// the backend rejects. Mirrors the IsReadonly helper (board || thread).
148	if !isReadonly && !boards.IsThread(post) {
149		if t, ok := getThread(post.Board, post.ThreadID); ok {
150			isReadonly = t.Readonly
151		}
152	}
153	if !isReadonly {
154		replyLabel := "Reply"
155		if boards.IsThread(post) {
156			replyLabel = "Comment"
157		}
158		replyAction := md.Link(replyLabel, makeCreateReplyURI(post))
159		// Add reply count if any
160		if post.Replies.Size() > 0 {
161			replyAction += " [" + strconv.Itoa(post.Replies.Size()) + "]"
162		}
163
164		actions = append(
165			actions,
166			replyAction,
167			md.Link("Edit", makeEditPostURI(post)),
168			md.Link("Delete", makeDeletePostURI(post)),
169		)
170	}
171
172	if levels == 0 {
173		switch {
174		case boards.IsThread(post):
175			actions = append(actions, md.Link("Show all Replies", makeThreadURI(post)))
176		case post.Replies.Size() > 0:
177			// Reached at levels==0 — in practice the re-rooted view's
178			// context-parent (see renderPost). It still has replies below, so
179			// re-root here (Reddit/HN "continue this thread") to keep the
180			// subtree drillable instead of bouncing to the thread root.
181			actions = append(actions, md.Link("Continue this thread →", makeReplyURI(post)))
182		}
183	}
184
185	b.WriteString("↳ " + strings.Join(actions, " • ") + "\n")
186	return b.String()
187}
188
189func renderPostInner(post *boards.Post, path string) string {
190	if boards.IsThread(post) {
191		return ""
192	}
193
194	var (
195		s         string
196		threadID  = post.ThreadID
197		thread, _ = getThread(post.Board, threadID)
198		budget    = maxRenderedBodies()
199	)
200
201	// Fully render parent if it's not a repost.
202	if !boards.IsRepost(post) {
203		parentID := post.ParentID
204		parent := thread
205
206		if thread.ID != parentID {
207			parent, _ = getReply(thread, parentID)
208		}
209
210		s += renderPost(parent, "", "", 0, &budget, false) + "\n"
211	}
212
213	// Pass the reply's own path so renderPost routes to renderTopLevelReplies
214	// and paginates this comment's direct replies (the re-root has its own
215	// ?page= — no collision with the thread view, which is a different path).
216	// desc=false: order is derived from path by renderTopLevelReplies.
217	s += renderPost(post, path, "> ", 5, &budget, false)
218	return s
219}
220
221func renderSourcePost(post *boards.Post, indent string, budget *int) (string, *boards.Post) {
222	if !boards.IsRepost(post) {
223		return "", nil
224	}
225
226	indent += "> "
227
228	// TODO: figure out a way to decouple posts from a global storage.
229	board, ok := gBoards.Get(post.OriginalBoardID)
230	if !ok {
231		// TODO: Boards can't be deleted so this might be redundant
232		return indentBody(indent, "⚠ Source board has been deleted"), nil
233	}
234
235	srcPost, ok := getThread(board, post.ParentID)
236	if !ok {
237		return indentBody(indent, "⚠ Source post has been deleted"), nil
238	}
239
240	if srcPost.Hidden {
241		return indentBody(indent, "⚠ Source post has been flagged as inappropriate"), nil
242	}
243
244	return indentForeignBody(indent, srcPost.Body, budget) + "\n\n", srcPost
245}
246
247func renderFlagPost(res *mux.ResponseWriter, req *mux.Request) {
248	name := req.GetVar("board")
249	board, found := gBoards.GetByName(name)
250	if !found {
251		res.Write("Board not found")
252		return
253	}
254
255	// Thread ID must always be available
256	rawID := req.GetVar("thread")
257	threadID, err := strconv.Atoi(rawID)
258	if err != nil {
259		res.Write("Invalid thread ID: " + md.EscapeText(rawID))
260		return
261	}
262
263	thread, found := getThread(board, boards.ID(threadID))
264	if !found {
265		res.Write("Thread not found")
266		return
267	}
268
269	// Parse reply ID when post is a reply
270	var reply *boards.Post
271	rawID = req.GetVar("reply")
272	isReply := rawID != ""
273	if isReply {
274		replyID, err := strconv.Atoi(rawID)
275		if err != nil {
276			res.Write("Invalid reply ID: " + md.EscapeText(rawID))
277			return
278		}
279
280		reply, _ = getReply(thread, boards.ID(replyID))
281		if reply == nil {
282			res.Write("Reply not found")
283			return
284		}
285	}
286
287	exec := "FlagThread"
288	if isReply {
289		exec = "FlagReply"
290	}
291
292	form := mdform.New("exec", exec)
293	form.Input(
294		"boardID",
295		"placeholder", "Board ID",
296		"value", board.ID.String(),
297		"readonly", "true",
298	)
299	form.Input(
300		"threadID",
301		"placeholder", "Thread ID",
302		"value", thread.ID.String(),
303		"readonly", "true",
304	)
305
306	if isReply {
307		form.Input(
308			"replyID",
309			"placeholder", "Reply ID",
310			"value", reply.ID.String(),
311			"readonly", "true",
312		)
313	}
314
315	form.Input(
316		"reason",
317		"placeholder", "Flagging Reason",
318	)
319
320	// Breadcrumb navigation
321	backLink := md.Link("← Back to thread", makeThreadURI(thread))
322
323	if isReply {
324		res.Write(md.H1(board.Name + ": Flag Comment"))
325	} else {
326		res.Write(md.H1(board.Name + ": Flag Thread"))
327	}
328	res.Write(backLink + "\n\n")
329
330	res.Write(
331		md.Paragraph(
332			"Thread or comment moderation is done through flagging, which is usually done "+
333				"by board members with the moderator role, though other roles could also potentially flag.",
334		) +
335			md.Paragraph(
336				"Flagging relies on a configurable threshold, which by default is of one flag, that when "+
337					"reached leads to the flagged thread or comment to be hidden.",
338			) +
339			md.Paragraph(
340				"Flagging thresholds can be different within each board.",
341			),
342	)
343
344	if isReply {
345		res.Write(
346			md.Paragraph(
347				ufmt.Sprintf(
348					"⚠ You are flagging a %s from %s ⚠",
349					md.Link("comment", makeReplyURI(reply)),
350					userLink(reply.Creator),
351				),
352			),
353		)
354	} else {
355		res.Write(
356			md.Paragraph(
357				ufmt.Sprintf(
358					"⚠ You are flagging the thread: %s ⚠",
359					md.Link(thread.Title, makeThreadURI(thread)),
360				),
361			),
362		)
363	}
364
365	res.Write(form.String())
366	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n")
367}
368
369func renderFlaggingReasonsPost(res *mux.ResponseWriter, req *mux.Request) {
370	name := req.GetVar("board")
371	board, found := gBoards.GetByName(name)
372	if !found {
373		res.Write("Board not found")
374		return
375	}
376
377	// Thread ID must always be available
378	rawID := req.GetVar("thread")
379	threadID, err := strconv.Atoi(rawID)
380	if err != nil {
381		res.Write("Invalid thread ID: " + md.EscapeText(rawID))
382		return
383	}
384
385	thread, found := getThread(board, boards.ID(threadID))
386	if !found {
387		res.Write("Thread not found")
388		return
389	}
390
391	flags := thread.Flags
392
393	// Parse reply ID when post is a reply
394	var reply *boards.Post
395	rawID = req.GetVar("reply")
396	isReply := rawID != ""
397	if isReply {
398		replyID, err := strconv.Atoi(rawID)
399		if err != nil {
400			res.Write("Invalid reply ID: " + md.EscapeText(rawID))
401			return
402		}
403
404		reply, found = getReply(thread, boards.ID(replyID))
405		if !found {
406			res.Write("Reply not found")
407			return
408		}
409
410		flags = reply.Flags
411	}
412
413	table := mdtable.Table{
414		Headers: []string{"Moderator", "Reason"},
415	}
416
417	flags.Iterate(0, flags.Size(), func(f boards.Flag) bool {
418		// f.Reason is user-supplied (only trimmed at write); escape it so a
419		// flag reason can't inject markdown (links/images) or HTML into the
420		// reasons table. md.EscapeText leaves '|' for mdtable to escape.
421		table.Append([]string{userLink(f.User), md.EscapeText(f.Reason)})
422		return false
423	})
424
425	// Breadcrumb navigation
426	backLink := md.Link("← Back to thread", makeThreadURI(thread))
427
428	res.Write(md.H1("Flagging Reasons"))
429	res.Write(backLink + "\n\n")
430	if isReply {
431		res.Write(
432			md.Paragraph(
433				ufmt.Sprintf(
434					"Moderation flags for a %s submitted by %s",
435					md.Link("comment", makeReplyURI(reply)),
436					userLink(reply.Creator),
437				),
438			),
439		)
440	} else {
441		res.Write(
442			md.Paragraph(
443				// Intentionally hide flagged thread title
444				ufmt.Sprintf("Moderation flags for %s", md.Link("thread", makeThreadURI(thread))),
445			),
446		)
447	}
448	res.Write(table.String())
449}
450
451func renderReplyPost(res *mux.ResponseWriter, req *mux.Request) {
452	name := req.GetVar("board")
453	board, found := gBoards.GetByName(name)
454	if !found {
455		res.Write("Board not found")
456		return
457	}
458
459	// Thread ID must always be available
460	rawID := req.GetVar("thread")
461	threadID, err := strconv.Atoi(rawID)
462	if err != nil {
463		res.Write("Invalid thread ID: " + md.EscapeText(rawID))
464		return
465	}
466
467	thread, found := board.Threads.Get(boards.ID(threadID))
468	if !found {
469		res.Write("Thread not found")
470		return
471	}
472
473	// Parse reply ID when post is a reply
474	var reply *boards.Post
475	rawID = req.GetVar("reply")
476	isReply := rawID != ""
477	if isReply {
478		replyID, err := strconv.Atoi(rawID)
479		if err != nil {
480			res.Write("Invalid reply ID: " + md.EscapeText(rawID))
481			return
482		}
483
484		reply, _ = getReply(thread, boards.ID(replyID))
485		if reply == nil {
486			res.Write("Reply not found")
487			return
488		}
489	}
490
491	form := mdform.New("exec", "CreateReply")
492	form.Input(
493		"boardID",
494		"placeholder", "Board ID",
495		"value", board.ID.String(),
496		"readonly", "true",
497	)
498	form.Input(
499		"threadID",
500		"placeholder", "Thread ID",
501		"value", thread.ID.String(),
502		"readonly", "true",
503	)
504
505	if isReply {
506		form.Input(
507			"replyID",
508			"placeholder", "Reply ID",
509			"value", reply.ID.String(),
510			"readonly", "true",
511		)
512	} else {
513		form.Input(
514			"replyID",
515			"placeholder", "Reply ID",
516			"value", "0",
517			"readonly", "true",
518		)
519	}
520
521	form.Textarea(
522		"body",
523		"placeholder", "Comment",
524		"required", "true",
525	)
526
527	// Breadcrumb navigation
528	backLink := md.Link("← Back to thread", makeThreadURI(thread))
529
530	if isReply {
531		res.Write(md.H1(board.Name + ": Reply"))
532		res.Write(backLink + "\n\n")
533		res.Write(
534			md.Paragraph(ufmt.Sprintf("Replying to a comment posted by %s:", userLink(reply.Creator))) +
535				foreign.ForeignWithLabel("Quoted comment", reply.Body),
536		)
537	} else {
538		res.Write(md.H1(board.Name + ": Comment"))
539		res.Write(backLink + "\n\n")
540		res.Write(
541			md.Paragraph(
542				ufmt.Sprintf("Commenting on the thread: %s", md.Link(thread.Title, makeThreadURI(thread))),
543			),
544		)
545	}
546
547	res.Write(form.String())
548	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n")
549}