Search Apps Documentation Source Content File Folder Download Copy Actions Download

render_thread.gno

8.32 Kb · 314 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	if boards.IsRepost(thread) {
137		summary += ``
138		postURI += ` "This is a thread repost"`
139	}
140
141	b.WriteString(md.H6(md.Link(summary, postURI)))
142	b.WriteString("Created by " + creatorLink + roleBadge + " on " + date + "  \n")
143
144	status := []string{
145		strconv.Itoa(thread.Replies.Size()) + " replies",
146		strconv.Itoa(thread.Reposts.Size()) + " reposts",
147	}
148	b.WriteString(md.Bold(strings.Join(status, " • ")) + "\n")
149	return b.String()
150}
151
152func renderCreateThread(res *mux.ResponseWriter, req *mux.Request) {
153	name := req.GetVar("board")
154	board, found := gBoards.GetByName(name)
155	if !found {
156		res.Write("Board not found")
157		return
158	}
159
160	form := mdform.New("exec", "CreateThread")
161	form.Input(
162		"boardID",
163		"placeholder", "Board ID",
164		"value", board.ID.String(),
165		"readonly", "true",
166	)
167	form.Input(
168		"title",
169		"placeholder", "Title",
170		"required", "true",
171	)
172	form.Textarea(
173		"body",
174		"placeholder", "Content",
175		"rows", "10",
176		"required", "true",
177	)
178
179	res.Write(md.H1(board.Name + ": Create Thread"))
180	res.Write(md.Link("← Back to board", makeBoardURI(board)) + "\n\n")
181	res.Write(
182		md.Paragraph(
183			ufmt.Sprintf("Thread will be created in the board: %s", md.Link(board.Name, makeBoardURI(board))),
184		),
185	)
186	res.Write(form.String())
187	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to board", makeBoardURI(board)) + "\n")
188}
189
190func renderEditThread(res *mux.ResponseWriter, req *mux.Request) {
191	name := req.GetVar("board")
192	board, found := gBoards.GetByName(name)
193	if !found {
194		res.Write("Board not found")
195		return
196	}
197
198	rawID := req.GetVar("thread")
199	threadID, err := strconv.Atoi(rawID)
200	if err != nil {
201		res.Write("Invalid thread ID: " + md.EscapeText(rawID))
202		return
203	}
204
205	thread, found := getThread(board, boards.ID(threadID))
206	if !found {
207		res.Write("Thread not found")
208		return
209	}
210
211	form := mdform.New("exec", "EditThread")
212	form.Input(
213		"boardID",
214		"placeholder", "Board ID",
215		"value", board.ID.String(),
216		"readonly", "true",
217	)
218	form.Input(
219		"threadID",
220		"placeholder", "Thread ID",
221		"value", thread.ID.String(),
222		"readonly", "true",
223	)
224	form.Input(
225		"title",
226		"placeholder", "Title",
227		"value", thread.Title,
228		"required", "true",
229	)
230	form.Textarea(
231		"body",
232		"placeholder", "Content",
233		"rows", "10",
234		"value", thread.Body,
235		"required", "true",
236	)
237
238	res.Write(md.H1(board.Name + ": Edit Thread"))
239	res.Write(md.Link("← Back to thread", makeThreadURI(thread)) + "\n\n")
240	res.Write(
241		md.Paragraph("Editing " + md.Link(thread.Title, makeThreadURI(thread))),
242	)
243	res.Write(form.String())
244	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n")
245}
246
247func renderRepostThread(res *mux.ResponseWriter, req *mux.Request) {
248	name := req.GetVar("board")
249	board, found := gBoards.GetByName(name)
250	if !found {
251		res.Write("Board not found")
252		return
253	}
254
255	rawID := req.GetVar("thread")
256	threadID, err := strconv.Atoi(rawID)
257	if err != nil {
258		res.Write("Invalid thread ID: " + md.EscapeText(rawID))
259		return
260	}
261
262	thread, found := getThread(board, boards.ID(threadID))
263	if !found {
264		res.Write("Thread not found")
265		return
266	}
267
268	form := mdform.New("exec", "CreateRepost")
269	form.Input(
270		"boardID",
271		"placeholder", "Board ID",
272		"value", board.ID.String(),
273		"readonly", "true",
274	)
275	form.Input(
276		"threadID",
277		"placeholder", "Thread ID",
278		"value", thread.ID.String(),
279		"readonly", "true",
280	)
281	form.Input(
282		"destinationBoardID",
283		"type", mdform.InputTypeNumber,
284		"placeholder", "Board ID where to repost",
285		"required", "true",
286	)
287	form.Input(
288		"title",
289		"value", thread.Title,
290		"placeholder", "Title",
291		"required", "true",
292	)
293	form.Textarea(
294		"body",
295		"placeholder", "Content",
296		"rows", "10",
297	)
298
299	res.Write(md.H1(board.Name + ": Repost Thread"))
300	res.Write(md.Link("← Back to thread", makeThreadURI(thread)) + "\n\n")
301	res.Write(
302		md.Paragraph(
303			"Threads can be reposted to other open boards or boards where you are a member " +
304				"and are allowed to create new threads.",
305		),
306	)
307	res.Write(
308		md.Paragraph(
309			ufmt.Sprintf("Reposting the thread: %s.", md.Link(thread.Title, makeThreadURI(thread))),
310		),
311	)
312	res.Write(form.String())
313	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n")
314}