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