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}