Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}