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}