Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / timeline / timeline.go
6.6 kB 240 lines
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}