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