forked from
tangled.org/core
Monorepo for Tangled
1package timeline
2
3import (
4 "net/http"
5 "sort"
6
7 "github.com/bluesky-social/indigo/atproto/syntax"
8 "tangled.org/core/appview/db"
9 "tangled.org/core/appview/models"
10 "tangled.org/core/appview/oauth"
11 "tangled.org/core/appview/pages"
12 "tangled.org/core/appview/pagination"
13 "tangled.org/core/orm"
14)
15
16func (t *Timeline) Timeline(w http.ResponseWriter, r *http.Request) {
17 user := t.oauth.GetMultiAccountUser(r)
18
19 const timelineTabCookie = "timeline-tab"
20 var followingOnly bool
21 if _, hasParam := r.URL.Query()["following"]; hasParam {
22 followingOnly = r.URL.Query().Get("following") == "true" && user != nil
23 } else if user != nil {
24 if c, err := r.Cookie(timelineTabCookie); err == nil {
25 followingOnly = c.Value == "following"
26 }
27 }
28
29 if user != nil {
30 val := "global"
31 if followingOnly {
32 val = "following"
33 }
34 http.SetCookie(w, &http.Cookie{
35 Name: timelineTabCookie,
36 Value: val,
37 Path: "/",
38 MaxAge: 365 * 24 * 60 * 60,
39 HttpOnly: true,
40 SameSite: http.SameSiteLaxMode,
41 })
42 }
43
44 var userDid string
45 if user != nil {
46 userDid = user.Did
47 }
48 timeline, err := db.MakeTimeline(t.db, 50, userDid, followingOnly)
49 if err != nil {
50 t.logger.Error("failed to make timeline", "err", err)
51 t.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
52 }
53
54 repos, err := db.GetTopStarredReposLastWeek(t.db)
55 if err != nil {
56 t.logger.Error("failed to get top starred repos", "err", err)
57 t.pages.Notice(w, "topstarredrepos", "Unable to load.")
58 return
59 }
60
61 gfiLabel, err := db.GetLabelDefinition(t.db, orm.FilterEq("at_uri", t.config.Label.GoodFirstIssue))
62 if err != nil {
63 // non-fatal
64 }
65
66 var notifications []*models.NotificationWithEntity
67 if user != nil {
68 notifications, err = db.GetNotificationsWithEntities(
69 t.db,
70 pagination.Page{Limit: 5, Offset: 0},
71 orm.FilterEq("recipient_did", user.Did),
72 )
73 if err != nil {
74 t.logger.Error("failed to get notifications for timeline", "err", err)
75 }
76 }
77
78 var vouchSuggestions []models.VouchSuggestion
79 if user != nil {
80 vouchSuggestions, err = db.GetVouchSuggestions(t.db, user.Did, 3)
81 if err != nil {
82 t.logger.Error("failed to get vouch suggestions", "err", err)
83 }
84 if len(vouchSuggestions) > 0 {
85 suggestionDids := make([]syntax.DID, len(vouchSuggestions))
86 for i, sv := range vouchSuggestions {
87 suggestionDids[i] = syntax.DID(sv.Did)
88 }
89 relationships, err := db.GetVouchRelationshipsBatch(t.db, syntax.DID(user.Did), suggestionDids)
90 if err != nil {
91 t.logger.Error("failed to get vouch relationships for suggestions", "err", err)
92 } else {
93 for i := range vouchSuggestions {
94 vouchSuggestions[i].VouchRelationship = relationships[vouchSuggestions[i].Did]
95 }
96 }
97 }
98 }
99
100 var recents []pages.RecentItem
101 if user != nil {
102 recents, err = t.buildRecents(user.Did)
103 if err != nil {
104 t.logger.Error("failed to build recents for timeline", "err", err)
105 }
106 }
107
108 var onboarding *models.Onboarding
109 if user != nil {
110 onboarding, err = db.GetOnboarding(t.db, user.Did)
111 if err != nil {
112 t.logger.Error("failed to get onboarding status", "err", err)
113 }
114 }
115
116 var canFocus bool
117 if user != nil {
118 focusCount, _ := db.CountFocusNotifs(t.db, user.Did)
119 canFocus = focusCount > 1
120 }
121
122 err = t.pages.Timeline(w, pages.TimelineParams{
123 BaseParams: pages.BaseParamsFromContext(r.Context()),
124 Onboarding: onboarding.Progress(),
125 Timeline: timeline,
126 Repos: repos,
127 GfiLabel: gfiLabel,
128 VouchSuggestions: vouchSuggestions,
129 Notifications: notifications,
130 Recents: recents,
131 FollowingOnly: followingOnly,
132 RecentBlogPosts: t.recentPosts,
133 ShowNewsletter: t.showNewsletter(user),
134 CanFocus: canFocus,
135 })
136 if err != nil {
137 t.logger.Error("failed to render timeline", "err", err)
138 }
139}
140
141func (t *Timeline) buildRecents(userDid string) ([]pages.RecentItem, error) {
142 links, err := db.GetRecentLinks(t.db, orm.FilterEq("user_did", userDid))
143 if err != nil {
144 return nil, err
145 }
146 if len(links) == 0 {
147 return nil, nil
148 }
149
150 // group targets by type.
151 var repoDids, issueAtUris, pullAtUris []string
152 for _, l := range links {
153 switch l.LinkType {
154 case models.RecentLinkTypeRepo:
155 repoDids = append(repoDids, l.Target)
156 case models.RecentLinkTypeIssue:
157 issueAtUris = append(issueAtUris, l.Target)
158 case models.RecentLinkTypePull:
159 pullAtUris = append(pullAtUris, l.Target)
160 }
161 }
162
163 // fetch repos by DID.
164 repoByDid := make(map[string]*models.Repo)
165 if len(repoDids) > 0 {
166 fetched, err := db.GetRepos(t.db, orm.FilterIn("repo_did", repoDids))
167 if err != nil {
168 return nil, err
169 }
170 for i := range fetched {
171 repoByDid[fetched[i].RepoDid] = &fetched[i]
172 }
173 }
174
175 // fetch issues by aturi
176 issueByAtUri := make(map[string]*models.Issue)
177 if len(issueAtUris) > 0 {
178 issues, err := db.GetIssues(t.db, orm.FilterIn("at_uri", issueAtUris))
179 if err != nil {
180 return nil, err
181 }
182 for _, issue := range issues {
183 issueByAtUri[issue.AtUri().String()] = &issue
184 }
185 }
186
187 // fetch pulls by aturi
188 pullByAtUri := make(map[string]*models.Pull)
189 if len(pullAtUris) > 0 {
190 fetched, err := db.GetPulls(t.db, orm.FilterIn("at_uri", pullAtUris))
191 if err != nil {
192 return nil, err
193 }
194 for _, p := range fetched {
195 pullByAtUri[p.AtUri().String()] = p
196 }
197 }
198
199 // build result in original link order
200 var items []pages.RecentItem
201 for _, l := range links {
202 item := pages.RecentItem{Link: l}
203 switch l.LinkType {
204 case models.RecentLinkTypeRepo:
205 item.Repo = repoByDid[l.Target]
206 case models.RecentLinkTypeIssue:
207 item.Issue = issueByAtUri[l.Target]
208 case models.RecentLinkTypePull:
209 item.Pull = pullByAtUri[l.Target]
210 }
211 // skip if the entity could not be resolved (e.g. deleted).
212 if item.Repo == nil && item.Issue == nil && item.Pull == nil {
213 continue
214 }
215 items = append(items, item)
216 }
217
218 // re-sort by visited descending to restore recency order after map lookups.
219 sort.Slice(items, func(i, j int) bool {
220 return items[i].Link.Visited.After(items[j].Link.Visited)
221 })
222
223 return items, nil
224}
225
226// showNewsletter decides whether the newsletter widget/CTA should render.
227// Anonymous visitors always see it (they can dismiss via localStorage);
228// logged-in users whose newsletter_preferences row exists (either
229// subscribed or dismissed) do not.
230func (t *Timeline) showNewsletter(user *oauth.MultiAccountUser) bool {
231 if user == nil {
232 return true
233 }
234 pref, err := db.GetNewsletterPref(t.db, user.Did)
235 if err != nil {
236 t.logger.Error("failed to read newsletter preference", "did", user.Did, "err", err)
237 return true
238 }
239 return pref == nil
240}