package boards2 import ( "strconv" "strings" "gno.land/p/gnoland/boards" "gno.land/p/jeronimoalbi/mdform" "gno.land/p/leon/svgbtn" "gno.land/p/moul/md" "gno.land/p/moul/mdtable" "gno.land/p/nt/markdown/foreign/v0" "gno.land/p/nt/mdalert/v0" "gno.land/p/nt/mux/v0" "gno.land/p/nt/ufmt/v0" ) // renderPost renders a post and (unless it's a leaf or capped) its replies. // desc is the page's sort order, forwarded so a comment's nested inline // children (renderSubReplies) match the order of the view they appear in. It // is unused for the top-level/re-root call (path != "" routes to // renderTopLevelReplies, which derives the order from the path); those // callers pass false. func renderPost(post *boards.Post, path, indent string, levels int, budget *int, desc bool) string { var b strings.Builder // Thread reposts might not have a title, if so get title from source thread title := post.Title if boards.IsRepost(post) && title == "" { if board, ok := gBoards.Get(post.OriginalBoardID); ok { if src, ok := getThread(board, post.ParentID); ok { title = src.Title } } } if title != "" { // Replies don't have a title b.WriteString(md.H2(md.EscapeText(title))) } b.WriteString(indent + "\n") b.WriteString(renderPostContent(post, indent, levels, budget)) if post.Replies.Size() == 0 { return b.String() } // In practice this only fires for the re-rooted view's context-parent, // which renderPostInner renders with an explicit levels==0. The thread // recursion does levels-1 in BOTH renderPost and renderTopLevelReplies/ // renderSubReplies, so levels drops by 2 per nesting level and skips 0 — // i.e. levels does NOT bound depth in the thread view; the render budget // and the per-node breadth cap (renderSubReplies) are the real bounds. if levels == 0 { b.WriteString(indent + "\n") return b.String() } if path != "" { b.WriteString(renderTopLevelReplies(post, path, indent, levels-1, budget)) } else { b.WriteString(renderSubReplies(post, indent, levels-1, budget, desc)) } return b.String() } func renderPostContent(post *boards.Post, indent string, levels int, budget *int) string { var b strings.Builder // Author and date header creatorLink := userLink(post.Creator) roleBadge := getRoleBadge(post) date := post.CreatedAt.Format(dateFormat) b.WriteString(indent) b.WriteString(md.Bold(creatorLink) + roleBadge + " · " + date) if !boards.IsThread(post) { b.WriteString(" " + md.Link("#"+post.ID.String(), makeReplyURI(post))) } b.WriteString(" \n") // Flagged comment should be hidden, but replies still visible (see: #3480) // Flagged threads will be hidden by render function caller. if post.Hidden { link := md.Link("inappropriate", makeFlaggingReasonsURI(post)) b.WriteString(indentBody(indent, "⚠ Reply is hidden as it has been flagged as "+link)) b.WriteString("\n") return b.String() } srcContent, srcPost := renderSourcePost(post, indent, budget) if boards.IsRepost(post) && srcPost != nil { msg := ufmt.Sprintf( "Original thread is %s \nCreated by %s on %s", md.Link(srcPost.Title, makeThreadURI(srcPost)), userLink(srcPost.Creator), srcPost.CreatedAt.Format(dateFormat), ) b.WriteString(mdalert.New(mdalert.TypeInfo, "Thread Repost", msg, true).String()) b.WriteString("\n") } // Render repost body before original thread's body if post.Body != "" { b.WriteString(indentForeignBody(indent, post.Body, budget) + "\n") if srcContent != "" { // Add extra line to separate repost content from original thread content b.WriteString("\n") } } b.WriteString(srcContent) // Add a newline to separate source deleted message from repost body content if boards.IsRepost(post) && srcPost == nil && len(post.Body) > 0 { b.WriteString("\n\n") } // Split thread content and actions if boards.IsThread(post) && !boards.IsRepost(post) { b.WriteString("\n") } // Action buttons b.WriteString(indent) if !boards.IsThread(post) { // is comment b.WriteString(" \n") b.WriteString(indent) } actions := []string{ md.Link("Flag", makeFlagURI(post)), } if boards.IsThread(post) { repostAction := md.Link("Repost", makeCreateRepostURI(post)) if post.Reposts.Size() > 0 { repostAction += " [" + strconv.Itoa(post.Reposts.Size()) + "]" } actions = append(actions, repostAction) } isReadonly := post.Readonly || post.Board.Readonly // A reply doesn't carry the thread's frozen flag (FreezeThread sets // Readonly on the thread post only), so check the enclosing thread too — // otherwise a frozen thread's replies show Reply/Edit/Delete links that // the backend rejects. Mirrors the IsReadonly helper (board || thread). if !isReadonly && !boards.IsThread(post) { if t, ok := getThread(post.Board, post.ThreadID); ok { isReadonly = t.Readonly } } if !isReadonly { replyLabel := "Reply" if boards.IsThread(post) { replyLabel = "Comment" } replyAction := md.Link(replyLabel, makeCreateReplyURI(post)) // Add reply count if any if post.Replies.Size() > 0 { replyAction += " [" + strconv.Itoa(post.Replies.Size()) + "]" } actions = append( actions, replyAction, md.Link("Edit", makeEditPostURI(post)), md.Link("Delete", makeDeletePostURI(post)), ) } if levels == 0 { switch { case boards.IsThread(post): actions = append(actions, md.Link("Show all Replies", makeThreadURI(post))) case post.Replies.Size() > 0: // Reached at levels==0 — in practice the re-rooted view's // context-parent (see renderPost). It still has replies below, so // re-root here (Reddit/HN "continue this thread") to keep the // subtree drillable instead of bouncing to the thread root. actions = append(actions, md.Link("Continue this thread →", makeReplyURI(post))) } } b.WriteString("↳ " + strings.Join(actions, " • ") + "\n") return b.String() } func renderPostInner(post *boards.Post, path string) string { if boards.IsThread(post) { return "" } var ( s string threadID = post.ThreadID thread, _ = getThread(post.Board, threadID) budget = maxRenderedBodies() ) // Fully render parent if it's not a repost. if !boards.IsRepost(post) { parentID := post.ParentID parent := thread if thread.ID != parentID { parent, _ = getReply(thread, parentID) } s += renderPost(parent, "", "", 0, &budget, false) + "\n" } // Pass the reply's own path so renderPost routes to renderTopLevelReplies // and paginates this comment's direct replies (the re-root has its own // ?page= — no collision with the thread view, which is a different path). // desc=false: order is derived from path by renderTopLevelReplies. s += renderPost(post, path, "> ", 5, &budget, false) return s } func renderSourcePost(post *boards.Post, indent string, budget *int) (string, *boards.Post) { if !boards.IsRepost(post) { return "", nil } indent += "> " // TODO: figure out a way to decouple posts from a global storage. board, ok := gBoards.Get(post.OriginalBoardID) if !ok { // TODO: Boards can't be deleted so this might be redundant return indentBody(indent, "⚠ Source board has been deleted"), nil } srcPost, ok := getThread(board, post.ParentID) if !ok { return indentBody(indent, "⚠ Source post has been deleted"), nil } if srcPost.Hidden { return indentBody(indent, "⚠ Source post has been flagged as inappropriate"), nil } return indentForeignBody(indent, srcPost.Body, budget) + "\n\n", srcPost } func renderFlagPost(res *mux.ResponseWriter, req *mux.Request) { name := req.GetVar("board") board, found := gBoards.GetByName(name) if !found { res.Write("Board not found") return } // Thread ID must always be available rawID := req.GetVar("thread") threadID, err := strconv.Atoi(rawID) if err != nil { res.Write("Invalid thread ID: " + md.EscapeText(rawID)) return } thread, found := getThread(board, boards.ID(threadID)) if !found { res.Write("Thread not found") return } // Parse reply ID when post is a reply var reply *boards.Post rawID = req.GetVar("reply") isReply := rawID != "" if isReply { replyID, err := strconv.Atoi(rawID) if err != nil { res.Write("Invalid reply ID: " + md.EscapeText(rawID)) return } reply, _ = getReply(thread, boards.ID(replyID)) if reply == nil { res.Write("Reply not found") return } } exec := "FlagThread" if isReply { exec = "FlagReply" } form := mdform.New("exec", exec) form.Input( "boardID", "placeholder", "Board ID", "value", board.ID.String(), "readonly", "true", ) form.Input( "threadID", "placeholder", "Thread ID", "value", thread.ID.String(), "readonly", "true", ) if isReply { form.Input( "replyID", "placeholder", "Reply ID", "value", reply.ID.String(), "readonly", "true", ) } form.Input( "reason", "placeholder", "Flagging Reason", ) // Breadcrumb navigation backLink := md.Link("← Back to thread", makeThreadURI(thread)) if isReply { res.Write(md.H1(board.Name + ": Flag Comment")) } else { res.Write(md.H1(board.Name + ": Flag Thread")) } res.Write(backLink + "\n\n") res.Write( md.Paragraph( "Thread or comment moderation is done through flagging, which is usually done "+ "by board members with the moderator role, though other roles could also potentially flag.", ) + md.Paragraph( "Flagging relies on a configurable threshold, which by default is of one flag, that when "+ "reached leads to the flagged thread or comment to be hidden.", ) + md.Paragraph( "Flagging thresholds can be different within each board.", ), ) if isReply { res.Write( md.Paragraph( ufmt.Sprintf( "⚠ You are flagging a %s from %s ⚠", md.Link("comment", makeReplyURI(reply)), userLink(reply.Creator), ), ), ) } else { res.Write( md.Paragraph( ufmt.Sprintf( "⚠ You are flagging the thread: %s ⚠", md.Link(thread.Title, makeThreadURI(thread)), ), ), ) } res.Write(form.String()) res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n") } func renderFlaggingReasonsPost(res *mux.ResponseWriter, req *mux.Request) { name := req.GetVar("board") board, found := gBoards.GetByName(name) if !found { res.Write("Board not found") return } // Thread ID must always be available rawID := req.GetVar("thread") threadID, err := strconv.Atoi(rawID) if err != nil { res.Write("Invalid thread ID: " + md.EscapeText(rawID)) return } thread, found := getThread(board, boards.ID(threadID)) if !found { res.Write("Thread not found") return } flags := thread.Flags // Parse reply ID when post is a reply var reply *boards.Post rawID = req.GetVar("reply") isReply := rawID != "" if isReply { replyID, err := strconv.Atoi(rawID) if err != nil { res.Write("Invalid reply ID: " + md.EscapeText(rawID)) return } reply, found = getReply(thread, boards.ID(replyID)) if !found { res.Write("Reply not found") return } flags = reply.Flags } table := mdtable.Table{ Headers: []string{"Moderator", "Reason"}, } flags.Iterate(0, flags.Size(), func(f boards.Flag) bool { // f.Reason is user-supplied (only trimmed at write); escape it so a // flag reason can't inject markdown (links/images) or HTML into the // reasons table. md.EscapeText leaves '|' for mdtable to escape. table.Append([]string{userLink(f.User), md.EscapeText(f.Reason)}) return false }) // Breadcrumb navigation backLink := md.Link("← Back to thread", makeThreadURI(thread)) res.Write(md.H1("Flagging Reasons")) res.Write(backLink + "\n\n") if isReply { res.Write( md.Paragraph( ufmt.Sprintf( "Moderation flags for a %s submitted by %s", md.Link("comment", makeReplyURI(reply)), userLink(reply.Creator), ), ), ) } else { res.Write( md.Paragraph( // Intentionally hide flagged thread title ufmt.Sprintf("Moderation flags for %s", md.Link("thread", makeThreadURI(thread))), ), ) } res.Write(table.String()) } func renderReplyPost(res *mux.ResponseWriter, req *mux.Request) { name := req.GetVar("board") board, found := gBoards.GetByName(name) if !found { res.Write("Board not found") return } // Thread ID must always be available rawID := req.GetVar("thread") threadID, err := strconv.Atoi(rawID) if err != nil { res.Write("Invalid thread ID: " + md.EscapeText(rawID)) return } thread, found := board.Threads.Get(boards.ID(threadID)) if !found { res.Write("Thread not found") return } // Parse reply ID when post is a reply var reply *boards.Post rawID = req.GetVar("reply") isReply := rawID != "" if isReply { replyID, err := strconv.Atoi(rawID) if err != nil { res.Write("Invalid reply ID: " + md.EscapeText(rawID)) return } reply, _ = getReply(thread, boards.ID(replyID)) if reply == nil { res.Write("Reply not found") return } } form := mdform.New("exec", "CreateReply") form.Input( "boardID", "placeholder", "Board ID", "value", board.ID.String(), "readonly", "true", ) form.Input( "threadID", "placeholder", "Thread ID", "value", thread.ID.String(), "readonly", "true", ) if isReply { form.Input( "replyID", "placeholder", "Reply ID", "value", reply.ID.String(), "readonly", "true", ) } else { form.Input( "replyID", "placeholder", "Reply ID", "value", "0", "readonly", "true", ) } form.Textarea( "body", "placeholder", "Comment", "required", "true", ) // Breadcrumb navigation backLink := md.Link("← Back to thread", makeThreadURI(thread)) if isReply { res.Write(md.H1(board.Name + ": Reply")) res.Write(backLink + "\n\n") res.Write( md.Paragraph(ufmt.Sprintf("Replying to a comment posted by %s:", userLink(reply.Creator))) + foreign.ForeignWithLabel("Quoted comment", reply.Body), ) } else { res.Write(md.H1(board.Name + ": Comment")) res.Write(backLink + "\n\n") res.Write( md.Paragraph( ufmt.Sprintf("Commenting on the thread: %s", md.Link(thread.Title, makeThreadURI(thread))), ), ) } res.Write(form.String()) res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n") }