Monorepo for Tangled tangled.org
2

Configure Feed

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

appview/timeline: put timeline handlers in new module

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
date (Jun 8, 2026, 2:45 PM +0100) commit 692dbc1e parent d36683e5 change-id xpmkptot
+182 -64
+5 -3
appview/state/router.go
··· 25 25 "tangled.org/core/appview/spindles" 26 26 "tangled.org/core/appview/state/userutil" 27 27 avstrings "tangled.org/core/appview/strings" 28 + avtimeline "tangled.org/core/appview/timeline" 28 29 "tangled.org/core/log" 29 30 ) 30 31 ··· 163 164 164 165 r.Handle("/static/*", s.pages.Static()) 165 166 166 - r.Get("/", s.HomeOrTimeline) 167 - r.Get("/home", s.Home) 168 - r.Get("/timeline", s.Timeline) 167 + tl := avtimeline.New(s.oauth, s.db, s.config, s.pages, s.logger, "blog/posts") 168 + r.Get("/", tl.HomeOrTimeline) 169 + r.Get("/home", tl.Home) 170 + r.Get("/timeline", tl.Timeline) 169 171 r.Get("/upgradeBanner", s.UpgradeBanner) 170 172 r.Post("/newsletter/signup", s.NewsletterSignup) 171 173 r.Post("/newsletter/dismiss", s.NewsletterDismiss)
+29 -61
appview/state/timeline.go appview/timeline/timeline.go
··· 1 - package state 1 + package timeline 2 2 3 3 import ( 4 4 "net/http" ··· 13 13 "tangled.org/core/orm" 14 14 ) 15 15 16 - func (s *State) Home(w http.ResponseWriter, r *http.Request) { 17 - // TODO: set this flag based on the UI 18 - filtered := false 19 - 20 - user := s.oauth.GetMultiAccountUser(r) 21 - 22 - timeline, err := db.MakeTimeline(s.db, 50, "", filtered) 23 - if err != nil { 24 - s.logger.Error("failed to make timeline", "err", err) 25 - s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 26 - return 27 - } 28 - 29 - blueskyPosts, err := db.GetBlueskyPosts(s.db, 8) 30 - if err != nil { 31 - s.logger.Error("failed to get bluesky posts", "err", err) 32 - } 33 - 34 - s.pages.Home(w, pages.TimelineParams{ 35 - LoggedInUser: user, 36 - Timeline: timeline, 37 - BlueskyPosts: blueskyPosts, 38 - ShowNewsletter: s.showNewsletter(user), 39 - }) 40 - } 41 - func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 42 - if s.oauth.GetMultiAccountUser(r) != nil { 43 - s.Timeline(w, r) 44 - return 45 - } 46 - s.Home(w, r) 47 - } 48 - 49 - func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 50 - user := s.oauth.GetMultiAccountUser(r) 16 + func (t *Timeline) Timeline(w http.ResponseWriter, r *http.Request) { 17 + user := t.oauth.GetMultiAccountUser(r) 51 18 52 19 followingOnly := r.URL.Query().Get("following") == "true" && user != nil 53 20 ··· 55 22 if user != nil { 56 23 userDid = user.Did 57 24 } 58 - timeline, err := db.MakeTimeline(s.db, 50, userDid, followingOnly) 25 + timeline, err := db.MakeTimeline(t.db, 50, userDid, followingOnly) 59 26 if err != nil { 60 - s.logger.Error("failed to make timeline", "err", err) 61 - s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 27 + t.logger.Error("failed to make timeline", "err", err) 28 + t.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 62 29 } 63 30 64 - repos, err := db.GetTopStarredReposLastWeek(s.db) 31 + repos, err := db.GetTopStarredReposLastWeek(t.db) 65 32 if err != nil { 66 - s.logger.Error("failed to get top starred repos", "err", err) 67 - s.pages.Notice(w, "topstarredrepos", "Unable to load.") 33 + t.logger.Error("failed to get top starred repos", "err", err) 34 + t.pages.Notice(w, "topstarredrepos", "Unable to load.") 68 35 return 69 36 } 70 37 71 - gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 38 + gfiLabel, err := db.GetLabelDefinition(t.db, orm.FilterEq("at_uri", t.config.Label.GoodFirstIssue)) 72 39 if err != nil { 73 40 // non-fatal 74 41 } ··· 76 43 var notifications []*models.NotificationWithEntity 77 44 if user != nil { 78 45 notifications, err = db.GetNotificationsWithEntities( 79 - s.db, 46 + t.db, 80 47 pagination.Page{Limit: 5, Offset: 0}, 81 48 orm.FilterEq("recipient_did", user.Did), 82 49 ) 83 50 if err != nil { 84 - s.logger.Error("failed to get notifications for timeline", "err", err) 51 + t.logger.Error("failed to get notifications for timeline", "err", err) 85 52 } 86 53 } 87 54 88 55 var vouchSuggestions []models.VouchSuggestion 89 56 if user != nil { 90 - vouchSuggestions, err = db.GetVouchSuggestions(s.db, user.Did, 3) 57 + vouchSuggestions, err = db.GetVouchSuggestions(t.db, user.Did, 3) 91 58 if err != nil { 92 - s.logger.Error("failed to get vouch suggestions", "err", err) 59 + t.logger.Error("failed to get vouch suggestions", "err", err) 93 60 } 94 61 if len(vouchSuggestions) > 0 { 95 62 suggestionDids := make([]syntax.DID, len(vouchSuggestions)) 96 63 for i, sv := range vouchSuggestions { 97 64 suggestionDids[i] = syntax.DID(sv.Did) 98 65 } 99 - relationships, err := db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), suggestionDids) 66 + relationships, err := db.GetVouchRelationshipsBatch(t.db, syntax.DID(user.Did), suggestionDids) 100 67 if err != nil { 101 - s.logger.Error("failed to get vouch relationships for suggestions", "err", err) 68 + t.logger.Error("failed to get vouch relationships for suggestions", "err", err) 102 69 } else { 103 70 for i := range vouchSuggestions { 104 71 vouchSuggestions[i].VouchRelationship = relationships[vouchSuggestions[i].Did] ··· 109 76 110 77 var recents []pages.RecentItem 111 78 if user != nil { 112 - recents, err = s.buildRecents(user.Did) 79 + recents, err = t.buildRecents(user.Did) 113 80 if err != nil { 114 - s.logger.Error("failed to build recents for timeline", "err", err) 81 + t.logger.Error("failed to build recents for timeline", "err", err) 115 82 } 116 83 } 117 84 118 - s.pages.Timeline(w, pages.TimelineParams{ 85 + t.pages.Timeline(w, pages.TimelineParams{ 119 86 LoggedInUser: user, 120 87 Timeline: timeline, 121 88 Repos: repos, ··· 124 91 Notifications: notifications, 125 92 Recents: recents, 126 93 FollowingOnly: followingOnly, 127 - ShowNewsletter: s.showNewsletter(user), 94 + RecentBlogPosts: t.recentPosts, 95 + ShowNewsletter: t.showNewsletter(user), 128 96 }) 129 97 } 130 98 131 - func (s *State) buildRecents(userDid string) ([]pages.RecentItem, error) { 132 - links, err := db.GetRecentLinks(s.db, orm.FilterEq("user_did", userDid)) 99 + func (t *Timeline) buildRecents(userDid string) ([]pages.RecentItem, error) { 100 + links, err := db.GetRecentLinks(t.db, orm.FilterEq("user_did", userDid)) 133 101 if err != nil { 134 102 return nil, err 135 103 } ··· 153 121 // fetch repos by DID. 154 122 repoByDid := make(map[string]*models.Repo) 155 123 if len(repoDids) > 0 { 156 - fetched, err := db.GetRepos(s.db, orm.FilterIn("repo_did", repoDids)) 124 + fetched, err := db.GetRepos(t.db, orm.FilterIn("repo_did", repoDids)) 157 125 if err != nil { 158 126 return nil, err 159 127 } ··· 165 133 // fetch issues by aturi 166 134 issueByAtUri := make(map[string]*models.Issue) 167 135 if len(issueAtUris) > 0 { 168 - issues, err := db.GetIssues(s.db, orm.FilterIn("at_uri", issueAtUris)) 136 + issues, err := db.GetIssues(t.db, orm.FilterIn("at_uri", issueAtUris)) 169 137 if err != nil { 170 138 return nil, err 171 139 } ··· 177 145 // fetch pulls by aturi 178 146 pullByAtUri := make(map[string]*models.Pull) 179 147 if len(pullAtUris) > 0 { 180 - fetched, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAtUris)) 148 + fetched, err := db.GetPulls(t.db, orm.FilterIn("at_uri", pullAtUris)) 181 149 if err != nil { 182 150 return nil, err 183 151 } ··· 217 185 // Anonymous visitors always see it (they can dismiss via localStorage); 218 186 // logged-in users whose newsletter_preferences row exists (either 219 187 // subscribed or dismissed) do not. 220 - func (s *State) showNewsletter(user *oauth.MultiAccountUser) bool { 188 + func (t *Timeline) showNewsletter(user *oauth.MultiAccountUser) bool { 221 189 if user == nil { 222 190 return true 223 191 } 224 - pref, err := db.GetNewsletterPref(s.db, user.Did) 192 + pref, err := db.GetNewsletterPref(t.db, user.Did) 225 193 if err != nil { 226 - s.logger.Error("failed to read newsletter preference", "did", user.Did, "err", err) 194 + t.logger.Error("failed to read newsletter preference", "did", user.Did, "err", err) 227 195 return true 228 196 } 229 197 return pref == nil
+43
appview/timeline/home.go
··· 1 + package timeline 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/appview/db" 7 + "tangled.org/core/appview/pages" 8 + ) 9 + 10 + func (t *Timeline) Home(w http.ResponseWriter, r *http.Request) { 11 + // TODO: set this flag based on the UI 12 + filtered := false 13 + 14 + user := t.oauth.GetMultiAccountUser(r) 15 + 16 + timeline, err := db.MakeTimeline(t.db, 50, "", filtered) 17 + if err != nil { 18 + t.logger.Error("failed to make timeline", "err", err) 19 + t.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 20 + return 21 + } 22 + 23 + blueskyPosts, err := db.GetBlueskyPosts(t.db, 8) 24 + if err != nil { 25 + t.logger.Error("failed to get bluesky posts", "err", err) 26 + } 27 + 28 + t.pages.Home(w, pages.TimelineParams{ 29 + LoggedInUser: user, 30 + Timeline: timeline, 31 + BlueskyPosts: blueskyPosts, 32 + RecentBlogPosts: t.recentPosts, 33 + ShowNewsletter: t.showNewsletter(user), 34 + }) 35 + } 36 + 37 + func (t *Timeline) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 38 + if t.oauth.GetMultiAccountUser(r) != nil { 39 + t.Timeline(w, r) 40 + return 41 + } 42 + t.Home(w, r) 43 + }
+105
appview/timeline/router.go
··· 1 + package timeline 2 + 3 + import ( 4 + "bytes" 5 + "io/fs" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "strings" 10 + 11 + "github.com/adrg/frontmatter" 12 + "github.com/go-chi/chi/v5" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/oauth" 16 + "tangled.org/core/appview/pages" 17 + ) 18 + 19 + type postMeta struct { 20 + Slug string `yaml:"slug"` 21 + Title string `yaml:"title"` 22 + Date string `yaml:"date"` 23 + Draft bool `yaml:"draft"` 24 + } 25 + 26 + type Timeline struct { 27 + oauth *oauth.OAuth 28 + db *db.DB 29 + config *config.Config 30 + pages *pages.Pages 31 + logger *slog.Logger 32 + recentPosts []pages.BlogPost 33 + } 34 + 35 + func New( 36 + oauth *oauth.OAuth, 37 + db *db.DB, 38 + config *config.Config, 39 + pages *pages.Pages, 40 + logger *slog.Logger, 41 + postsDir string, 42 + ) *Timeline { 43 + t := &Timeline{ 44 + oauth: oauth, 45 + db: db, 46 + config: config, 47 + pages: pages, 48 + logger: logger, 49 + } 50 + t.recentPosts = loadRecentPosts(postsDir, logger) 51 + return t 52 + } 53 + 54 + func (t *Timeline) Router() http.Handler { 55 + r := chi.NewRouter() 56 + r.Get("/", t.HomeOrTimeline) 57 + r.Get("/home", t.Home) 58 + r.Get("/timeline", t.Timeline) 59 + return r 60 + } 61 + 62 + func loadRecentPosts(postsDir string, logger *slog.Logger) []pages.BlogPost { 63 + fsys := os.DirFS(postsDir) 64 + entries, err := fs.ReadDir(fsys, ".") 65 + if err != nil { 66 + logger.Warn("failed to read blog posts dir", "dir", postsDir, "err", err) 67 + return nil 68 + } 69 + 70 + var posts []postMeta 71 + for _, entry := range entries { 72 + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { 73 + continue 74 + } 75 + data, err := fs.ReadFile(fsys, entry.Name()) 76 + if err != nil { 77 + continue 78 + } 79 + var meta postMeta 80 + if _, err := frontmatter.Parse(bytes.NewReader(data), &meta); err != nil { 81 + continue 82 + } 83 + if meta.Draft { 84 + continue 85 + } 86 + posts = append(posts, meta) 87 + } 88 + 89 + // sort newest-first by date string (format "2006-01-02" sorts lexicographically) 90 + for i := 1; i < len(posts); i++ { 91 + for j := i; j > 0 && posts[j].Date > posts[j-1].Date; j-- { 92 + posts[j], posts[j-1] = posts[j-1], posts[j] 93 + } 94 + } 95 + 96 + if len(posts) > 3 { 97 + posts = posts[:3] 98 + } 99 + 100 + result := make([]pages.BlogPost, len(posts)) 101 + for i, p := range posts { 102 + result[i] = pages.BlogPost{Slug: p.Slug, Title: p.Title, Date: p.Date} 103 + } 104 + return result 105 + }