render_reply.gno
7.23 Kb · 249 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
16func renderReply(res *mux.ResponseWriter, req *mux.Request) {
17 name := req.GetVar("board")
18 board, found := gBoards.GetByName(name)
19 if !found {
20 res.Write("Board not found")
21 return
22 }
23
24 rawID := req.GetVar("thread")
25 threadID, err := strconv.Atoi(rawID)
26 if err != nil {
27 res.Write("Invalid thread ID: " + md.EscapeText(rawID))
28 return
29 }
30
31 rawID = req.GetVar("reply")
32 replyID, err := strconv.Atoi(rawID)
33 if err != nil {
34 res.Write("Invalid reply ID: " + md.EscapeText(rawID))
35 return
36 }
37
38 thread, found := getThread(board, boards.ID(threadID))
39 if !found {
40 res.Write("Thread not found")
41 return
42 }
43
44 reply, found := getReply(thread, boards.ID(replyID))
45 if !found {
46 res.Write("Reply not found")
47 return
48 }
49
50 // Call render even for hidden replies to display children.
51 // Original comment content will be hidden under the hood.
52 // See: #3480
53 res.Write(renderPostInner(reply, req.RawPath))
54}
55
56// newClampedPager builds a pager, clamping an out-of-range, zero, negative,
57// or malformed ?page= to the last valid page instead of erroring. pager.New
58// rejects page==0, page>pageCount, and non-numeric pages with
59// ErrInvalidPageNumber, which the callers would otherwise surface as a panic
60// (aborting the whole render) or an error page in place of the list. A
61// NEGATIVE page is not rejected by pager.New (it only checks ==0 and
62// >pageCount), so it must be caught here too — otherwise it renders a broken
63// "page -N of M" picker with both arrows disabled. Triggers on a stale deep
64// link after deletions, or a hand-edited URL.
65func newClampedPager(path string, size, pageSize int) pager.Pager {
66 p, err := pager.New(path, size, pager.WithPageSize(pageSize))
67 if err == nil && p.Page() >= 1 {
68 return p
69 }
70 // Re-parse without ?page= to get a valid page-1 pager and read the real
71 // page count, then jump to the last page when there is more than one.
72 r := parseRealmPath(path)
73 r.Query.Del("page")
74 first, ferr := pager.New(r.String(), size, pager.WithPageSize(pageSize))
75 if ferr != nil {
76 // A page-less path is valid for every current route, so this is
77 // unreachable today; fall back to the original pager rather than
78 // asserting the invariant with a panic that would abort the render.
79 return p
80 }
81 if last := first.PageCount(); last > 1 {
82 r.Query.Set("page", strconv.Itoa(last))
83 if clamped, e := pager.New(r.String(), size, pager.WithPageSize(pageSize)); e == nil {
84 return clamped
85 }
86 }
87 return first
88}
89
90func renderTopLevelReplies(post *boards.Post, path, indent string, levels int, budget *int) string {
91 p := newClampedPager(path, post.Replies.Size(), pageSizeReplies)
92 link, desc := sortToggleLink(path)
93
94 var (
95 b strings.Builder
96 commentsIndent = indent + "> "
97 truncated bool
98 )
99
100 render := func(reply *boards.Post) bool {
101 if *budget <= 0 {
102 truncated = true
103 return true // stop: render budget exhausted (see maxRenderedBodies)
104 }
105 // Forward the page order so this reply's nested children match it.
106 b.WriteString(indent + "\n" + renderPost(reply, "", commentsIndent, levels-1, budget, desc))
107 return false
108 }
109
110 b.WriteString("\n" + md.HorizontalRule() + "Sort by: " + link + "\n")
111
112 count := p.PageSize()
113 if desc {
114 count = -count // Reverse iterate
115 }
116
117 post.Replies.Iterate(p.Offset(), count, render)
118
119 if truncated {
120 b.WriteString(indent + "\n" + commentsIndent + "_Some replies not shown — " +
121 md.Link("view all comments", makeThreadFlatURI(post)) + "._\n")
122 }
123
124 // Suppress the page picker when the budget truncated this page: later
125 // replies in the page were skipped, so the offset-based "next page" would
126 // jump past them. The flat link above is the complete, reachable view.
127 if !truncated && p.HasPages() {
128 b.WriteString(md.HorizontalRule())
129 b.WriteString(pager.Picker(p))
130 }
131 return b.String()
132}
133
134func renderSubReplies(post *boards.Post, indent string, levels int, budget *int, desc bool) string {
135 var (
136 b strings.Builder
137 commentsIndent = indent + "> "
138 truncated bool
139 )
140
141 // Cap inline children at pageSizeReplies. A nested reply list can't have
142 // its own pager (it would collide with the page's ?page=), so instead of
143 // dumping every child here a comment with more links to its own re-rooted
144 // view, which paginates them. Keeps every view bounded to <=pageSizeReplies
145 // children per post. count's sign follows the page order so the inline
146 // children match the view they appear in (desc → the newest ones).
147 count := pageSizeReplies
148 if desc {
149 count = -count
150 }
151 post.Replies.Iterate(0, count, func(reply *boards.Post) bool {
152 if *budget <= 0 {
153 truncated = true
154 return true // stop: render budget exhausted (see maxRenderedBodies)
155 }
156 b.WriteString(indent + "\n" + renderPost(reply, "", commentsIndent, levels-1, budget, desc))
157 return false
158 })
159
160 notice := func(text, uri string) {
161 b.WriteString(indent + "\n" + commentsIndent + md.Link(text, uri) + "\n")
162 }
163 switch {
164 case truncated:
165 // Budget exhausted: the whole-thread flat view is the reachable backstop.
166 notice("More replies — view all comments", makeThreadFlatURI(post))
167 case post.Replies.Size() > pageSizeReplies:
168 // Breadth cap hit: re-root at this comment to page the rest, carrying
169 // the page's sort order so the re-root opens the same way (its first
170 // page then matches the newest-first children shown inline here).
171 uri := makeReplyURI(post)
172 if desc {
173 uri += "?order=desc"
174 }
175 notice("View all "+strconv.Itoa(post.Replies.Size())+" replies", uri)
176 }
177 return b.String()
178}
179
180func renderEditReply(res *mux.ResponseWriter, req *mux.Request) {
181 name := req.GetVar("board")
182 board, found := gBoards.GetByName(name)
183 if !found {
184 res.Write("Board not found")
185 return
186 }
187
188 rawID := req.GetVar("thread")
189 threadID, err := strconv.Atoi(rawID)
190 if err != nil {
191 res.Write("Invalid thread ID: " + md.EscapeText(rawID))
192 return
193 }
194
195 rawID = req.GetVar("reply")
196 replyID, err := strconv.Atoi(rawID)
197 if err != nil {
198 res.Write("Invalid reply ID: " + md.EscapeText(rawID))
199 return
200 }
201
202 thread, found := getThread(board, boards.ID(threadID))
203 if !found {
204 res.Write("Thread not found")
205 return
206 }
207
208 reply, found := getReply(thread, boards.ID(replyID))
209 if !found {
210 res.Write("Reply not found")
211 return
212 }
213
214 form := mdform.New("exec", "EditReply")
215 form.Input(
216 "boardID",
217 "placeholder", "Board ID",
218 "value", board.ID.String(),
219 "readonly", "true",
220 )
221 form.Input(
222 "threadID",
223 "placeholder", "Thread ID",
224 "value", thread.ID.String(),
225 "readonly", "true",
226 )
227 form.Input(
228 "replyID",
229 "placeholder", "Reply ID",
230 "value", reply.ID.String(),
231 "readonly", "true",
232 )
233 form.Textarea(
234 "body",
235 "placeholder", "Comment",
236 "value", reply.Body,
237 "required", "true",
238 )
239
240 res.Write(md.H1(board.Name + ": Edit Comment"))
241 res.Write(md.Link("← Back to thread", makeThreadURI(thread)) + "\n\n")
242 res.Write(
243 md.Paragraph(
244 ufmt.Sprintf("Editing a comment from the thread: %s", md.Link(thread.Title, makeThreadURI(thread))),
245 ),
246 )
247 res.Write(form.String())
248 res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to thread", makeThreadURI(thread)) + "\n")
249}