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

talk.gno

5.50 Kb · 221 lines
  1package wiki
  2
  3import (
  4	"errors"
  5	"time"
  6
  7	"gno.land/p/moul/ulist/v0"
  8	"gno.land/p/nt/avl/v0"
  9)
 10
 11// MaxCommentLen bounds one comment in bytes.
 12const MaxCommentLen = 2000
 13
 14// MaxReplyDepth is 1: a comment replies to a top-level comment, and that is
 15// all. Deeper nesting needs recursive rendering with no natural bound, which
 16// is exactly the shape a query gas ceiling punishes.
 17const MaxReplyDepth = 1
 18
 19var (
 20	ErrCommentTooLong = errors.New("wiki: comment exceeds the size limit")
 21	ErrEmptyComment   = errors.New("wiki: empty comment")
 22	ErrNoSuchComment  = errors.New("wiki: no such comment")
 23	ErrReplyToReply   = errors.New("wiki: replies nest one level only")
 24)
 25
 26// Comment is one message in a page's discussion.
 27//
 28// Discussion is a comment store rather than a "Talk:" article, which is the
 29// one place this design departs from MediaWiki on purpose. A talk page is an
 30// article, so the last editor can rewrite what someone else said, and
 31// moderating one bad message means editing the whole page. An append-only
 32// store gives each message its own author, its own timestamp and its own
 33// moderation, and nobody can silently rewrite anyone else's words.
 34type Comment struct {
 35	ID     uint64
 36	Parent uint64 // 0 for a top-level comment
 37	Author address
 38	Time   time.Time
 39	Height int64
 40	Body   string
 41	Hidden bool // a steward hid it; Body is cleared and its deposit released
 42}
 43
 44// Comment appends a message to a page's discussion. replyTo is 0 for a
 45// top-level message, or the id of a top-level message to reply to.
 46//
 47// The engine does not decide who may comment: as with Edit, that is the
 48// realm's call. It does enforce the shape, because the shape is what bounds
 49// the render.
 50func (w *Wiki) Comment(author address, now time.Time, height int64, raw, body string, replyTo uint64) (*Comment, error) {
 51	t, err := ParseTitle(raw)
 52	if err != nil {
 53		return nil, err
 54	}
 55	if !w.Exists(t) {
 56		return nil, ErrNoSuchPage
 57	}
 58	if body == "" {
 59		return nil, ErrEmptyComment
 60	}
 61	if len(body) > MaxCommentLen {
 62		return nil, ErrCommentTooLong
 63	}
 64
 65	list := w.thread(t, true)
 66	if replyTo != 0 {
 67		parent := findComment(list, replyTo)
 68		if parent == nil {
 69			return nil, ErrNoSuchComment
 70		}
 71		if parent.Parent != 0 {
 72			return nil, ErrReplyToReply
 73		}
 74	}
 75
 76	w.nextComment++
 77	c := &Comment{
 78		ID:     w.nextComment,
 79		Parent: replyTo,
 80		Author: author,
 81		Time:   now,
 82		Height: height,
 83		Body:   body,
 84	}
 85	list.Append(c)
 86	w.numComments++
 87	w.bytesHeld += len(body)
 88	return c, nil
 89}
 90
 91// HideComment clears one message's body and returns the bytes released. The
 92// message itself stays in the thread, so a deleted comment reads as "removed"
 93// rather than as a gap someone has to reconstruct from block explorers.
 94func (w *Wiki) HideComment(raw string, id uint64) (int, error) {
 95	t, err := ParseTitle(raw)
 96	if err != nil {
 97		return 0, err
 98	}
 99	list := w.thread(t, false)
100	if list == nil {
101		return 0, ErrNoSuchComment
102	}
103	c := findComment(list, id)
104	if c == nil {
105		return 0, ErrNoSuchComment
106	}
107	if c.Hidden {
108		return 0, nil
109	}
110	n := len(c.Body)
111	c.Body = ""
112	c.Hidden = true
113	w.bytesHeld -= n
114	return n, nil
115}
116
117// Comments returns up to count top-level messages of a page's discussion,
118// oldest first, skipping offset of them, each paired with its replies in the
119// order they were written.
120func (w *Wiki) Comments(t Title, offset, count int) []*Thread {
121	out := []*Thread{}
122	list := w.thread(t, false)
123	if list == nil || count <= 0 {
124		return out
125	}
126
127	// One pass to bucket replies by parent, a second to emit top-level
128	// messages in order. Buckets live in an avl rather than a map because
129	// this feeds a render, and gno map iteration order is unspecified.
130	replies := avl.NewTree()
131	list.Iterator(0, list.Size()-1, func(_ int, v any) bool {
132		c := v.(*Comment)
133		if c.Parent == 0 {
134			return false
135		}
136		key := commentKey(c.Parent)
137		var bucket []*Comment
138		if b := replies.Get(key); b != nil {
139			bucket = b.([]*Comment)
140		}
141		replies.Set(key, append(bucket, c))
142		return false
143	})
144
145	skipped := 0
146	list.Iterator(0, list.Size()-1, func(_ int, v any) bool {
147		c := v.(*Comment)
148		if c.Parent != 0 {
149			return false
150		}
151		if skipped < offset {
152			skipped++
153			return false
154		}
155		th := &Thread{Root: c}
156		if b := replies.Get(commentKey(c.ID)); b != nil {
157			th.Replies = b.([]*Comment)
158		}
159		out = append(out, th)
160		return len(out) >= count
161	})
162	return out
163}
164
165// Thread is a top-level comment and its replies.
166type Thread struct {
167	Root    *Comment
168	Replies []*Comment
169}
170
171// NumComments returns how many messages a page's discussion holds, replies
172// and hidden messages included.
173func (w *Wiki) NumComments(t Title) int {
174	list := w.thread(t, false)
175	if list == nil {
176		return 0
177	}
178	return list.Size()
179}
180
181// thread returns a page's comment list, creating it when create is set.
182func (w *Wiki) thread(t Title, create bool) *ulist.List {
183	v := w.talk.Get(t.Key())
184	if v != nil {
185		return v.(*ulist.List)
186	}
187	if !create {
188		return nil
189	}
190	list := ulist.New()
191	w.talk.Set(t.Key(), list)
192	return list
193}
194
195func findComment(list *ulist.List, id uint64) *Comment {
196	var found *Comment
197	list.Iterator(0, list.Size()-1, func(_ int, v any) bool {
198		c := v.(*Comment)
199		if c.ID == id {
200			found = c
201			return true
202		}
203		return false
204	})
205	return found
206}
207
208// commentKey is only ever used as an avl key for reply buckets, where the
209// order does not matter, so a plain decimal is fine here. Anything whose
210// ordering IS meaningful needs a fixed-width key: see Title.Key.
211func commentKey(id uint64) string {
212	if id == 0 {
213		return "0"
214	}
215	var b []byte
216	for id > 0 {
217		b = append([]byte{byte('0' + id%10)}, b...)
218		id /= 10
219	}
220	return string(b)
221}