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}