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