Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

render_thread.gno

8.33 Kb · 315 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/jeronimoalbi/pager"
 10	"gno.land/p/leon/svgbtn"
 11	"gno.land/p/moul/md"
 12	"gno.land/p/nt/mux/v0"
 13	"gno.land/p/nt/ufmt/v0"
 14)
 15
 16// maxFlatIndentDepth caps the blockquote nesting in the flat comment view so
 17// deep reply chains stay readable; comments deeper than this still render,
 18// just at the capped indent.
 19const maxFlatIndentDepth = 6
 20
 21func renderThread(res *mux.ResponseWriter, req *mux.Request) {
 22	name := req.GetVar("board")
 23	board, found := gBoards.GetByName(name)
 24	if !found {
 25		res.Write("Board not found")
 26		return
 27	}
 28
 29	rawID := req.GetVar("thread")
 30	threadID, err := strconv.Atoi(rawID)
 31	if err != nil {
 32		res.Write("Invalid thread ID: " + md.EscapeText(rawID))
 33		return
 34	}
 35
 36	thread, found := getThread(board, boards.ID(threadID))
 37	if !found {
 38		res.Write("Thread not found")
 39		return
 40	}
 41
 42	if thread.Hidden {
 43		link := md.Link("inappropriate", makeFlaggingReasonsURI(thread))
 44		res.Write("⚠ Thread has been flagged as " + link)
 45		return
 46	}
 47
 48	res.Write(md.H1(md.Link("Boards", gRealmPath) + " › " + md.Link(board.Name, makeBoardURI(board))))
 49	budget := maxRenderedBodies()
 50	if parseRealmPath(req.RawPath).Query.Get("flat") != "" {
 51		res.Write(renderThreadFlat(thread, req.RawPath, &budget))
 52		return
 53	}
 54	res.Write(renderPost(thread, req.RawPath, "", 5, &budget, false))
 55}
 56
 57// renderThreadFlat renders every comment in the thread as a single flat,
 58// depth-indented, paginated list backed by ThreadMeta.AllReplies (the index
 59// that already holds every reply at every depth). Unlike the recursive
 60// threaded view — which bounds work with the render budget and truncates a
 61// large subtree — this view is reachable to the very last comment: each
 62// comment renders one <gno-foreign> block (no recursion), so a fixed page
 63// size (pageSizeFlat) plus the OP stays well under the budget regardless of
 64// nesting, and the pager always advances. ?order=desc shows newest first, so
 65// its page 1 is the latest comments.
 66func renderThreadFlat(thread *boards.Post, path string, budget *int) string {
 67	var b strings.Builder
 68
 69	// The OP for context (its body only; no replies — levels 0).
 70	b.WriteString(renderPost(thread, "", "", 0, budget, false))
 71
 72	meta, ok := thread.Meta.(*ThreadMeta)
 73	if !ok || meta.AllReplies.Size() == 0 {
 74		return b.String()
 75	}
 76	all := meta.AllReplies
 77	p := newClampedPager(path, all.Size(), pageSizeFlat)
 78
 79	b.WriteString("\n" + md.HorizontalRule())
 80	b.WriteString(md.Link("← Threaded view", makeThreadURI(thread)) + " · All " +
 81		strconv.Itoa(all.Size()) + " comments — sort by: ")
 82
 83	// sortToggleLink preserves flat=1, so the toggle stays in the flat view.
 84	link, desc := sortToggleLink(path)
 85	b.WriteString(link + "\n")
 86
 87	count := p.PageSize()
 88	if desc {
 89		count = -count // reverse iterate: newest first
 90	}
 91	all.Iterate(p.Offset(), count, func(reply *boards.Post) bool {
 92		if *budget <= 0 {
 93			// Unreachable while pageSizeFlat << maxRenderedBodies; a backstop
 94			// in case a chain upgrade drops the native cap below one page.
 95			return true
 96		}
 97		indent := flatIndent(thread, reply)
 98		b.WriteString(indent + "\n" + renderPost(reply, "", indent, 0, budget, false))
 99		return false
100	})
101
102	if p.HasPages() {
103		b.WriteString(md.HorizontalRule())
104		b.WriteString(pager.Picker(p))
105	}
106	return b.String()
107}
108
109// flatIndent returns the blockquote indent for a reply in the flat view,
110// derived from its depth below the thread root (depth 1 = a direct reply to
111// the thread), capped at maxFlatIndentDepth.
112func flatIndent(thread *boards.Post, reply *boards.Post) string {
113	depth := 1
114	pid := reply.ParentID
115	for pid != thread.ID && pid != 0 && depth < maxFlatIndentDepth {
116		parent, ok := getReply(thread, pid)
117		if !ok {
118			break
119		}
120		depth++
121		pid = parent.ParentID
122	}
123	return strings.Repeat("> ", depth)
124}
125
126func renderThreadSummary(thread *boards.Post) string {
127	var (
128		b           strings.Builder
129		postURI     = makeThreadURI(thread)
130		summary     = summaryOf(thread.Title, 80)
131		creatorLink = userLink(thread.Creator)
132		roleBadge   = getRoleBadge(thread)
133		date        = thread.CreatedAt.Format(dateFormat)
134	)
135
136	byline := "Created by "
137	if boards.IsRepost(thread) {
138		summary += ``
139		byline = "Reposted by "
140	}
141
142	b.WriteString(md.H6(md.Link(summary, postURI)))
143	b.WriteString(byline + creatorLink + roleBadge + " on " + date + "  \n")
144
145	status := []string{
146		strconv.Itoa(thread.Replies.Size()) + " replies",
147		strconv.Itoa(thread.Reposts.Size()) + " reposts",
148	}
149	b.WriteString(md.Bold(strings.Join(status, " • ")) + "\n")
150	return b.String()
151}
152
153func renderCreateThread(res *mux.ResponseWriter, req *mux.Request) {
154	name := req.GetVar("board")
155	board, found := gBoards.GetByName(name)
156	if !found {
157		res.Write("Board not found")
158		return
159	}
160
161	form := mdform.New("exec", "CreateThread")
162	form.Input(
163		"boardID",
164		"placeholder", "Board ID",
165		"value", board.ID.String(),
166		"readonly", "true",
167	)
168	form.Input(
169		"title",
170		"placeholder", "Title",
171		"required", "true",
172	)
173	form.Textarea(
174		"body",
175		"placeholder", "Content",
176		"rows", "10",
177		"required", "true",
178	)
179
180	res.Write(md.H1(board.Name + ": Create Thread"))
181	res.Write(md.Link("← Back to board", makeBoardURI(board)) + "\n\n")
182	res.Write(
183		md.Paragraph(
184			ufmt.Sprintf("Thread will be created in the board: %s", md.Link(board.Name, makeBoardURI(board))),
185		),
186	)
187	res.Write(form.String())
188	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to board", makeBoardURI(board)) + "\n")
189}
190
191func renderEditThread(res *mux.ResponseWriter, req *mux.Request) {
192	name := req.GetVar("board")
193	board, found := gBoards.GetByName(name)
194	if !found {
195		res.Write("Board not found")
196		return
197	}
198
199	rawID := req.GetVar("thread")
200	threadID, err := strconv.Atoi(rawID)
201	if err != nil {
202		res.Write("Invalid thread ID: " + md.EscapeText(rawID))
203		return
204	}
205
206	thread, found := getThread(board, boards.ID(threadID))
207	if !found {
208		res.Write("Thread not found")
209		return
210	}
211
212	form := mdform.New("exec", "EditThread")
213	form.Input(
214		"boardID",
215		"placeholder", "Board ID",
216		"value", board.ID.String(),
217		"readonly", "true",
218	)
219	form.Input(
220		"threadID",
221		"placeholder", "Thread ID",
222		"value", thread.ID.String(),
223		"readonly", "true",
224	)
225	form.Input(
226		"title",
227		"placeholder", "Title",
228		"value", thread.Title,
229		"required", "true",
230	)
231	form.Textarea(
232		"body",
233		"placeholder", "Content",
234		"rows", "10",
235		"value", thread.Body,
236		"required", "true",
237	)
238
239	res.Write(md.H1(board.Name + ": Edit Thread"))
240	res.Write(md.Link("← Back to thread", makeThreadURI(thread)) + "\n\n")
241	res.Write(
242		md.Paragraph("Editing " + md.Link(thread.Title, makeThreadURI(thread))),
243	)
244	res.Write(form.String())
245	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n")
246}
247
248func renderRepostThread(res *mux.ResponseWriter, req *mux.Request) {
249	name := req.GetVar("board")
250	board, found := gBoards.GetByName(name)
251	if !found {
252		res.Write("Board not found")
253		return
254	}
255
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	form := mdform.New("exec", "CreateRepost")
270	form.Input(
271		"boardID",
272		"placeholder", "Board ID",
273		"value", board.ID.String(),
274		"readonly", "true",
275	)
276	form.Input(
277		"threadID",
278		"placeholder", "Thread ID",
279		"value", thread.ID.String(),
280		"readonly", "true",
281	)
282	form.Input(
283		"destinationBoardID",
284		"type", mdform.InputTypeNumber,
285		"placeholder", "Board ID where to repost",
286		"required", "true",
287	)
288	form.Input(
289		"title",
290		"value", thread.Title,
291		"placeholder", "Title",
292		"required", "true",
293	)
294	form.Textarea(
295		"body",
296		"placeholder", "Content",
297		"rows", "10",
298	)
299
300	res.Write(md.H1(board.Name + ": Repost Thread"))
301	res.Write(md.Link("← Back to thread", makeThreadURI(thread)) + "\n\n")
302	res.Write(
303		md.Paragraph(
304			"Threads can be reposted to other open boards or boards where you are a member " +
305				"and are allowed to create new threads.",
306		),
307	)
308	res.Write(
309		md.Paragraph(
310			ufmt.Sprintf("Reposting the thread: %s.", md.Link(thread.Title, makeThreadURI(thread))),
311		),
312	)
313	res.Write(form.String())
314	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n")
315}