Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

post.gno

9.81 Kb · 330 lines
  1package social
  2
  3import (
  4	"bytes"
  5	"strconv"
  6	"time"
  7
  8	"gno.land/p/moul/txlink/v0"
  9	"gno.land/p/nt/bptree/v0"
 10	"gno.land/p/nt/markdown/sanitize/v0"
 11	"gno.land/p/nt/ufmt/v0"
 12)
 13
 14//----------------------------------------
 15// Post
 16
 17// NOTE: a PostID is relative to the userPosts.
 18type PostID uint64
 19
 20func (pid PostID) String() string {
 21	return strconv.Itoa(int(pid))
 22}
 23
 24// Reaction is for the "enum" of ways to react to a post
 25type Reaction int
 26
 27const (
 28	Gnod Reaction = iota
 29	MaxReaction
 30)
 31
 32// A Post is a "thread" or a "reply" depending on context.
 33// A thread is a Post of a UserPosts that holds other replies.
 34// This is similar to boards.Post except that this doesn't have a title.
 35type Post struct {
 36	userPosts  *UserPosts
 37	id         PostID
 38	creator    address
 39	body       string
 40	replies    bptree.BPTree  // PostID -> *Post
 41	repliesAll bptree.BPTree  // PostID -> *Post (all replies, for top-level posts)
 42	reposts    bptree.BPTree  // UserPosts user address -> PostID
 43	threadID   PostID         // original PostID
 44	parentID   PostID         // parent PostID (if reply or repost)
 45	repostUser address        // UserPosts user address of original post (if repost)
 46	reactions  *bptree.BPTree // Reaction -> *bptree.BPTree of address -> "" (Use the bptree.BPTree keys as the "set" of addresses)
 47	createdAt  time.Time
 48}
 49
 50func newPost(userPosts *UserPosts, id PostID, creator address, body string, threadID, parentID PostID, repostUser address) *Post {
 51	return &Post{
 52		userPosts:  userPosts,
 53		id:         id,
 54		creator:    creator,
 55		body:       body,
 56		replies:    bptree.BPTree{},
 57		repliesAll: bptree.BPTree{},
 58		reposts:    bptree.BPTree{},
 59		threadID:   threadID,
 60		parentID:   parentID,
 61		repostUser: repostUser,
 62		reactions:  bptree.NewBPTree32(),
 63		createdAt:  time.Now(),
 64	}
 65}
 66
 67func (post *Post) IsThread() bool {
 68	return post.parentID == 0
 69}
 70
 71func (post *Post) GetPostID() PostID {
 72	return post.id
 73}
 74
 75func (post *Post) addReply(creator address, body string) *Post {
 76	userPosts := post.userPosts
 77	pid := userPosts.incGetPostID()
 78	pidkey := postIDKey(pid)
 79	reply := newPost(userPosts, pid, creator, body, post.threadID, post.id, "")
 80	post.replies.Set(pidkey, reply)
 81	if post.threadID == post.id {
 82		post.repliesAll.Set(pidkey, reply)
 83	} else {
 84		thread := userPosts.GetThread(post.threadID)
 85		thread.repliesAll.Set(pidkey, reply)
 86	}
 87	return reply
 88}
 89
 90func (post *Post) addRepostTo(creator address, comment string, dst *UserPosts) *Post {
 91	if !post.IsThread() {
 92		panic("cannot repost non-thread post")
 93	}
 94
 95	pid := dst.incGetPostID()
 96	pidkey := postIDKey(pid)
 97	repost := newPost(dst, pid, creator, comment, pid, post.id, post.userPosts.userAddr)
 98	dst.threads.Set(pidkey, repost)
 99	// Also add to the home posts.
100	dst.homePosts.Set(pidkey, repost)
101	post.reposts.Set(creator.String(), pid)
102	return repost
103}
104
105func (post *Post) GetReply(pid PostID) *Post {
106	pidkey := postIDKey(pid)
107	replyI := post.repliesAll.Get(pidkey)
108	if replyI == nil {
109		return nil
110	} else {
111		return replyI.(*Post)
112	}
113}
114
115// Add the userAddr to the posts.reactions for reaction.
116// Create the reaction key in post.reactions if needed.
117// If userAddr is already added, do nothing.
118// If the userAddr is the post's creator, do nothing. (Don't react to one's own posts.)
119// Return a boolean indicating whether the userAddr was added (false if it was already added).
120func (post *Post) addReaction(userAddr address, reaction Reaction) bool {
121	validateReaction(reaction)
122
123	if userAddr == post.creator {
124		// Don't react to one's own posts.
125		return false
126	}
127	value := getOrCreateReactionValue(post.reactions, reaction)
128	if value.Has(userAddr.String()) {
129		// Already added.
130		return false
131	}
132
133	value.Set(userAddr.String(), "")
134	return true
135}
136
137// Remove the userAddr from the posts.reactions for reaction.
138// If userAddr is already removed, do nothing.
139// Return a boolean indicating whether the userAddr was found and removed.
140func (post *Post) removeReaction(userAddr address, reaction Reaction) bool {
141	validateReaction(reaction)
142
143	if !post.reactions.Has(reactionKey(reaction)) {
144		// There is no entry for reaction, so don't create one.
145		return false
146	}
147
148	_, removed := getOrCreateReactionValue(post.reactions, reaction).Remove(userAddr.String())
149	return removed
150}
151
152// Return the count of reactions for the reaction.
153func (post *Post) GetReactionCount(reaction Reaction) int {
154	key := reactionKey(reaction)
155	valueI := post.reactions.Get(key)
156	if valueI != nil {
157		return valueI.(*bptree.BPTree).Size()
158	} else {
159		return 0
160	}
161}
162
163func validateReaction(reaction Reaction) {
164	if reaction < 0 || reaction >= MaxReaction {
165		panic("invalid Reaction value: " + strconv.Itoa(int(reaction)))
166	}
167}
168
169func (post *Post) GetSummary() string {
170	return summaryOf(post.body, 80)
171}
172
173func (post *Post) GetURL() string {
174	// Not IsThread(), which is false for a repost even though it is top-level.
175	if post.threadID == post.id {
176		return post.userPosts.GetURLFromThreadAndReplyID(
177			post.id, 0)
178	} else {
179		return post.userPosts.GetURLFromThreadAndReplyID(
180			post.threadID, post.id)
181	}
182}
183
184func (post *Post) GetGnodFormURL() string {
185	return txlink.Call("AddReaction",
186		"userPostsAddr", post.userPosts.userAddr.String(),
187		"threadid", post.threadID.String(),
188		"postid", post.id.String(),
189		"reaction", strconv.Itoa(int(Gnod)))
190}
191
192func (post *Post) GetReplyFormURL() string {
193	return txlink.Call("PostReply",
194		"userPostsAddr", post.userPosts.userAddr.String(),
195		"threadid", post.threadID.String(),
196		"postid", post.id.String())
197}
198
199func (post *Post) GetRepostFormURL() string {
200	return txlink.Call("RepostThread",
201		"userPostsAddr", post.userPosts.userAddr.String(),
202		"threadid", post.threadID.String(),
203		"postid", post.id.String())
204}
205
206func (post *Post) RenderSummary() string {
207	if post.repostUser != "" {
208		dstUserPosts := getUserPosts(post.repostUser)
209		if dstUserPosts == nil {
210			panic("repost user does not exist")
211		}
212		thread := dstUserPosts.GetThread(PostID(post.parentID))
213		if thread == nil {
214			return "reposted post does not exist"
215		}
216		return "Repost: " + sanitize.InlineText(post.GetSummary()) + "\n\n" + thread.RenderSummary()
217	}
218	str := ""
219	str += sanitize.InlineText(post.GetSummary()) + "\n"
220	str += "\\- " + displayAddressMD(post.creator) + ","
221	str += " [" + post.createdAt.Format("2006-01-02 3:04pm MST") + "](" + post.GetURL() + ")"
222	str += " (" + strconv.Itoa(post.GetReactionCount(Gnod)) + " gnods)"
223	str += " (" + strconv.Itoa(post.replies.Size()) + " replies)"
224	str += " (" + strconv.Itoa(post.reposts.Size()) + " reposts)" + "\n"
225	return str
226}
227
228func (post *Post) RenderPost(indent string, levels int) string {
229	if post == nil {
230		return "nil post"
231	}
232	str := ""
233	str += indentBody(indent, sanitize.Block(post.body)) + "\n" // TODO: indent body lines.
234	str += indent + "\\- " + displayAddressMD(post.creator) + ", "
235	str += "[" + post.createdAt.Format("2006-01-02 3:04pm (MST)") + "](" + post.GetURL() + ")"
236	str += " - (" + strconv.Itoa(post.GetReactionCount(Gnod)) + " gnods) - "
237	str += " \\[[gnod](" + post.GetGnodFormURL() + ")]"
238	str += " \\[[reply](" + post.GetReplyFormURL() + ")]"
239	if post.IsThread() {
240		str += " \\[[repost](" + post.GetRepostFormURL() + ")]"
241	}
242	str += "\n"
243	// TODO: This renders every reply, recursively for `levels` levels, with no pagination, and
244	// post bodies have no length limit. Should paginate them, and cap the body length.
245	// See renderThread in r/gnoland/boards2/v0/render_thread.gno .
246	if levels > 0 {
247		if post.replies.Size() > 0 {
248			post.replies.ReverseIterate("", "", func(key string, value interface{}) bool {
249				str += indent + "\n"
250				str += value.(*Post).RenderPost(indent+"> ", levels-1)
251				return false
252			})
253		}
254	} else {
255		if post.replies.Size() > 0 {
256			str += indent + "\n"
257			str += indent + "_[see all " + strconv.Itoa(post.replies.Size()) + " replies](" + post.GetURL() + ")_\n"
258		}
259	}
260	return str
261}
262
263// render reply and link to context thread
264func (post *Post) RenderInner() string {
265	if post.IsThread() {
266		panic("unexpected thread")
267	}
268	threadID := post.threadID
269	// replyID := post.id
270	parentID := post.parentID
271	str := ""
272	str += "_[see thread](" + post.userPosts.GetURLFromThreadAndReplyID(
273		threadID, 0) + ")_\n\n"
274	thread := post.userPosts.GetThread(post.threadID)
275	var parent *Post
276	if thread.id == parentID {
277		parent = thread
278	} else {
279		parent = thread.GetReply(parentID)
280	}
281	str += parent.RenderPost("", 0)
282	str += "\n"
283	str += post.RenderPost("> ", 5)
284	return str
285}
286
287// MarshalJSON implements the json.Marshaler interface.
288func (post *Post) MarshalJSON() ([]byte, error) {
289	createdAt, err := post.createdAt.MarshalJSON()
290	if err != nil {
291		return nil, err
292	}
293
294	json := new(bytes.Buffer)
295
296	json.WriteString(ufmt.Sprintf(`{"id": %d, "createdAt": %s, "creator": "%s", "n_gnods": %d, "n_replies": %d, "n_replies_all": %d, "parent_id": %d`,
297		uint64(post.id), string(createdAt), post.creator.String(), post.GetReactionCount(Gnod), post.replies.Size(), post.repliesAll.Size(),
298		uint64(post.parentID)))
299	if post.repostUser != "" {
300		json.WriteString(ufmt.Sprintf(`, "repost_user": %s`, strconv.Quote(post.repostUser.String())))
301	}
302	// TODO: strconv.Quote is not a JSON encoder. For control characters it emits Go escapes
303	// like \x01, \a and \v which are invalid JSON, so JSON.parse rejects the whole response.
304	// Since anyone can reply to any thread, one such reply breaks that thread in the mobile
305	// app. This goes away when we switch to vm/qeval_json.
306	json.WriteString(ufmt.Sprintf(`, "body": %s}`, strconv.Quote(post.body)))
307
308	return json.Bytes(), nil
309}
310
311func getPosts(posts bptree.BPTree, startIndex int, endIndex int) string {
312	json := ufmt.Sprintf("{\"n_threads\": %d, \"posts\": [\n  ", posts.Size())
313
314	for i := startIndex; i < endIndex && i < posts.Size(); i++ {
315		if i > startIndex {
316			json += ",\n  "
317		}
318
319		_, postI := posts.GetByIndex(i)
320		post := postI.(*Post)
321		postJson, err := post.MarshalJSON()
322		if err != nil {
323			panic("can't get post JSON")
324		}
325		json += ufmt.Sprintf("{\"index\": %d, \"post\": %s}", i, string(postJson))
326	}
327
328	json += "]}"
329	return json
330}