Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}