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}