post.gno
11.02 Kb · 431 lines
1package boards2
2
3import (
4 "errors"
5 "std"
6 "strconv"
7 "strings"
8 "time"
9
10 "gno.land/p/jeronimoalbi/pager"
11 "gno.land/p/moul/md"
12 "gno.land/p/nt/avl"
13)
14
15const dateFormat = "2006-01-02 3:04pm MST"
16
17// PostID defines a type for Post (Threads/Replies) identifiers.
18type PostID uint64
19
20// String returns the ID as a string.
21func (id PostID) String() string {
22 return strconv.Itoa(int(id))
23}
24
25// Key returns the ID as a string with 10 characters padded with zeroes.
26// This value can be used for indexing by ID.
27func (id PostID) Key() string {
28 return padZero(uint64(id), 10)
29}
30
31// A Post is a "thread" or a "reply" depending on context.
32// A thread is a Post of a Board that holds other replies.
33type Post struct {
34 ID PostID
35 Board *Board
36 Creator std.Address
37 Title string
38 Body string
39 Hidden bool
40 Readonly bool
41 ThreadID PostID // Original Post.ID
42 ParentID PostID // Parent Post.ID (if reply or repost)
43 RepostBoardID BoardID // Original Board.ID (if repost)
44 UpdatedAt time.Time
45
46 flags avl.Tree // std.Address -> string(reason)
47 replies avl.Tree // Post.ID -> *Post
48 repliesAll avl.Tree // Post.ID -> *Post (all replies, for top-level posts)
49 reposts avl.Tree // Board.ID -> Post.ID
50 createdAt time.Time
51}
52
53func newPost(board *Board, threadID, id PostID, creator std.Address, title, body string) *Post {
54 return &Post{
55 Board: board,
56 ThreadID: threadID,
57 ID: id,
58 Creator: creator,
59 Title: title,
60 Body: body,
61 createdAt: time.Now(),
62 }
63}
64
65// CreatedAt returns the time when post was created.
66func (post *Post) CreatedAt() time.Time {
67 return post.createdAt
68}
69
70// IsRepost checks if current post is repost.
71func (post *Post) IsRepost() bool {
72 return post.RepostBoardID != 0
73}
74
75// IsThread checks if current post is a thread.
76func (post *Post) IsThread() bool {
77 // repost threads also have parent ID
78 return post.ParentID == 0 || post.IsRepost()
79}
80
81// Flag add a flag to the post.
82// It returns false when the user flagging the post already flagged it.
83func (post *Post) Flag(user std.Address, reason string) bool {
84 if post.flags.Has(user.String()) {
85 return false
86 }
87
88 post.flags.Set(user.String(), reason)
89 return true
90}
91
92// FlagsCount returns the number of time post was flagged.
93func (post *Post) FlagsCount() int {
94 return post.flags.Size()
95}
96
97// AddReply adds a new reply to the post.
98// Replies can be added to threads and also to other replies.
99func (post *Post) AddReply(creator std.Address, body string) *Post {
100 board := post.Board
101 pid := board.generateNextPostID()
102 pKey := pid.Key()
103 reply := newPost(board, post.ThreadID, pid, creator, "", body)
104 reply.ParentID = post.ID
105 // TODO: Figure out how to remove this redundancy of data "replies==repliesAll" in threads
106 post.replies.Set(pKey, reply)
107 if post.ThreadID == post.ID {
108 post.repliesAll.Set(pKey, reply)
109 } else {
110 thread, _ := board.GetThread(post.ThreadID)
111 thread.repliesAll.Set(pKey, reply)
112 }
113 return reply
114}
115
116// HasReplies checks if post has replies.
117func (post *Post) HasReplies() bool {
118 return post.replies.Size() > 0
119}
120
121// Get returns a post reply.
122func (thread *Post) GetReply(pid PostID) (_ *Post, found bool) {
123 v, found := thread.repliesAll.Get(pid.Key())
124 if !found {
125 return nil, false
126 }
127 return v.(*Post), true
128}
129
130// Repost reposts a thread into another boards.
131func (post *Post) Repost(creator std.Address, dst *Board, title, body string) *Post {
132 if !post.IsThread() {
133 panic("post must be a thread to be reposted to another board")
134 }
135
136 repost := dst.AddThread(creator, title, body)
137 repost.ParentID = post.ID
138 repost.RepostBoardID = post.Board.ID
139
140 dst.threads.Set(repost.ID.Key(), repost)
141 post.reposts.Set(dst.ID.Key(), repost.ID)
142 return repost
143}
144
145// DeleteReply deletes a reply from a thread.
146func (post *Post) DeleteReply(replyID PostID) error {
147 if !post.IsThread() {
148 // TODO: Allow removing replies from parent replies too
149 panic("cannot delete reply from a non-thread post")
150 }
151
152 if post.ID == replyID {
153 return errors.New("expected an ID of an inner reply")
154 }
155
156 key := replyID.Key()
157 v, removed := post.repliesAll.Remove(key)
158 if !removed {
159 return errors.New("reply not found in thread")
160 }
161
162 // TODO: Shouldn't reply be hidden instead of deleted? Maybe replace reply by a deleted message.
163 reply := v.(*Post)
164 if reply.ParentID != post.ID {
165 parent, _ := post.GetReply(reply.ParentID)
166 parent.replies.Remove(key)
167 } else {
168 post.replies.Remove(key)
169 }
170 return nil
171}
172
173// Summary return a summary of the post's body.
174// It returns the body making sure that the length is limited to 80 characters.
175func (post *Post) Summary() string {
176 return summaryOf(post.Body, 80)
177}
178
179func (post *Post) RenderSummary() string {
180 var (
181 b strings.Builder
182 postURI = makeThreadURI(post)
183 threadSummary = summaryOf(post.Title, 80)
184 creatorLink = md.UserLink(post.Creator.String())
185 date = post.CreatedAt().Format(dateFormat)
186 )
187
188 b.WriteString(md.Bold("≡ "+md.Link(threadSummary, postURI)) + " \n")
189 b.WriteString("Created by " + creatorLink + " on " + date + " \n")
190
191 status := []string{
192 strconv.Itoa(post.repliesAll.Size()) + " replies",
193 strconv.Itoa(post.reposts.Size()) + " reposts",
194 }
195 b.WriteString(md.Bold(strings.Join(status, " • ")) + "\n")
196 return b.String()
197}
198
199func (post *Post) renderSourcePost(indent string) (string, *Post) {
200 if !post.IsRepost() {
201 return "", nil
202 }
203
204 indent += "> "
205
206 // TODO: figure out a way to decouple posts from a global storage.
207 board, ok := getBoard(post.RepostBoardID)
208 if !ok {
209 // TODO: Boards can't be deleted so this might be redundant
210 return indentBody(indent, md.Italic("⚠ Source board has been deleted")+"\n"), nil
211 }
212
213 srcPost, ok := board.GetThread(post.ParentID)
214 if !ok {
215 return indentBody(indent, md.Italic("⚠ Source post has been deleted")+"\n"), nil
216 }
217
218 if srcPost.Hidden {
219 return indentBody(indent, md.Italic("⚠ Source post has been flagged as inappropriate")+"\n"), nil
220 }
221
222 return indentBody(indent, srcPost.Summary()) + "\n\n", srcPost
223}
224
225// renderPostContent renders post text content (including repost body).
226// Function will dump a predefined message instead of a body if post is hidden.
227func (post *Post) renderPostContent(sb *strings.Builder, indent string, levels int) {
228 if post.Hidden {
229 // Flagged comment should be hidden, but replies still visible (see: #3480)
230 // Flagged threads will be hidden by render function caller.
231 sb.WriteString(indentBody(indent, md.Italic("⚠ Reply is hidden as it has been flagged as inappropriate")) + "\n")
232 return
233 }
234
235 srcContent, srcPost := post.renderSourcePost(indent)
236 if post.IsRepost() && srcPost != nil {
237 originLink := md.Link("another thread", makeThreadURI(srcPost))
238 sb.WriteString(" \nThis thread is a repost of " + originLink + ": \n")
239 }
240
241 sb.WriteString(srcContent)
242
243 if post.IsRepost() && srcPost == nil && len(post.Body) > 0 {
244 // Add a newline to separate source deleted message from repost body content
245 sb.WriteString("\n")
246 }
247
248 sb.WriteString(indentBody(indent, post.Body))
249 sb.WriteString("\n")
250
251 if post.IsThread() {
252 // Split content and controls for threads.
253 sb.WriteString("\n")
254 }
255
256 // Buttons & counters
257 sb.WriteString(indent)
258 if !post.IsThread() {
259 sb.WriteString(" \n")
260 sb.WriteString(indent)
261 }
262
263 creatorLink := md.UserLink(post.Creator.String())
264 date := post.CreatedAt().Format(dateFormat)
265 sb.WriteString("Created by " + creatorLink + " on " + date)
266
267 // Add a reply view link to each top level reply
268 if !post.IsThread() {
269 sb.WriteString(", " + md.Link("#"+post.ID.String(), makeReplyURI(post)))
270 }
271
272 if post.reposts.Size() > 0 {
273 sb.WriteString(", " + strconv.Itoa(post.reposts.Size()) + " repost(s)")
274 }
275
276 sb.WriteString(" \n")
277
278 actions := []string{
279 md.Link("Flag", makeFlagURI(post)),
280 }
281
282 if post.IsThread() {
283 actions = append(actions, md.Link("Repost", makeCreateRepostURI(post)))
284 }
285
286 isReadonly := post.Readonly || post.Board.Readonly
287 if !isReadonly {
288 actions = append(
289 actions,
290 md.Link("Reply", makeCreateReplyURI(post)),
291 md.Link("Edit", makeEditPostURI(post)),
292 md.Link("Delete", makeDeletePostURI(post)),
293 )
294 }
295
296 if levels == 0 {
297 if post.IsThread() {
298 actions = append(actions, md.Link("Show all Replies", makeThreadURI(post)))
299 } else {
300 actions = append(actions, md.Link("View Thread", makeThreadURI(post)))
301 }
302 }
303
304 sb.WriteString(strings.Join(actions, " • ") + " \n")
305}
306
307func (post *Post) Render(path string, indent string, levels int) string {
308 if post == nil {
309 return ""
310 }
311
312 var sb strings.Builder
313
314 // Thread reposts might not have a title, if so get title from source thread
315 title := post.Title
316 if post.IsRepost() && title == "" {
317 if board, ok := getBoard(post.RepostBoardID); ok {
318 if src, ok := board.GetThread(post.ParentID); ok {
319 title = src.Title
320 }
321 }
322 }
323
324 if title != "" { // Replies don't have a title
325 sb.WriteString(md.H1(title))
326 }
327 sb.WriteString(indent + "\n")
328
329 post.renderPostContent(&sb, indent, levels)
330
331 if post.replies.Size() == 0 {
332 return sb.String()
333 }
334
335 // XXX: This triggers for reply views
336 if levels == 0 {
337 sb.WriteString(indent + "\n")
338 return sb.String()
339 }
340
341 if path != "" {
342 sb.WriteString(post.renderTopLevelReplies(path, indent, levels-1))
343 } else {
344 sb.WriteString(post.renderSubReplies(indent, levels-1))
345 }
346 return sb.String()
347}
348
349func (post *Post) renderTopLevelReplies(path, indent string, levels int) string {
350 p, err := pager.New(path, post.replies.Size(), pager.WithPageSize(pageSizeReplies))
351 if err != nil {
352 panic(err)
353 }
354
355 var (
356 b strings.Builder
357 commentsIndent = indent + "> "
358 )
359
360 render := func(_ string, v any) bool {
361 reply := v.(*Post)
362 b.WriteString(indent + "\n" + reply.Render("", commentsIndent, levels-1))
363 return false
364 }
365
366 b.WriteString("\n" + md.HorizontalRule() + "Sort by: ")
367 r := parseRealmPath(path)
368 if r.Query.Get("order") == "desc" {
369 r.Query.Set("order", "asc")
370 b.WriteString(md.Link("newest first", r.String()) + "\n")
371 post.replies.ReverseIterateByOffset(p.Offset(), p.PageSize(), render)
372
373 } else {
374 r.Query.Set("order", "desc")
375 b.WriteString(md.Link("oldest first", r.String()) + "\n")
376 post.replies.IterateByOffset(p.Offset(), p.PageSize(), render)
377 }
378
379 if p.HasPages() {
380 b.WriteString(md.HorizontalRule())
381 b.WriteString(pager.Picker(p))
382 }
383
384 return b.String()
385}
386
387func (post *Post) renderSubReplies(indent string, levels int) string {
388 var (
389 b strings.Builder
390 commentsIndent = indent + "> "
391 )
392
393 post.replies.Iterate("", "", func(_ string, v any) bool {
394 reply := v.(*Post)
395 b.WriteString(indent + "\n" + reply.Render("", commentsIndent, levels-1))
396 return false
397 })
398
399 return b.String()
400}
401
402func (post *Post) RenderInner() string {
403 if post.IsThread() {
404 panic("unexpected thread")
405 }
406
407 var (
408 s string
409 threadID = post.ThreadID
410 thread, _ = post.Board.GetThread(threadID) // TODO: This seems redundant (post == thread)
411 )
412
413 // Fully render parent if it's not a repost.
414 if !post.IsRepost() {
415 var (
416 parent *Post
417 parentID = post.ParentID
418 )
419
420 if thread.ID == parentID {
421 parent = thread
422 } else {
423 parent, _ = thread.GetReply(parentID)
424 }
425
426 s += parent.Render("", "", 0) + "\n"
427 }
428
429 s += post.Render("", "> ", 5)
430 return s
431}