Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

board.gno

21.82 Kb · 755 lines
  1package kourtv3
  2import (
  3	"strconv"
  4	"strings"
  5	bptree "gno.land/p/nt/bptree/v0"
  6	sanitize "gno.land/p/nt/markdown/sanitize/v0"
  7)
  8const (
  9	maxBoardTextLen = 2000
 10	maxBoardRowsPerClaim = 512
 11	maxBoardRowsPerAuthor = 8
 12	maxBoardRepliesShown = 8
 13)
 14type boardRow struct {
 15	id     uint64
 16	parent uint64
 17	author address
 18	text   string
 19	score    int64
 20	scoreKey string
 21	at int64
 22	hiddenByAuthor bool
 23	hiddenByMod    bool
 24	global          bool
 25	purged          bool
 26	globalClearedAt     int64
 27	globalClearedAtTime int64
 28}
 29func boardMark(r *boardRow) string {
 30	switch {
 31	case r.purged:
 32		return "x"
 33	case r.global:
 34		return "g"
 35	case r.hiddenByAuthor || r.hiddenByMod:
 36		return "h"
 37	}
 38	return "."
 39}
 40func ensureBoard(cs *claimState) {
 41	if cs.board == nil {
 42		cs.board = bptree.NewBPTree32()
 43		cs.boardTop = bptree.NewBPTree32()
 44		cs.boardWrote = bptree.NewBPTree32()
 45	}
 46}
 47func ensureKids(cs *claimState) *bptree.BPTree {
 48	if cs.boardKids == nil {
 49		cs.boardKids = bptree.NewBPTree32()
 50	}
 51	return cs.boardKids
 52}
 53func ensureScoreIdx(cs *claimState) *bptree.BPTree {
 54	if cs.boardScore == nil {
 55		cs.boardScore = bptree.NewBPTree32()
 56	}
 57	return cs.boardScore
 58}
 59func ensureVoted(cs *claimState) *bptree.BPTree {
 60	if cs.boardVoted == nil {
 61		cs.boardVoted = bptree.NewBPTree32()
 62	}
 63	return cs.boardVoted
 64}
 65func boardOpen(cs *claimState) bool { return cs.verdictAt == 0 && !cs.closed }
 66func boardWroteBy(cs *claimState, who address) int64 {
 67	if cs.boardWrote == nil {
 68		return 0
 69	}
 70	if v := cs.boardWrote.Get(string(who)); v != nil {
 71		return v.(int64)
 72	}
 73	return 0
 74}
 75func PostComment(cur realm, courtSlug string, claimID uint64, parentRow uint64, text string) uint64 {
 76	if !cur.IsCurrent() {
 77		panic(errStaleRealm)
 78	}
 79	who := cur.Previous().Address()
 80	c := mustCourt(courtSlug)
 81	if courtIsPurged(c) {
 82		panic("kourtv3: this court is purged; it takes no new content")
 83	}
 84	cs := mustClaim(c, claimID)
 85	if !boardOpen(cs) {
 86		panic("kourtv3: this claim is settled; its board is closed to new comments")
 87	}
 88	mustBoardWritable(c, cs, who)
 89	text = strings.TrimSpace(text)
 90	if text == "" {
 91		panic("kourtv3: a comment needs text")
 92	}
 93	if runeLen(text) > maxBoardTextLen {
 94		panic("kourtv3: a comment is at most " + strconv.Itoa(maxBoardTextLen) + " characters")
 95	}
 96	if parentRow != 0 {
 97		p := mustBoardRow(cs, parentRow)
 98		if p.parent != 0 {
 99			panic("kourtv3: replies are one level deep; reply to the comment, not to a reply")
100		}
101	}
102	if boardWroteBy(cs, who) >= maxBoardRowsPerAuthor {
103		panic("kourtv3: you already have " + strconv.Itoa(maxBoardRowsPerAuthor) +
104			" comments on this claim")
105	}
106	if cs.board != nil && cs.board.Size() >= maxBoardRowsPerClaim {
107		panic("kourtv3: this claim's board is full")
108	}
109	mustSpendPost(c, who)
110	ensureBoard(cs)
111	cs.boardNextID++
112	r := &boardRow{
113		id: cs.boardNextID, parent: parentRow, author: who, text: text,
114		at: heightNow(),
115	}
116	k := beClaimKey(r.id)
117	cs.board.Set(k, r)
118	if parentRow == 0 {
119		cs.boardTop.Set(k, r)
120		scoreIndexPut(cs, r)
121	} else {
122		ensureKids(cs).Set(beClaimKey(parentRow)+k, r)
123	}
124	cs.boardWrote.Set(string(who), boardWroteBy(cs, who)+1)
125	return r.id
126}
127func mustBoardRow(cs *claimState, rowID uint64) *boardRow {
128	if cs.board == nil {
129		panic("kourtv3: no such comment")
130	}
131	v := cs.board.Get(beClaimKey(rowID))
132	if v == nil {
133		panic("kourtv3: no such comment")
134	}
135	return v.(*boardRow)
136}
137func HideOwnComment(cur realm, courtSlug string, claimID uint64, rowID uint64, hide bool) {
138	if !cur.IsCurrent() {
139		panic(errStaleRealm)
140	}
141	who := cur.Previous().Address()
142	cs := mustClaim(mustCourt(courtSlug), claimID)
143	r := mustBoardRow(cs, rowID)
144	if r.author != who {
145		panic("kourtv3: only a comment's author may hide their own comment")
146	}
147	r.hiddenByAuthor = hide
148	if hide {
149		scoreIndexDrop(cs, r)
150	} else {
151		scoreIndexPut(cs, r)
152	}
153}
154func BoardSize(courtSlug string, claimID uint64) int {
155	cs := mustClaim(mustCourt(courtSlug), claimID)
156	if cs.board == nil {
157		return 0
158	}
159	return cs.board.Size()
160}
161func BoardCounts(courtSlug string, ids string) string {
162	c := mustCourt(courtSlug)
163	out := strings.Builder{}
164	sent := 0
165	for _, part := range strings.Split(ids, ",") {
166		if sent >= seriesPageMax {
167			break
168		}
169		part = strings.TrimSpace(part)
170		if part == "" {
171			continue
172		}
173		id, err := strconv.ParseUint(part, 10, 64)
174		if err != nil {
175			continue
176		}
177		v := c.claims.Get(beClaimKey(id))
178		if v == nil {
179			continue
180		}
181		cs := v.(*claimState)
182		rows, threads := 0, 0
183		if cs.board != nil {
184			rows = cs.board.Size()
185		}
186		if cs.boardTop != nil {
187			threads = cs.boardTop.Size()
188		}
189		if sent > 0 {
190			out.WriteString(";")
191		}
192		out.WriteString(strconv.FormatUint(id, 10))
193		out.WriteString(":" + strconv.Itoa(rows))
194		out.WriteString(":" + strconv.Itoa(threads))
195		sent++
196	}
197	return out.String()
198}
199func BoardHiddenCount(courtSlug string, claimID uint64) int {
200	cs := mustClaim(mustCourt(courtSlug), claimID)
201	if cs.boardTop == nil {
202		return 0
203	}
204	n := 0
205	cs.boardTop.Iterate("", "", func(_ string, v any) bool {
206		if boardMark(v.(*boardRow)) != "." {
207			n++
208		}
209		return false
210	})
211	return n
212}
213func BoardOpen(courtSlug string, claimID uint64) bool {
214	return boardOpen(mustClaim(mustCourt(courtSlug), claimID))
215}
216func CommentsWrittenBy(courtSlug string, claimID uint64, who address) int64 {
217	return boardWroteBy(mustClaim(mustCourt(courtSlug), claimID), who)
218}
219func BoardNewest(courtSlug string, claimID uint64, offset, count int) string {
220	cs := mustClaim(mustCourt(courtSlug), claimID)
221	if cs.boardTop == nil || count <= 0 {
222		return ""
223	}
224	if count > maxBoardRepliesShown*8 {
225		count = maxBoardRepliesShown * 8
226	}
227	var b strings.Builder
228	cs.boardTop.ReverseIterateByOffset(offset, count, func(_ string, v any) bool {
229		r := v.(*boardRow)
230		b.WriteString(strconv.FormatUint(r.id, 10) + "|" + r.author.String() + "|" +
231			strconv.Itoa(boardReplyCount(cs, r.id)) + "|")
232		if m := boardMark(r); m != "." {
233			b.WriteString(m + "|" + strconv.FormatInt(r.at, 10) + "|\n")
234			return false
235		}
236		b.WriteString(".|" + strconv.FormatInt(r.at, 10) + "|" + boardTextFor(r) + "\n")
237		return false
238	})
239	return b.String()
240}
241func boardReplyCount(cs *claimState, parent uint64) int {
242	if cs.boardKids == nil {
243		return 0
244	}
245	pre := beClaimKey(parent)
246	n := 0
247	cs.boardKids.Iterate(pre, "", func(k string, _ any) bool {
248		if !strings.HasPrefix(k, pre) || !boardKeyMine(pre, k) {
249			return true
250		}
251		n++
252		return false
253	})
254	return n
255}
256func boardKeyMine(pre, k string) bool { return len(k) == len(pre)+8 }
257func BoardReplies(courtSlug string, claimID uint64, parent uint64) string {
258	cs := mustClaim(mustCourt(courtSlug), claimID)
259	if cs.boardKids == nil {
260		return ""
261	}
262	pre := beClaimKey(parent)
263	var b strings.Builder
264	n := 0
265	cs.boardKids.Iterate(pre, "", func(k string, v any) bool {
266		if !strings.HasPrefix(k, pre) || !boardKeyMine(pre, k) {
267			return true
268		}
269		r := v.(*boardRow)
270		b.WriteString(strconv.FormatUint(r.id, 10) + "|" + r.author.String() + "|")
271		if m := boardMark(r); m != "." {
272			b.WriteString(m + "|" + strconv.FormatInt(r.at, 10) + "|\n")
273		} else {
274			b.WriteString(".|" + strconv.FormatInt(r.at, 10) + "|" + boardTextFor(r) + "\n")
275		}
276		n++
277		return n >= maxBoardRepliesShown
278	})
279	return b.String()
280}
281func BoardPartyRows(courtSlug string, claimID uint64) string {
282	cs := mustClaim(mustCourt(courtSlug), claimID)
283	if cs.boardTop == nil {
284		return ""
285	}
286	var b strings.Builder
287	for _, r := range boardPartyRows(cs) {
288		role := "answerer"
289		if r.author == cs.author {
290			role = "author"
291		}
292		b.WriteString(strconv.FormatUint(r.id, 10) + "|" + r.author.String() + "|" + role + "|")
293		if m := boardMark(r); m != "." {
294			b.WriteString(m + "|" + strconv.FormatInt(r.at, 10) + "|\n")
295			continue
296		}
297		b.WriteString(".|" + strconv.FormatInt(r.at, 10) + "|" + boardTextFor(r) + "\n")
298	}
299	return b.String()
300}
301func boardTextFor(r *boardRow) string {
302	if boardMark(r) != "." {
303		return ""
304	}
305	return escapeWireText(r.text)
306}
307func escapeWireText(s string) string {
308	if !strings.ContainsAny(s, "\\\n\r") {
309		return s
310	}
311	var b strings.Builder
312	for i := 0; i < len(s); i++ {
313		switch s[i] {
314		case '\\':
315			b.WriteString("\\\\")
316		case '\n':
317			b.WriteString("\\n")
318		case '\r':
319			b.WriteString("\\r")
320		default:
321			b.WriteByte(s[i])
322		}
323	}
324	return b.String()
325}
326func renderBoardPage(slug string, claimID uint64) string {
327	return renderBoardIn(slug, claimID, false)
328}
329func renderBoardTop(slug string, claimID uint64) string {
330	return renderBoardIn(slug, claimID, true)
331}
332func renderBoardHidden(slug string, claimID uint64) string {
333	c := mustCourt(slug)
334	cs := mustClaim(c, claimID)
335	base := "/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug + "/" + strconv.FormatUint(claimID, 10)
336	var b strings.Builder
337	b.WriteString("# Hidden comments on claim " + strconv.FormatUint(claimID, 10) + "\n\n")
338	b.WriteString("[← the comments](" + base + "/board)\n\n")
339	if cs.boardTop == nil {
340		b.WriteString("_Nothing has been hidden here._\n")
341		return b.String()
342	}
343	n := 0
344	cs.boardTop.Iterate("", "", func(_ string, v any) bool {
345		r := v.(*boardRow)
346		m := boardMark(r)
347		if m == "." {
348			return false
349		}
350		n++
351		b.WriteString("---\n\n**" + sanitize.InlineText(r.author.String()) + "**")
352		b.WriteString(" · block " + strconv.FormatInt(r.at, 10))
353		if m != "x" {
354			b.WriteString(" · [#" + strconv.FormatUint(r.id, 10) + "](" + base +
355				"/board/" + strconv.FormatUint(r.id, 10) + ")")
356		} else {
357			b.WriteString(" · #" + strconv.FormatUint(r.id, 10))
358		}
359		b.WriteString("\n\n")
360		switch {
361		case r.purged:
362			b.WriteString("> _Removed on legal grounds. The text is gone from the record; " +
363				"the row remains._\n\n")
364		case r.global:
365			b.WriteString("> _Withheld pending a legal determination._\n\n")
366		case r.hiddenByMod:
367			b.WriteString("> _Removed from the listing by this court's moderators._\n\n")
368			b.WriteString(boardTextVisible(r) + "\n\n")
369		default:
370			b.WriteString("> _Withdrawn from the listing by its author._\n\n")
371			b.WriteString(boardTextVisible(r) + "\n\n")
372		}
373		return false
374	})
375	if n == 0 {
376		b.WriteString("_Nothing has been hidden here._\n")
377	}
378	return b.String()
379}
380func boardPostLink(slug string, claimID, parentRow uint64) string {
381	return "/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3$help&func=PostComment" +
382		"&courtSlug=" + slug +
383		"&claimID=" + strconv.FormatUint(claimID, 10) +
384		"&parentRow=" + strconv.FormatUint(parentRow, 10)
385}
386func renderBoardIn(slug string, claimID uint64, ranked bool) string {
387	c := mustCourt(slug)
388	cs := mustClaim(c, claimID)
389	var b strings.Builder
390	b.WriteString("# Comments on claim " + strconv.FormatUint(claimID, 10) + "\n\n")
391	base := "/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug + "/" + strconv.FormatUint(claimID, 10)
392	b.WriteString("[← the claim](" + base + ")")
393	if ranked {
394		b.WriteString(" · **Top** · [Newest](" + base + "/board)")
395	} else {
396		b.WriteString(" · [Top](" + base + "/board/top) · **Newest**")
397	}
398	b.WriteString("\n\n")
399	b.WriteString("[Write a comment](" + boardPostLink(slug, claimID, 0) + ") · " +
400		"[Where you stand](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug + "/me) · " +
401		helpLink + "\n\n")
402	if !boardOpen(cs) {
403		b.WriteString("> This claim is settled. Its board is closed to new comments;\n" +
404			"> what is here stays.\n\n")
405	}
406	if TextRedacted(c.id, claimID) {
407		b.WriteString("> **The claim itself is withheld or removed on legal grounds.**\n" +
408			"> These comments are a separate record and are not covered by that act.\n\n")
409	}
410	if at, until := claimBoardFrozenAt(c, claimID); until != 0 {
411		when := "block " + strconv.FormatInt(until, 10)
412		if at != 0 {
413			when = strconv.FormatInt(at, 10) + " (" + when + ")"
414		}
415		b.WriteString("> **Comments are paused on this claim by this court's moderators**\n" +
416			"> until " + when + ". The claim itself is\n" +
417			"> unaffected: answers, stakes and the verdict continue as normal.\n\n")
418	}
419	if cs.boardTop == nil || cs.boardTop.Size() == 0 {
420		b.WriteString("_No comments yet._\n")
421		return b.String()
422	}
423	seen := map[uint64]bool{}
424	if parties := boardPartyRows(cs); len(parties) > 0 {
425		b.WriteString("## The parties\n\n")
426		for _, r := range parties {
427			seen[r.id] = true
428			if boardMark(r) != "." {
429				continue
430			}
431			b.WriteString(renderBoardOne(slug, claimID, cs, r, true))
432		}
433		if ranked {
434			b.WriteString("\n## Ranked\n\n")
435		} else {
436			b.WriteString("\n## Newest\n\n")
437		}
438	}
439	shown := 0
440	if ranked {
441		if cs.boardScore != nil {
442			cs.boardScore.IterateByOffset(0, maxBoardRepliesShown*3, func(_ string, v any) bool {
443				r := v.(*boardRow)
444				if seen[r.id] {
445					return false
446				}
447				b.WriteString(renderBoardOne(slug, claimID, cs, r, true))
448				shown++
449				return false
450			})
451		}
452		if shown == 0 {
453			b.WriteString("_Nothing else upvoted yet — [Newest](" + base +
454				"/board) is the fuller view._\n")
455		}
456		writeHiddenLink(&b, slug, claimID, base)
457		return b.String()
458	}
459	cs.boardTop.ReverseIterateByOffset(0, maxBoardRepliesShown*3, func(_ string, v any) bool {
460		r := v.(*boardRow)
461		if seen[r.id] || boardMark(r) != "." {
462			return false
463		}
464		b.WriteString(renderBoardOne(slug, claimID, cs, r, true))
465		shown++
466		return false
467	})
468	if rest := cs.boardTop.Size() - shown - len(seen); rest > 0 {
469		b.WriteString("\n_" + strconv.Itoa(rest) + " older comments._\n")
470	}
471	writeHiddenLink(&b, slug, claimID, base)
472	return b.String()
473}
474func writeHiddenLink(b *strings.Builder, slug string, claimID uint64, base string) {
475	if k := BoardHiddenCount(slug, claimID); k > 0 {
476		b.WriteString("\n[" + strconv.Itoa(k) + " hidden comment" + plural2(k) +
477			"](" + base + "/board/hidden)\n")
478	}
479}
480func boardPartyRows(cs *claimState) []*boardRow {
481	want := []address{cs.author}
482	if cs.frozenAt != 0 && cs.answerer != "" && cs.answerer != cs.author {
483		want = append(want, cs.answerer)
484	}
485	var out []*boardRow
486	for _, who := range want {
487		var newest *boardRow
488		cs.boardTop.ReverseIterate("", "", func(_ string, v any) bool {
489			r := v.(*boardRow)
490			if r.author == who {
491				newest = r
492				return true
493			}
494			return false
495		})
496		if newest != nil {
497			out = append(out, newest)
498		}
499	}
500	return out
501}
502func renderBoardRow(slug string, claimID uint64, rowArg string) string {
503	id, err := strconv.ParseUint(rowArg, 10, 64)
504	if err != nil {
505		return "## Not found\nA comment id must be a number."
506	}
507	c := mustCourt(slug)
508	cs := mustClaim(c, claimID)
509	if cs.board == nil || cs.board.Get(beClaimKey(id)) == nil {
510		return "## Not found\nNo comment by that id on this claim."
511	}
512	r := mustBoardRow(cs, id)
513	var b strings.Builder
514	b.WriteString("# " + claimTitleFor(c, cs) + "\n\n")
515	base := "/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug + "/" + strconv.FormatUint(claimID, 10)
516	b.WriteString("Comment " + strconv.FormatUint(id, 10) + " on this claim in " +
517		"[" + courtNameFor(c) + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug + ")\n\n")
518	b.WriteString("[← the claim](" + base + ") · [all " +
519		strconv.Itoa(cs.board.Size()) + " comments](" + base + "/board)\n\n")
520	b.WriteString(renderBoardOne(slug, claimID, cs, r, false))
521	return b.String()
522}
523func renderBoardOne(slug string, claimID uint64, cs *claimState, r *boardRow, listing bool) string {
524	var b strings.Builder
525	b.WriteString("---\n\n**" + sanitize.InlineText(r.author.String()) + "**")
526	for _, badge := range boardBadges(cs, r.author) {
527		b.WriteString(" · " + badge)
528	}
529	b.WriteString(" · [#" + strconv.FormatUint(r.id, 10) + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug + "/" +
530		strconv.FormatUint(claimID, 10) + "/board/" + strconv.FormatUint(r.id, 10) + ")" +
531		" · block " + strconv.FormatInt(r.at, 10) + "\n\n")
532	if (r.hiddenByAuthor || r.hiddenByMod) && !r.global && !r.purged && listing {
533		if r.hiddenByMod {
534			b.WriteString("> _Removed from this listing by this court's moderators. " +
535				"It still reads at its own link._\n\n")
536		} else {
537			b.WriteString("> _Withdrawn from this listing. It still reads at its own link._\n\n")
538		}
539		return b.String()
540	}
541	b.WriteString(boardTextVisible(r) + "\n\n")
542	if r.parent == 0 {
543		writeBoardReplies(&b, slug, claimID, cs, r.id)
544		if boardOpen(cs) {
545			b.WriteString("[Reply](" + boardPostLink(slug, claimID, r.id) + ")\n\n")
546		}
547	}
548	return b.String()
549}
550func writeBoardReplies(b *strings.Builder, slug string, claimID uint64, cs *claimState, parent uint64) {
551	pre := beClaimKey(parent)
552	n, total := 0, boardReplyCount(cs, parent)
553	if total == 0 {
554		return
555	}
556	cs.boardKids.Iterate(pre, "", func(k string, v any) bool {
557		if !strings.HasPrefix(k, pre) || !boardKeyMine(pre, k) {
558			return true
559		}
560		r := v.(*boardRow)
561		if boardMark(r) != "." {
562			return false
563		}
564		b.WriteString("> **" + sanitize.InlineText(r.author.String()) + "**")
565		for _, badge := range boardBadges(cs, r.author) {
566			b.WriteString(" · " + badge)
567		}
568		b.WriteString(" · [#" + strconv.FormatUint(r.id, 10) + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug +
569			"/" + strconv.FormatUint(claimID, 10) + "/board/" + strconv.FormatUint(r.id, 10) + ")\n>\n")
570		{
571			for _, line := range strings.Split(boardTextVisible(r), "\n") {
572				b.WriteString("> " + line + "\n")
573			}
574			b.WriteString("\n")
575		}
576		n++
577		return n >= maxBoardRepliesShown
578	})
579	if total > n {
580		b.WriteString("_" + strconv.Itoa(total-n) + " more repl" + plural(total-n) +
581			" at [#" + strconv.FormatUint(parent, 10) + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug + "/" +
582			strconv.FormatUint(claimID, 10) + "/board/" + strconv.FormatUint(parent, 10) + ")._\n\n")
583	}
584}
585func plural(n int) string {
586	if n == 1 {
587		return "y"
588	}
589	return "ies"
590}
591func boardBadges(cs *claimState, who address) []string {
592	var out []string
593	if who == cs.author {
594		out = append(out, "author")
595	}
596	if cs.frozenAt != 0 && who == cs.answerer {
597		out = append(out, "answerer")
598	}
599	for _, side := range []int{sideYES, sideNO} {
600		if v := cs.stakers.Get(posKey(who, side)); v != nil {
601			if p := v.(*stakePos); p.stake > 0 {
602				name := "YES"
603				if side == sideNO {
604					name = "NO"
605				}
606				out = append(out, name+" "+strconv.FormatInt(p.stake, 10))
607			}
608		}
609	}
610	return out
611}
612func boardTextVisible(r *boardRow) string {
613	if r.purged {
614		return "> _Removed. The text is gone from the record; the row remains._"
615	}
616	if r.global {
617		return "> _Withheld pending a legal determination._"
618	}
619	if r.text == "" {
620		return "_(empty)_"
621	}
622	return sanitize.Block(r.text)
623}
624func boardScoreKey(score int64, rowID uint64) string {
625	return beInv(score) + beInv(int64(rowID))
626}
627func scoreIndexPut(cs *claimState, r *boardRow) {
628	if r.parent != 0 || boardMark(r) != "." {
629		return
630	}
631	r.scoreKey = boardScoreKey(r.score, r.id)
632	ensureScoreIdx(cs).Set(r.scoreKey, r)
633}
634func scoreIndexDrop(cs *claimState, r *boardRow) {
635	if cs.boardScore == nil || r.scoreKey == "" {
636		return
637	}
638	cs.boardScore.Remove(r.scoreKey)
639	r.scoreKey = ""
640}
641func UpvoteComment(cur realm, courtSlug string, claimID uint64, rowID uint64) int64 {
642	if !cur.IsCurrent() {
643		panic(errStaleRealm)
644	}
645	who := cur.Previous().Address()
646	c := mustCourt(courtSlug)
647	if courtIsPurged(c) {
648		panic("kourtv3: this court is purged; it takes no new content")
649	}
650	cs := mustClaim(c, claimID)
651	if !boardOpen(cs) {
652		panic("kourtv3: this claim is settled; its board is closed")
653	}
654	mustBoardWritable(c, cs, who)
655	r := mustBoardRow(cs, rowID)
656	if r.parent != 0 {
657		panic("kourtv3: only top-level comments are ranked; a reply is read under its parent")
658	}
659	if r.author == who {
660		panic("kourtv3: you cannot upvote your own comment")
661	}
662	if r.purged {
663		panic("kourtv3: that comment was destroyed on legal grounds; it takes no votes")
664	}
665	vk := beClaimKey(rowID) + string(who)
666	if cs.boardVoted != nil && cs.boardVoted.Has(vk) {
667		panic("kourtv3: you have already upvoted that comment")
668	}
669	var w int64
670	if sr := lookupStanding(c, who); sr != nil {
671		w = sr.score
672	}
673	if w <= 0 {
674		panic("kourtv3: an upvote is weighted by standing, and you have none here yet")
675	}
676	mustSpendPost(c, who)
677	ensureBoard(cs)
678	ensureVoted(cs).Set(vk, true)
679	scoreIndexDrop(cs, r)
680	r.score = satAdd(r.score, w)
681	scoreIndexPut(cs, r)
682	return r.score
683}
684func HasUpvoted(courtSlug string, claimID uint64, rowID uint64, who address) bool {
685	cs := mustClaim(mustCourt(courtSlug), claimID)
686	if cs.boardVoted == nil {
687		return false
688	}
689	return cs.boardVoted.Has(beClaimKey(rowID) + string(who))
690}
691func CommentScore(courtSlug string, claimID uint64, rowID uint64) int64 {
692	return mustBoardRow(mustClaim(mustCourt(courtSlug), claimID), rowID).score
693}
694func BoardTop(courtSlug string, claimID uint64, offset, count int) string {
695	cs := mustClaim(mustCourt(courtSlug), claimID)
696	if cs.boardScore == nil || count <= 0 {
697		return ""
698	}
699	if count > maxBoardRepliesShown*8 {
700		count = maxBoardRepliesShown * 8
701	}
702	var b strings.Builder
703	cs.boardScore.IterateByOffset(offset, count, func(_ string, v any) bool {
704		r := v.(*boardRow)
705		b.WriteString(strconv.FormatUint(r.id, 10) + "|" + r.author.String() + "|" +
706			strconv.FormatInt(r.score, 10) + "|.|" + strconv.FormatInt(r.at, 10) +
707			"|" + boardTextFor(r) + "\n")
708		return false
709	})
710	return b.String()
711}
712func writeBoardLink(b *strings.Builder, c *Court, cs *claimState, claimID uint64) {
713	n := 0
714	if cs.board != nil {
715		n = cs.board.Size()
716	}
717	link := "/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + c.id + "/" + strconv.FormatUint(claimID, 10) + "/board"
718	switch {
719	case n == 0 && !boardOpen(cs):
720		return
721	case n == 0:
722		b.WriteString("## Comments\n\n[Write the first one](" +
723			boardPostLink(c.id, claimID, 0) + ")\n\n")
724		return
725	}
726	b.WriteString("## Comments\n\n")
727	b.WriteString("[Write a comment](" + boardPostLink(c.id, claimID, 0) + ")\n\n")
728	shown, drawn := 0, 0
729	if cs.boardTop != nil {
730		cs.boardTop.ReverseIterate("", "", func(_ string, v any) bool {
731			r := v.(*boardRow)
732			if boardMark(r) != "." {
733				return false
734			}
735			b.WriteString(renderBoardOne(c.id, claimID, cs, r, true))
736			shown += 1 + boardReplyCount(cs, r.id)
737			drawn++
738			return drawn >= claimBoardPreview
739		})
740	}
741	all := ""
742	if shown < n {
743		all = "all "
744	}
745	b.WriteString("\n[" + all + strconv.Itoa(n) + " comment" + plural2(n) + "](" + link +
746		") · [Top](" + link + "/top)\n\n")
747	writeHiddenLink(b, c.id, claimID, "/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:"+c.id+"/"+strconv.FormatUint(claimID, 10))
748}
749const claimBoardPreview = 3
750func plural2(n int) string {
751	if n == 1 {
752		return ""
753	}
754	return "s"
755}