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}