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}