userposts.gno
8.67 Kb · 272 lines
1package social
2
3import (
4 "bytes"
5 "sort"
6 "strconv"
7 "strings"
8 "time"
9
10 "gno.land/p/moul/txlink"
11 "gno.land/p/nt/avl/v0"
12 "gno.land/p/nt/ufmt/v0"
13 "gno.land/r/sys/users"
14)
15
16type FollowingInfo struct {
17 startedFollowingAt time.Time
18 startedPostsCtr PostID
19}
20
21// UserPosts is similar to boards.Board where each user has their own "board" for
22// posts which come from the user. The list of posts is identified by the user's address .
23// A user's "home feed" may contain other posts (from followed users, etc.) but this only
24// has the top-level posts from the user (not replies to other user's posts).
25type UserPosts struct {
26 url string
27 userAddr address
28 threads avl.Tree // PostID -> *Post
29 homePosts avl.Tree // PostID -> *Post. Includes this user's threads posts plus posts of users being followed.
30 lastRefreshId PostID // Updated by refreshHomePosts
31 followers avl.Tree // address -> ""
32 following avl.Tree // address -> *FollowingInfo
33}
34
35// Create a new userPosts for the user. Panic if there is already a userPosts for the user.
36func newUserPosts(url string, userAddr address) *UserPosts {
37 if gUserPostsByAddress.Has(userAddr.String()) {
38 panic("userPosts already exists")
39 }
40 return &UserPosts{
41 url: url,
42 userAddr: userAddr,
43 threads: avl.Tree{},
44 homePosts: avl.Tree{},
45 lastRefreshId: PostID(postsCtr), // Ignore past messages of followed users.
46 followers: avl.Tree{},
47 following: avl.Tree{},
48 }
49}
50
51func (userPosts *UserPosts) GetThread(pid PostID) *Post {
52 pidkey := postIDKey(pid)
53 postI, exists := userPosts.threads.Get(pidkey)
54 if !exists {
55 return nil
56 }
57 return postI.(*Post)
58}
59
60// Add a new top-level thread to the userPosts. Return the new Post.
61func (userPosts *UserPosts) AddThread(body string) *Post {
62 pid := userPosts.incGetPostID()
63 pidkey := postIDKey(pid)
64 thread := newPost(userPosts, pid, userPosts.userAddr, body, pid, 0, "")
65 userPosts.threads.Set(pidkey, thread)
66 // Also add to the home posts.
67 userPosts.homePosts.Set(pidkey, thread)
68 return thread
69}
70
71// If already following followedAddr, then do nothing and return 0.
72// If there is a UserPosts for followedAddr, then add it to following,
73// and add this user to its followers.
74// If there is no UserPosts for followedAddr, then do nothing and return 0. (We don't expect
75// this because this is usually called by clicking on the display page of followedAddr.)
76// Return the value of startedPostsCtr in the added FollowingInfo.
77func (userPosts *UserPosts) Follow(followedAddr address) PostID {
78 if userPosts.following.Has(followedAddr.String()) {
79 // Already following.
80 return PostID(0)
81 }
82
83 followedUserPosts := getUserPosts(followedAddr)
84 if followedUserPosts != nil {
85 userPosts.following.Set(followedAddr.String(), &FollowingInfo{
86 startedFollowingAt: time.Now(),
87 startedPostsCtr: PostID(postsCtr), // Ignore past messages.
88 })
89 followedUserPosts.followers.Set(userPosts.userAddr.String(), "")
90 return PostID(postsCtr)
91 }
92
93 return PostID(0)
94}
95
96// Remove followedAddr from following.
97// If there is a UserPosts for followedAddr, then remove this user from its followers.
98// If there is no UserPosts for followedAddr, then do nothing. (We don't expect this usually.)
99func (userPosts *UserPosts) Unfollow(followedAddr address) {
100 userPosts.following.Remove(followedAddr.String())
101
102 followedUserPosts := getUserPosts(followedAddr)
103 if followedUserPosts != nil {
104 followedUserPosts.followers.Remove(userPosts.userAddr.String())
105 }
106}
107
108// Renders the userPosts for display suitable as plaintext in
109// console. This is suitable for demonstration or tests,
110// but not for prod.
111func (userPosts *UserPosts) RenderUserPosts(includeFollowed bool) string {
112 str := ""
113 followers := strconv.Itoa(userPosts.followers.Size()) + " Followers"
114 following := "Following " + strconv.Itoa(userPosts.following.Size())
115 user := users.ResolveAddress(userPosts.userAddr)
116 nameOrAddr := userPosts.userAddr.String()
117 if user != nil {
118 nameOrAddr = user.Name()
119 }
120 followers = "[" + followers + "](" + gRealmPath + ":" + nameOrAddr + "/followers)"
121 following = "[" + following + "](" + gRealmPath + ":" + nameOrAddr + "/following)"
122 str += followers + " " + following + "\n\n"
123
124 str += "\\[[post](" + userPosts.GetPostFormURL() + ")] \\[[follow](" + userPosts.GetFollowFormURL() + ")]"
125 if includeFollowed {
126 str += " \\[[refresh](" + userPosts.GetRefreshFormURL() + ")]"
127 }
128 str += "\n\n"
129
130 var posts *avl.Tree
131 if includeFollowed {
132 posts = &userPosts.homePosts
133 } else {
134 posts = &userPosts.threads
135 }
136 posts.ReverseIterate("", "", func(key string, postI interface{}) bool {
137 str += "----------------------------------------\n"
138 str += postI.(*Post).RenderSummary() + "\n"
139 return false
140 })
141
142 return str
143}
144
145func (userPosts *UserPosts) RenderFollowers() string {
146 str := ""
147 user := users.ResolveAddress(userPosts.userAddr)
148 ownerRef := userPosts.userAddr.String()
149 if user != nil {
150 ownerRef = user.Name()
151 }
152 str += "[@" + ownerRef + "](" + gRealmPath + ":" + ownerRef + ") Followers\n\n"
153
154 // List the followers, sorted by name/addr.
155 names := []string{}
156 userPosts.followers.Iterate("", "", func(key string, value interface{}) bool {
157 if u := users.ResolveAddress(address(key)); u != nil {
158 names = append(names, u.Name())
159 } else {
160 names = append(names, key)
161 }
162 return false
163 })
164 sort.Strings(names)
165 for _, name := range names {
166 str += " * [@" + name + "](" + gRealmPath + ":" + name + ")" + "\n"
167 }
168
169 return str
170}
171
172func (userPosts *UserPosts) RenderFollowing() string {
173 str := ""
174 user := users.ResolveAddress(userPosts.userAddr)
175 ownerRef := userPosts.userAddr.String()
176 if user != nil {
177 ownerRef = user.Name()
178 }
179 str += "[@" + ownerRef + "](" + gRealmPath + ":" + ownerRef + ") Following\n\n"
180
181 // List the following, sorted by name/addr.
182 nameAddrs := []string{}
183 userPosts.following.Iterate("", "", func(addr string, infoI interface{}) bool {
184 info := infoI.(*FollowingInfo)
185 ref := addr
186 if u := users.ResolveAddress(address(addr)); u != nil {
187 ref = u.Name()
188 }
189 nameAddrs = append(nameAddrs, ref+"/"+addr+"/"+info.startedFollowingAt.Format("2006-01-02"))
190 return false
191 })
192 sort.Strings(nameAddrs)
193 for _, nameAddr := range nameAddrs {
194 parts := strings.Split(nameAddr, "/")
195 name := parts[0]
196 addr := parts[1]
197 since := parts[2]
198 str += " * [@" + name + "](" + gRealmPath + ":" + name + ") since " + since +
199 " \\[[unfollow](" + userPosts.GetUnfollowFormURL(address(addr)) + ")]\n"
200 }
201
202 return str
203}
204
205func (userPosts *UserPosts) incGetPostID() PostID {
206 postsCtr++
207 return PostID(postsCtr)
208}
209
210func (userPosts *UserPosts) GetURLFromThreadAndReplyID(threadID, replyID PostID) string {
211 if replyID == 0 {
212 return userPosts.url + "/" + threadID.String()
213 } else {
214 return userPosts.url + "/" + threadID.String() + "/" + replyID.String()
215 }
216}
217
218func (userPosts *UserPosts) GetPostFormURL() string {
219 return txlink.Call("PostMessage")
220}
221
222func (userPosts *UserPosts) GetFollowFormURL() string {
223 return txlink.Call("Follow", "followedAddr", userPosts.userAddr.String())
224}
225
226func (userPosts *UserPosts) GetUnfollowFormURL(followedAddr address) string {
227 return txlink.Call("Unfollow", "followedAddr", followedAddr.String())
228}
229
230func (userPosts *UserPosts) GetRefreshFormURL() string {
231 return txlink.Call("RefreshHomePosts", "userPostsAddr", userPosts.userAddr.String())
232}
233
234// Scan userPosts.following for all posts from all followed users starting from lastRefreshId+1 .
235// Add the posts to the homePosts avl.Tree, which is sorted by the post ID which is unique for every post and
236// increases in time. When finished, update lastRefreshId.
237func (userPosts *UserPosts) refreshHomePosts() {
238 minStartKey := postIDKey(userPosts.lastRefreshId + 1)
239
240 userPosts.following.Iterate("", "", func(followedAddr string, infoI interface{}) bool {
241 followedUserPosts := getUserPosts(address(followedAddr))
242 if followedUserPosts == nil {
243 return false
244 }
245
246 info := infoI.(*FollowingInfo)
247 startKey := minStartKey
248 if info.startedPostsCtr > userPosts.lastRefreshId {
249 // Started following after the last refresh. Ignore messages before started following.
250 startKey = postIDKey(info.startedPostsCtr + 1)
251 }
252
253 followedUserPosts.threads.Iterate(startKey, "", func(id string, postI interface{}) bool {
254 userPosts.homePosts.Set(id, postI.(*Post))
255 return false
256 })
257
258 return false
259 })
260
261 userPosts.lastRefreshId = PostID(postsCtr)
262}
263
264// MarshalJSON implements the json.Marshaler interface.
265func (userPosts *UserPosts) MarshalJSON() ([]byte, error) {
266 json := new(bytes.Buffer)
267 json.WriteString(ufmt.Sprintf(`{"address": "%s", "url": %s, "n_threads": %d, "n_followers": %d, "n_following": %d}`,
268 userPosts.userAddr.String(), strconv.Quote(userPosts.url), userPosts.threads.Size(),
269 userPosts.followers.Size(), userPosts.following.Size()))
270
271 return json.Bytes(), nil
272}