Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}