This repository has no description
0

Configure Feed

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

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