Search Apps Documentation Source Content File Folder Download Copy Actions Download

store.gno

21.08 Kb · 704 lines
  1// Package reviews is the shared engine behind Memba's on-chain reviews / web-of-trust.
  2//
  3// It holds NO global state: a realm owns a *Store (avl-backed) and calls its methods.
  4// This is the reusable core extracted from memba_reviews_v2 (DoS-hardened: RV-1 O(1)
  5// per-subject summary counters, RV-3 flaggedIDs GC), so the app-reviews realm and any
  6// future memba_reviews_v3 share one audited implementation instead of forked copies.
  7//
  8// SPLIT OF CONCERNS: the Store is pure storage + validation + event emission. It takes the
  9// caller address and block height as explicit arguments — the consuming realm reads those
 10// from unsafe.PreviousRealm()/runtime.ChainHeight(), performs the moderator-multisig gate on
 11// Hide*/Unhide, and passes them in. That keeps the Store unit-testable without a realm frame.
 12package memba_reviews_core_v1
 13
 14import (
 15	"strconv"
 16	"strings"
 17
 18	"chain"
 19
 20	"gno.land/p/nt/avl/v0"
 21	"gno.land/p/nt/ufmt/v0"
 22)
 23
 24// ── Constants ────────────────────────────────────────────────
 25const (
 26	MaxBodyLen    = 2000 // review body
 27	MaxCommentLen = 1000 // comment body
 28	MaxPageLimit  = 100  // hard cap on any paginated read (DoS guard)
 29)
 30
 31// ── Types ────────────────────────────────────────────────────
 32type Review struct {
 33	ID        uint64
 34	Subject   string // g1… address OR realm path
 35	Author    address
 36	Rating    int    // 1..5
 37	Body      string // optional, ≤ MaxBodyLen
 38	CreatedAt int64  // block height
 39	EditedAt  int64  // block height of last edit (0 if never)
 40	Hidden    bool   // multisig soft-delete / auto-hide
 41	Deleted   bool   // author tombstone
 42	Likes     uint64
 43	Dislikes  uint64
 44	FlagCount uint64
 45}
 46
 47type Comment struct {
 48	ID        uint64
 49	ReviewID  uint64
 50	Author    address
 51	Body      string // ≤ MaxCommentLen
 52	CreatedAt int64
 53	EditedAt  int64
 54	Hidden    bool
 55	Deleted   bool
 56	Likes     uint64
 57	Dislikes  uint64
 58	FlagCount uint64
 59}
 60
 61// Store owns all review state for one consuming realm.
 62type Store struct {
 63	reviews       *avl.Tree // strID(id) -> *Review
 64	comments      *avl.Tree // strID(id) -> *Comment
 65	subjectIndex  *avl.Tree // subject -> []uint64 (review IDs, ascending)
 66	commentIndex  *avl.Tree // strID(reviewID) -> []uint64 (comment IDs, ascending)
 67	authorSubject *avl.Tree // subject + "\x00" + author -> uint64 (reviewID) — one-per-pair
 68	reactions     *avl.Tree // strID(targetID) + "/" + addr -> "like"|"dislike"
 69	flags         *avl.Tree // strID(targetID) + "/" + addr -> true
 70	reputation    *avl.Tree // addr -> int64 (Σ likes−dislikes on their reviews+comments)
 71	flaggedIDs    *avl.Tree // strID(targetID) -> true (visible targets with ≥1 flag)
 72	subjStatCount *avl.Tree // subject -> int64 (# visible reviews) — RV-1 O(1) summary
 73	subjStatSum   *avl.Tree // subject -> int64 (Σ rating over visible reviews)
 74	nextID        uint64
 75}
 76
 77// NewStore returns an empty review store ready for a realm to embed.
 78func NewStore() *Store {
 79	return &Store{
 80		reviews:       avl.NewTree(),
 81		comments:      avl.NewTree(),
 82		subjectIndex:  avl.NewTree(),
 83		commentIndex:  avl.NewTree(),
 84		authorSubject: avl.NewTree(),
 85		reactions:     avl.NewTree(),
 86		flags:         avl.NewTree(),
 87		reputation:    avl.NewTree(),
 88		flaggedIDs:    avl.NewTree(),
 89		subjStatCount: avl.NewTree(),
 90		subjStatSum:   avl.NewTree(),
 91		nextID:        1,
 92	}
 93}
 94
 95// ── Pure helpers (no state) ──────────────────────────────────
 96func strID(id uint64) string { return strconv.FormatUint(id, 10) }
 97
 98func validRating(r int) bool { return r >= 1 && r <= 5 }
 99
100func validBody(b string) bool { return len(b) <= MaxBodyLen }
101
102func validComment(b string) bool { return b != "" && len(b) <= MaxCommentLen }
103
104// pairKey returns the authorSubject tree key for a (subject, author) pair.
105// The NUL separator prevents prefix collisions between subject and author.
106func pairKey(subject string, a address) string { return subject + "\x00" + a.String() }
107
108func idList(t *avl.Tree, key string) []uint64 {
109	if v, ok := t.Get(key); ok {
110		return v.([]uint64)
111	}
112	return nil
113}
114
115// removeID returns ids with the first occurrence of target removed.
116func removeID(ids []uint64, target uint64) []uint64 {
117	out := make([]uint64, 0, len(ids))
118	removed := false
119	for _, id := range ids {
120		if !removed && id == target {
121			removed = true
122			continue
123		}
124		out = append(out, id)
125	}
126	return out
127}
128
129func getStat(t *avl.Tree, subject string) int64 {
130	if v, ok := t.Get(subject); ok {
131		return v.(int64)
132	}
133	return 0
134}
135
136func boolToInt(b bool) int {
137	if b {
138		return 1
139	}
140	return 0
141}
142
143// reactionDelta returns how a reaction change (old -> newKind, each "" | "like" | "dislike")
144// moves the target's like count, dislike count, and the target author's reputation.
145func reactionDelta(old, newKind string) (likesDelta, dislikesDelta, repDelta int) {
146	likesDelta = boolToInt(newKind == "like") - boolToInt(old == "like")
147	dislikesDelta = boolToInt(newKind == "dislike") - boolToInt(old == "dislike")
148	repDelta = likesDelta - dislikesDelta
149	return
150}
151
152// applyDelta adjusts an unsigned counter by ±delta without underflow.
153func applyDelta(v uint64, delta int) uint64 {
154	if delta < 0 {
155		d := uint64(-delta)
156		if v < d {
157			return 0
158		}
159		return v - d
160	}
161	return v + uint64(delta)
162}
163
164// clampLimit ensures limit is in [1, MaxPageLimit].
165func clampLimit(limit int) int {
166	if limit <= 0 || limit > MaxPageLimit {
167		return MaxPageLimit
168	}
169	return limit
170}
171
172// window slices ids[offset : offset+limit] safely (no panics on out-of-range).
173func window(ids []uint64, offset, limit int) []uint64 {
174	limit = clampLimit(limit)
175	if offset < 0 {
176		offset = 0
177	}
178	if offset >= len(ids) {
179		return nil
180	}
181	end := offset + limit
182	if end > len(ids) {
183		end = len(ids)
184	}
185	return ids[offset:end]
186}
187
188// SanitizeForRender strips markdown/HTML-sensitive chars from user strings used inside a
189// realm's Render() markdown (defense-in-depth; the frontend also DOMPurifies). "&" first.
190func SanitizeForRender(s string) string {
191	r := strings.NewReplacer(
192		"&", "&amp;",
193		"<", "&lt;", ">", "&gt;",
194		"[", "(", "]", ")",
195		"`", "'", "|", "/",
196		"\n", " ", "\r", " ",
197	)
198	return r.Replace(s)
199}
200
201// jsonEscape escapes a string for embedding in hand-built JSON.
202func jsonEscape(s string) string {
203	var b strings.Builder
204	for _, c := range s {
205		switch c {
206		case '"':
207			b.WriteString("\\\"")
208		case '\\':
209			b.WriteString("\\\\")
210		case '\n':
211			b.WriteString("\\n")
212		case '\r':
213			b.WriteString("\\r")
214		case '\t':
215			b.WriteString("\\t")
216		default:
217			if c < 0x20 {
218				const hexDigits = "0123456789abcdef"
219				b.WriteString("\\u00")
220				b.WriteByte(hexDigits[int((c>>4)&0xf)])
221				b.WriteByte(hexDigits[int(c&0xf)])
222			} else {
223				b.WriteRune(c)
224			}
225		}
226	}
227	return b.String()
228}
229
230// ── Store state helpers ──────────────────────────────────────
231func (s *Store) getReview(id uint64) (*Review, bool) {
232	v, ok := s.reviews.Get(strID(id))
233	if !ok {
234		return nil, false
235	}
236	return v.(*Review), true
237}
238
239func (s *Store) getComment(id uint64) (*Comment, bool) {
240	v, ok := s.comments.Get(strID(id))
241	if !ok {
242		return nil, false
243	}
244	return v.(*Comment), true
245}
246
247func (s *Store) getReputation(addr string) int64 { return getStat(s.reputation, addr) }
248
249func (s *Store) addReputation(addr string, delta int64) {
250	s.reputation.Set(addr, s.getReputation(addr)+delta)
251}
252
253// applySubjectStat adjusts the visible-review counters (RV-1) for a subject.
254func (s *Store) applySubjectStat(subject string, dCount, dSum int64) {
255	if dCount != 0 {
256		s.subjStatCount.Set(subject, getStat(s.subjStatCount, subject)+dCount)
257	}
258	if dSum != 0 {
259		s.subjStatSum.Set(subject, getStat(s.subjStatSum, subject)+dSum)
260	}
261}
262
263// ── Review writes ────────────────────────────────────────────
264
265// PostReview creates the caller's review for `subject`, or replaces it in place if one already
266// exists (the "one editable review per pair" rule). Returns the review id.
267func (s *Store) PostReview(caller address, subject string, rating int, body string, height int64) uint64 {
268	if subject == "" {
269		panic("subject required")
270	}
271	if !validRating(rating) {
272		panic("rating must be 1..5")
273	}
274	if !validBody(body) {
275		panic("body too long")
276	}
277
278	pk := pairKey(subject, caller)
279	if v, ok := s.authorSubject.Get(pk); ok {
280		r, found := s.getReview(v.(uint64))
281		if found && !r.Deleted && !r.Hidden {
282			// counted review already exists for this (subject, author): rating change only.
283			s.applySubjectStat(subject, 0, int64(rating)-int64(r.Rating))
284			r.Rating = rating
285			r.Body = body
286			r.EditedAt = height
287			s.reviews.Set(strID(r.ID), r)
288			chain.Emit("ReviewUpdated", "id", strID(r.ID), "subject", subject)
289			return r.ID
290		}
291	}
292
293	id := s.nextID
294	s.nextID++
295	r := &Review{
296		ID:        id,
297		Subject:   subject,
298		Author:    caller,
299		Rating:    rating,
300		Body:      body,
301		CreatedAt: height,
302	}
303	s.reviews.Set(strID(id), r)
304	s.authorSubject.Set(pk, id)
305	s.subjectIndex.Set(subject, append(idList(s.subjectIndex, subject), id))
306	s.applySubjectStat(subject, 1, int64(rating)) // new visible review
307	chain.Emit("ReviewPosted", "id", strID(id), "subject", subject, "author", caller.String())
308	return id
309}
310
311// EditReview updates rating + body of an existing non-deleted review. Author only.
312func (s *Store) EditReview(caller address, reviewID uint64, rating int, body string, height int64) {
313	r, ok := s.getReview(reviewID)
314	if !ok || r.Deleted {
315		panic("review not found")
316	}
317	if r.Author != caller {
318		panic("author only")
319	}
320	if !validRating(rating) {
321		panic("rating must be 1..5")
322	}
323	if !validBody(body) {
324		panic("body too long")
325	}
326	if !r.Hidden { // counted (r.Deleted is false per guard): apply the rating delta
327		s.applySubjectStat(r.Subject, 0, int64(rating)-int64(r.Rating))
328	}
329	r.Rating = rating
330	r.Body = body
331	r.EditedAt = height
332	s.reviews.Set(strID(reviewID), r)
333	chain.Emit("ReviewUpdated", "id", strID(reviewID), "subject", r.Subject)
334}
335
336// DeleteReview tombstones the caller's review: keeps the ID + reaction history, clears the
337// body, and frees the (author, subject) pair. Author only.
338func (s *Store) DeleteReview(caller address, reviewID uint64) {
339	r, ok := s.getReview(reviewID)
340	if !ok || r.Deleted {
341		panic("review not found")
342	}
343	if r.Author != caller {
344		panic("author only")
345	}
346	if !r.Hidden { // was counted (r.Deleted false per guard): drop from subject stats
347		s.applySubjectStat(r.Subject, -1, -int64(r.Rating))
348	}
349	r.Deleted = true
350	r.Body = ""
351	s.reviews.Set(strID(reviewID), r)
352	s.authorSubject.Remove(pairKey(r.Subject, caller))
353	s.subjectIndex.Set(r.Subject, removeID(idList(s.subjectIndex, r.Subject), reviewID))
354	s.flaggedIDs.Remove(strID(reviewID)) // RV-3: don't leave a tombstoned review in the dashboard
355	chain.Emit("ReviewDeleted", "id", strID(reviewID), "subject", r.Subject)
356}
357
358// ── Comment writes ───────────────────────────────────────────
359
360// PostComment posts a flat reply to an existing, non-deleted, non-hidden review. Returns id.
361func (s *Store) PostComment(caller address, reviewID uint64, body string, height int64) uint64 {
362	r, ok := s.getReview(reviewID)
363	if !ok || r.Deleted || r.Hidden {
364		panic("review not found")
365	}
366	if !validComment(body) {
367		panic("comment length invalid")
368	}
369	id := s.nextID
370	s.nextID++
371	c := &Comment{
372		ID:        id,
373		ReviewID:  reviewID,
374		Author:    caller,
375		Body:      body,
376		CreatedAt: height,
377	}
378	s.comments.Set(strID(id), c)
379	s.commentIndex.Set(strID(reviewID), append(idList(s.commentIndex, strID(reviewID)), id))
380	chain.Emit("CommentPosted", "id", strID(id), "review", strID(reviewID), "author", caller.String())
381	return id
382}
383
384// EditComment updates the body of an existing, non-deleted comment. Author only.
385func (s *Store) EditComment(caller address, commentID uint64, body string, height int64) {
386	c, ok := s.getComment(commentID)
387	if !ok || c.Deleted {
388		panic("comment not found")
389	}
390	if c.Author != caller {
391		panic("author only")
392	}
393	if !validComment(body) {
394		panic("comment length invalid")
395	}
396	c.Body = body
397	c.EditedAt = height
398	s.comments.Set(strID(commentID), c)
399	chain.Emit("CommentUpdated", "id", strID(commentID))
400}
401
402// DeleteComment tombstones the caller's comment. Author only.
403func (s *Store) DeleteComment(caller address, commentID uint64) {
404	c, ok := s.getComment(commentID)
405	if !ok || c.Deleted {
406		panic("comment not found")
407	}
408	if c.Author != caller {
409		panic("author only")
410	}
411	c.Deleted = true
412	c.Body = ""
413	s.comments.Set(strID(commentID), c)
414	s.flaggedIDs.Remove(strID(commentID)) // RV-3
415	chain.Emit("CommentDeleted", "id", strID(commentID))
416}
417
418// ── React ────────────────────────────────────────────────────
419
420// React records or toggles a like/dislike on a review or comment. Re-reacting with the same
421// kind toggles it off; switching kind replaces it. Self-reactions and deleted/hidden targets
422// are rejected.
423func (s *Store) React(caller address, targetID uint64, kind string) {
424	if kind != "like" && kind != "dislike" {
425		panic("kind must be like or dislike")
426	}
427
428	r, isReview := s.getReview(targetID)
429	c, isComment := s.getComment(targetID)
430	if !isReview && !isComment {
431		panic("target not found")
432	}
433
434	var author address
435	if isReview {
436		author = r.Author
437		if r.Deleted || r.Hidden {
438			panic("target not found")
439		}
440	} else {
441		author = c.Author
442		if c.Deleted || c.Hidden {
443			panic("target not found")
444		}
445	}
446	if author == caller {
447		panic("cannot react to your own review or comment")
448	}
449
450	rk := strID(targetID) + "/" + caller.String()
451	var old string
452	if v, ok := s.reactions.Get(rk); ok {
453		old = v.(string)
454	}
455
456	newKind := kind
457	if old == kind {
458		newKind = "" // toggle off
459	}
460
461	likesDelta, dislikesDelta, repDelta := reactionDelta(old, newKind)
462
463	if newKind == "" {
464		s.reactions.Remove(rk)
465	} else {
466		s.reactions.Set(rk, newKind)
467	}
468
469	if isReview {
470		r.Likes = applyDelta(r.Likes, likesDelta)
471		r.Dislikes = applyDelta(r.Dislikes, dislikesDelta)
472		s.reviews.Set(strID(targetID), r)
473	} else {
474		c.Likes = applyDelta(c.Likes, likesDelta)
475		c.Dislikes = applyDelta(c.Dislikes, dislikesDelta)
476		s.comments.Set(strID(targetID), c)
477	}
478	s.addReputation(author.String(), int64(repDelta))
479	chain.Emit("Reacted", "target", strID(targetID), "kind", newKind, "by", caller.String())
480}
481
482// ── Flag + moderation ────────────────────────────────────────
483
484// Flag records one community flag per account per target. Takedowns are moderator-only
485// (HideReview/HideComment, gated by the realm). Deleted/hidden targets are rejected.
486func (s *Store) Flag(caller address, targetID uint64) {
487	r, isReview := s.getReview(targetID)
488	c, isComment := s.getComment(targetID)
489	if !isReview && !isComment {
490		panic("target not found")
491	}
492	if isReview && (r.Deleted || r.Hidden) {
493		panic("target not found")
494	}
495	if isComment && (c.Deleted || c.Hidden) {
496		panic("target not found")
497	}
498	fk := strID(targetID) + "/" + caller.String()
499	if _, ok := s.flags.Get(fk); ok {
500		panic("already flagged")
501	}
502	s.flags.Set(fk, true)
503
504	if isReview {
505		r.FlagCount++
506		s.reviews.Set(strID(targetID), r)
507	} else {
508		c.FlagCount++
509		s.comments.Set(strID(targetID), c)
510	}
511	s.flaggedIDs.Set(strID(targetID), true)
512	chain.Emit("Flagged", "target", strID(targetID), "by", caller.String())
513}
514
515// HideReview soft-deletes a review. The realm gates this to the moderator multisig.
516func (s *Store) HideReview(id uint64) {
517	r, ok := s.getReview(id)
518	if !ok {
519		panic("review not found")
520	}
521	if !r.Hidden && !r.Deleted { // was counted → drop from subject stats
522		s.applySubjectStat(r.Subject, -1, -int64(r.Rating))
523	}
524	r.Hidden = true
525	s.reviews.Set(strID(id), r)
526	chain.Emit("Hidden", "target", strID(id))
527}
528
529// HideComment soft-deletes a comment. The realm gates this to the moderator multisig.
530func (s *Store) HideComment(id uint64) {
531	c, ok := s.getComment(id)
532	if !ok {
533		panic("comment not found")
534	}
535	c.Hidden = true
536	s.comments.Set(strID(id), c)
537	chain.Emit("Hidden", "target", strID(id))
538}
539
540// Unhide reverses a hide on a review or comment. The realm gates this to the moderator multisig.
541func (s *Store) Unhide(targetID uint64) {
542	if r, ok := s.getReview(targetID); ok {
543		if r.Hidden && !r.Deleted { // becomes counted again → restore to subject stats
544			s.applySubjectStat(r.Subject, 1, int64(r.Rating))
545		}
546		r.Hidden = false
547		s.reviews.Set(strID(targetID), r)
548		s.flaggedIDs.Remove(strID(targetID))
549		chain.Emit("Unhidden", "target", strID(targetID))
550		return
551	}
552	if c, ok := s.getComment(targetID); ok {
553		c.Hidden = false
554		s.comments.Set(strID(targetID), c)
555		s.flaggedIDs.Remove(strID(targetID))
556		chain.Emit("Unhidden", "target", strID(targetID))
557		return
558	}
559	panic("target not found")
560}
561
562// ── Reads ────────────────────────────────────────────────────
563
564func (s *Store) reviewJSON(r *Review) string {
565	body := ""
566	if !r.Deleted {
567		body = jsonEscape(r.Body)
568	}
569	return ufmt.Sprintf(
570		`{"id":%d,"subject":"%s","author":"%s","rating":%d,"body":"%s","createdAt":%d,"editedAt":%d,"deleted":%t,"likes":%d,"dislikes":%d,"flags":%d,"reputation":%d}`,
571		r.ID, jsonEscape(r.Subject), jsonEscape(r.Author.String()), r.Rating, body,
572		r.CreatedAt, r.EditedAt, r.Deleted, r.Likes, r.Dislikes, r.FlagCount,
573		s.getReputation(r.Author.String()),
574	)
575}
576
577func (s *Store) commentJSON(c *Comment) string {
578	body := ""
579	if !c.Deleted {
580		body = jsonEscape(c.Body)
581	}
582	return ufmt.Sprintf(
583		`{"id":%d,"reviewId":%d,"author":"%s","body":"%s","createdAt":%d,"editedAt":%d,"deleted":%t,"likes":%d,"dislikes":%d,"flags":%d,"reputation":%d}`,
584		c.ID, c.ReviewID, jsonEscape(c.Author.String()), body,
585		c.CreatedAt, c.EditedAt, c.Deleted, c.Likes, c.Dislikes, c.FlagCount,
586		s.getReputation(c.Author.String()),
587	)
588}
589
590// GetReviewsJSON returns a JSON array of a subject's non-hidden reviews (paginated).
591// Deleted reviews were removed from the subject index by DeleteReview, so they do NOT appear
592// here; hidden reviews are excluded at read time. (reviewJSON still tombstones a deleted review
593// defensively for any caller that fetches one by id.)
594func (s *Store) GetReviewsJSON(subject string, offset, limit int) string {
595	ids := window(idList(s.subjectIndex, subject), offset, limit)
596	var b strings.Builder
597	b.WriteString("[")
598	first := true
599	for _, id := range ids {
600		r, ok := s.getReview(id)
601		if !ok || r.Hidden {
602			continue
603		}
604		if !first {
605			b.WriteString(",")
606		}
607		b.WriteString(s.reviewJSON(r))
608		first = false
609	}
610	b.WriteString("]")
611	return b.String()
612}
613
614// GetCommentsJSON returns a JSON array of a review's non-hidden comments (paginated).
615func (s *Store) GetCommentsJSON(reviewID uint64, offset, limit int) string {
616	ids := window(idList(s.commentIndex, strID(reviewID)), offset, limit)
617	var b strings.Builder
618	b.WriteString("[")
619	first := true
620	for _, id := range ids {
621		c, ok := s.getComment(id)
622		if !ok || c.Hidden {
623			continue
624		}
625		if !first {
626			b.WriteString(",")
627		}
628		b.WriteString(s.commentJSON(c))
629		first = false
630	}
631	b.WriteString("]")
632	return b.String()
633}
634
635// SubjectSummaryJSON returns {"count":N,"average":A,"sum":S} over VISIBLE reviews (RV-1: O(1)).
636func (s *Store) SubjectSummaryJSON(subject string) string {
637	n := getStat(s.subjStatCount, subject)
638	sum := getStat(s.subjStatSum, subject)
639	avg := int64(0)
640	if n > 0 {
641		avg = (sum + n/2) / n // round to nearest
642	}
643	return ufmt.Sprintf(`{"count":%d,"average":%d,"sum":%d}`, n, avg, sum)
644}
645
646// GetReputation returns the net likes−dislikes reputation for an address (0 if unknown).
647func (s *Store) GetReputation(addr string) int64 { return s.getReputation(addr) }
648
649// GetFlaggedJSON returns a paginated JSON array of flagged target IDs for the mod dashboard.
650func (s *Store) GetFlaggedJSON(offset, limit int) string {
651	limit = clampLimit(limit)
652	if offset < 0 {
653		offset = 0
654	}
655	var ids []uint64
656	skipped := 0
657	s.flaggedIDs.Iterate("", "", func(key string, _ interface{}) bool {
658		if skipped < offset {
659			skipped++
660			return false
661		}
662		id, _ := strconv.ParseUint(key, 10, 64)
663		ids = append(ids, id)
664		return len(ids) >= limit // stop once collected enough
665	})
666	var b strings.Builder
667	b.WriteString("[")
668	for i, id := range ids {
669		if i > 0 {
670			b.WriteString(",")
671		}
672		b.WriteString(strID(id))
673	}
674	b.WriteString("]")
675	return b.String()
676}
677
678// ReviewCount returns the total number of reviews ever created (any state).
679func (s *Store) ReviewCount() int { return s.reviews.Size() }
680
681// RenderSubject returns a markdown list of a subject's visible reviews (bounded), for a
682// realm's Render() gnoweb view.
683func (s *Store) RenderSubject(subject string) string {
684	var b strings.Builder
685	written := false
686	renderedCount := 0
687	for _, id := range idList(s.subjectIndex, subject) {
688		if renderedCount >= MaxPageLimit {
689			break
690		}
691		r, ok := s.getReview(id)
692		if !ok || r.Hidden || r.Deleted {
693			continue
694		}
695		b.WriteString(ufmt.Sprintf("**%d/5** by %s\n\n%s\n\n---\n",
696			r.Rating, r.Author.String(), SanitizeForRender(r.Body)))
697		written = true
698		renderedCount++
699	}
700	if !written {
701		return "No reviews yet.\n"
702	}
703	return b.String()
704}