render_post.gno

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/mdalert/v0"
	"gno.land/p/nt/mux/v0"
	"gno.land/p/nt/ufmt/v0"
)

func renderPost(post *boards.Post, path, indent string, levels int) 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(title))
	}

	b.WriteString(indent + "\n")
	b.WriteString(renderPostContent(post, indent, levels))

	if post.Replies.Size() == 0 {
		return b.String()
	}

	// XXX: This triggers for reply views
	if levels == 0 {
		b.WriteString(indent + "\n")
		return b.String()
	}

	if path != "" {
		b.WriteString(renderTopLevelReplies(post, path, indent, levels-1))
	} else {
		b.WriteString(renderSubReplies(post, indent, levels-1))
	}
	return b.String()
}

func renderPostContent(post *boards.Post, indent string, levels 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)
	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(indentBody(indent, post.Body) + "\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
	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 {
		if boards.IsThread(post) {
			actions = append(actions, md.Link("Show all Replies", makeThreadURI(post)))
		} else {
			actions = append(actions, md.Link("View Thread", makeThreadURI(post)))
		}
	}

	b.WriteString("↳ " + strings.Join(actions, " • ") + "\n")
	return b.String()
}

func renderPostInner(post *boards.Post) string {
	if boards.IsThread(post) {
		return ""
	}

	var (
		s         string
		threadID  = post.ThreadID
		thread, _ = getThread(post.Board, threadID)
	)

	// 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) + "\n"
	}

	s += renderPost(post, "", "> ", 5)
	return s
}

func renderSourcePost(post *boards.Post, indent string) (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 indentBody(indent, srcPost.Body) + "\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: " + 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: " + 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: " + 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: " + 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 {
		table.Append([]string{userLink(f.User), 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: " + 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: " + 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))) +
				md.Blockquote(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")
}