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