Monorepo for Tangled
tangled.org
1package db
2
3import (
4 "sort"
5
6 "github.com/bluesky-social/indigo/atproto/syntax"
7 "tangled.org/core/appview/models"
8 "tangled.org/core/appview/pagination"
9 "tangled.org/core/orm"
10)
11
12// TODO: this gathers heterogenous events from different sources and aggregates
13// them in code; if we did this entirely in sql, we could order and limit and paginate easily
14func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineGroup, error) {
15 var events []models.TimelineEvent
16
17 var userIsFollowing []string
18 if limitToUsersIsFollowing {
19 following, err := GetFollowing(e, loggedInUserDid)
20 if err != nil {
21 return nil, err
22 }
23
24 userIsFollowing = make([]string, 0, len(following))
25 for _, follow := range following {
26 userIsFollowing = append(userIsFollowing, follow.SubjectDid)
27 }
28 }
29
30 // Fetch more events than we need to so that when we collapse each individual
31 // event into groups, we can still be relatively confident that we will have
32 // `limit` groups to fill the timeline with. Adjust multiplier as necessary.
33 fetchLimit := limit * 2
34
35 repos, err := getTimelineRepos(e, fetchLimit, loggedInUserDid, userIsFollowing)
36 if err != nil {
37 return nil, err
38 }
39
40 stars, err := getTimelineStars(e, fetchLimit, loggedInUserDid, userIsFollowing)
41 if err != nil {
42 return nil, err
43 }
44
45 follows, err := getTimelineFollows(e, fetchLimit, loggedInUserDid, userIsFollowing)
46 if err != nil {
47 return nil, err
48 }
49
50 events = append(events, repos...)
51 events = append(events, stars...)
52 events = append(events, follows...)
53
54 sort.Slice(events, func(i, j int) bool {
55 return events[i].EventAt.After(events[j].EventAt)
56 })
57
58 groups := collapseTimeline(events)
59 if len(groups) > limit {
60 groups = groups[:limit]
61 }
62 return groups, nil
63}
64
65// collapseTimeline merges consecutive events that share the same operation
66// and target into one TimelineGroup (assumes events are sorted newest-first).
67func collapseTimeline(events []models.TimelineEvent) []models.TimelineGroup {
68 var groups []models.TimelineGroup
69 i := 0
70 for i < len(events) {
71 group := models.TimelineGroup{Primary: events[i]}
72 j := i + 1
73 for j < len(events) && canCollapse(events[i], events[j]) {
74 group.Others = append(group.Others, events[j])
75 j++
76 }
77 groups = append(groups, group)
78 i = j
79 }
80 return groups
81}
82
83// canCollapse reports whether two adjacent events in the timeline represent
84// the same operation on the same target (repo starred or user followed).
85func canCollapse(a, b models.TimelineEvent) bool {
86 switch {
87 case a.RepoStar != nil && b.RepoStar != nil:
88 if a.RepoStar.Repo == nil || b.RepoStar.Repo == nil {
89 return false
90 }
91 return a.RepoStar.Repo.RepoAt() == b.RepoStar.Repo.RepoAt()
92 case a.Follow != nil && b.Follow != nil:
93 return a.Follow.SubjectDid == b.Follow.SubjectDid
94 default:
95 return false
96 }
97}
98
99func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) {
100 if loggedInUserDid == "" {
101 return nil, nil
102 }
103
104 var repoAts []syntax.ATURI
105 for _, r := range repos {
106 repoAts = append(repoAts, r.RepoAt())
107 }
108
109 return GetStarStatuses(e, loggedInUserDid, repoAts)
110}
111
112func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) {
113 var isStarred bool
114 if starStatuses != nil {
115 isStarred = starStatuses[repo.RepoAt().String()]
116 }
117
118 var starCount int64
119 if repo.RepoStats != nil {
120 starCount = int64(repo.RepoStats.StarCount)
121 }
122
123 return isStarred, starCount
124}
125
126func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
127 filters := make([]orm.Filter, 0)
128 if userIsFollowing != nil {
129 filters = append(filters, orm.FilterIn("did", userIsFollowing))
130 }
131
132 repos, err := GetReposPaginated(e, pagination.Page{Limit: limit}, filters...)
133 if err != nil {
134 return nil, err
135 }
136
137 // fetch all source repos
138 var args []string
139 for _, r := range repos {
140 if r.Source != "" {
141 args = append(args, r.Source)
142 }
143 }
144
145 var origRepos []models.Repo
146 if args != nil {
147 origRepos, err = GetRepos(e, orm.FilterIn("at_uri", args))
148 }
149 if err != nil {
150 return nil, err
151 }
152
153 uriToRepo := make(map[string]models.Repo)
154 for _, r := range origRepos {
155 uriToRepo[r.RepoAt().String()] = r
156 }
157
158 starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos)
159 if err != nil {
160 return nil, err
161 }
162
163 var events []models.TimelineEvent
164 for _, r := range repos {
165 var source *models.Repo
166 if r.Source != "" {
167 if origRepo, ok := uriToRepo[r.Source]; ok {
168 source = &origRepo
169 }
170 }
171
172 isStarred, starCount := getRepoStarInfo(&r, starStatuses)
173
174 events = append(events, models.TimelineEvent{
175 Repo: &r,
176 EventAt: r.Created,
177 Source: source,
178 IsStarred: isStarred,
179 StarCount: starCount,
180 })
181 }
182
183 return events, nil
184}
185
186func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
187 filters := make([]orm.Filter, 0)
188 if userIsFollowing != nil {
189 filters = append(filters, orm.FilterIn("did", userIsFollowing))
190 }
191
192 stars, err := GetRepoStars(e, pagination.Page{Limit: limit}, filters...)
193 if err != nil {
194 return nil, err
195 }
196
197 var repos []models.Repo
198 for _, s := range stars {
199 repos = append(repos, *s.Repo)
200 }
201
202 starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos)
203 if err != nil {
204 return nil, err
205 }
206
207 var events []models.TimelineEvent
208 for _, s := range stars {
209 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
210
211 events = append(events, models.TimelineEvent{
212 RepoStar: &s,
213 EventAt: s.Created,
214 IsStarred: isStarred,
215 StarCount: starCount,
216 })
217 }
218
219 return events, nil
220}
221
222func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
223 filters := make([]orm.Filter, 0)
224 if userIsFollowing != nil {
225 filters = append(filters, orm.FilterIn("user_did", userIsFollowing))
226 }
227
228 follows, err := GetFollows(e, limit, filters...)
229 if err != nil {
230 return nil, err
231 }
232
233 var subjects []string
234 for _, f := range follows {
235 subjects = append(subjects, f.SubjectDid)
236 }
237
238 if subjects == nil {
239 return nil, nil
240 }
241
242 profiles, err := GetProfiles(e, orm.FilterIn("did", subjects))
243 if err != nil {
244 return nil, err
245 }
246
247 followStatMap, err := GetFollowerFollowingCounts(e, subjects)
248 if err != nil {
249 return nil, err
250 }
251
252 var followStatuses map[string]models.FollowStatus
253 if loggedInUserDid != "" {
254 followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
255 if err != nil {
256 return nil, err
257 }
258 }
259
260 var events []models.TimelineEvent
261 for _, f := range follows {
262 profile, _ := profiles[f.SubjectDid]
263 followStatMap, _ := followStatMap[f.SubjectDid]
264
265 followStatus := models.IsNotFollowing
266 if followStatuses != nil {
267 followStatus = followStatuses[f.SubjectDid]
268 }
269
270 events = append(events, models.TimelineEvent{
271 Follow: &f,
272 Profile: profile,
273 FollowStats: &followStatMap,
274 FollowStatus: &followStatus,
275 EventAt: f.FollowedAt,
276 })
277 }
278
279 return events, nil
280}