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