Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}