From ba8bc13628bcc8057f086f2e6ef248b85306fb78 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Mon, 08 Jun 2026 10:59:24 +0000 Subject: [PATCH] appview/timeline: put timeline handlers in new module Signed-off-by: oppiliappan --- appview/state/router.go | 8 +++++--- appview/timeline/timeline.go | 90 +++++++++++++++++++++++++++++------------------------------------------------------------- appview/timeline/home.go | 43 +++++++++++++++++++++++++++++++++++++++++++ appview/timeline/router.go | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 file(s) changed, 182 insertion(s)(+), 64 deletion(s)(-) diff --git a/appview/state/router.go b/appview/state/router.go --- a/appview/state/router.go +++ b/appview/state/router.go @@ -25,6 +25,7 @@ "tangled.org/core/appview/signup" "tangled.org/core/appview/spindles" "tangled.org/core/appview/state/userutil" avstrings "tangled.org/core/appview/strings" + avtimeline "tangled.org/core/appview/timeline" "tangled.org/core/log" ) @@ -163,9 +164,10 @@ r := chi.NewRouter() r.Handle("/static/*", s.pages.Static()) - r.Get("/", s.HomeOrTimeline) - r.Get("/home", s.Home) - r.Get("/timeline", s.Timeline) + tl := avtimeline.New(s.oauth, s.db, s.config, s.pages, s.logger, "blog/posts") + r.Get("/", tl.HomeOrTimeline) + r.Get("/home", tl.Home) + r.Get("/timeline", tl.Timeline) r.Get("/upgradeBanner", s.UpgradeBanner) r.Post("/newsletter/signup", s.NewsletterSignup) r.Post("/newsletter/dismiss", s.NewsletterDismiss) diff --git a/appview/state/timeline.go b/appview/timeline/timeline.go rename from appview/state/timeline.go rename to appview/timeline/timeline.go --- a/appview/state/timeline.go +++ b/appview/timeline/timeline.go @@ -1,4 +1,4 @@ -package state +package timeline import ( "net/http" @@ -13,41 +13,8 @@ "tangled.org/core/appview/pagination" "tangled.org/core/orm" ) -func (s *State) Home(w http.ResponseWriter, r *http.Request) { - // TODO: set this flag based on the UI - filtered := false - - user := s.oauth.GetMultiAccountUser(r) - - timeline, err := db.MakeTimeline(s.db, 50, "", filtered) - if err != nil { - s.logger.Error("failed to make timeline", "err", err) - s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") - return - } - - blueskyPosts, err := db.GetBlueskyPosts(s.db, 8) - if err != nil { - s.logger.Error("failed to get bluesky posts", "err", err) - } - - s.pages.Home(w, pages.TimelineParams{ - LoggedInUser: user, - Timeline: timeline, - BlueskyPosts: blueskyPosts, - ShowNewsletter: s.showNewsletter(user), - }) -} -func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { - if s.oauth.GetMultiAccountUser(r) != nil { - s.Timeline(w, r) - return - } - s.Home(w, r) -} - -func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { - user := s.oauth.GetMultiAccountUser(r) +func (t *Timeline) Timeline(w http.ResponseWriter, r *http.Request) { + user := t.oauth.GetMultiAccountUser(r) followingOnly := r.URL.Query().Get("following") == "true" && user != nil @@ -55,20 +22,20 @@ var userDid string if user != nil { userDid = user.Did } - timeline, err := db.MakeTimeline(s.db, 50, userDid, followingOnly) + timeline, err := db.MakeTimeline(t.db, 50, userDid, followingOnly) if err != nil { - s.logger.Error("failed to make timeline", "err", err) - s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") + t.logger.Error("failed to make timeline", "err", err) + t.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") } - repos, err := db.GetTopStarredReposLastWeek(s.db) + repos, err := db.GetTopStarredReposLastWeek(t.db) if err != nil { - s.logger.Error("failed to get top starred repos", "err", err) - s.pages.Notice(w, "topstarredrepos", "Unable to load.") + t.logger.Error("failed to get top starred repos", "err", err) + t.pages.Notice(w, "topstarredrepos", "Unable to load.") return } - gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) + gfiLabel, err := db.GetLabelDefinition(t.db, orm.FilterEq("at_uri", t.config.Label.GoodFirstIssue)) if err != nil { // non-fatal } @@ -76,29 +43,29 @@ var notifications []*models.NotificationWithEntity if user != nil { notifications, err = db.GetNotificationsWithEntities( - s.db, + t.db, pagination.Page{Limit: 5, Offset: 0}, orm.FilterEq("recipient_did", user.Did), ) if err != nil { - s.logger.Error("failed to get notifications for timeline", "err", err) + t.logger.Error("failed to get notifications for timeline", "err", err) } } var vouchSuggestions []models.VouchSuggestion if user != nil { - vouchSuggestions, err = db.GetVouchSuggestions(s.db, user.Did, 3) + vouchSuggestions, err = db.GetVouchSuggestions(t.db, user.Did, 3) if err != nil { - s.logger.Error("failed to get vouch suggestions", "err", err) + t.logger.Error("failed to get vouch suggestions", "err", err) } if len(vouchSuggestions) > 0 { suggestionDids := make([]syntax.DID, len(vouchSuggestions)) for i, sv := range vouchSuggestions { suggestionDids[i] = syntax.DID(sv.Did) } - relationships, err := db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), suggestionDids) + relationships, err := db.GetVouchRelationshipsBatch(t.db, syntax.DID(user.Did), suggestionDids) if err != nil { - s.logger.Error("failed to get vouch relationships for suggestions", "err", err) + t.logger.Error("failed to get vouch relationships for suggestions", "err", err) } else { for i := range vouchSuggestions { vouchSuggestions[i].VouchRelationship = relationships[vouchSuggestions[i].Did] @@ -109,13 +76,13 @@ } var recents []pages.RecentItem if user != nil { - recents, err = s.buildRecents(user.Did) + recents, err = t.buildRecents(user.Did) if err != nil { - s.logger.Error("failed to build recents for timeline", "err", err) + t.logger.Error("failed to build recents for timeline", "err", err) } } - s.pages.Timeline(w, pages.TimelineParams{ + t.pages.Timeline(w, pages.TimelineParams{ LoggedInUser: user, Timeline: timeline, Repos: repos, @@ -124,12 +91,13 @@ VouchSuggestions: vouchSuggestions, Notifications: notifications, Recents: recents, FollowingOnly: followingOnly, - ShowNewsletter: s.showNewsletter(user), + RecentBlogPosts: t.recentPosts, + ShowNewsletter: t.showNewsletter(user), }) } -func (s *State) buildRecents(userDid string) ([]pages.RecentItem, error) { - links, err := db.GetRecentLinks(s.db, orm.FilterEq("user_did", userDid)) +func (t *Timeline) buildRecents(userDid string) ([]pages.RecentItem, error) { + links, err := db.GetRecentLinks(t.db, orm.FilterEq("user_did", userDid)) if err != nil { return nil, err } @@ -153,7 +121,7 @@ // fetch repos by DID. repoByDid := make(map[string]*models.Repo) if len(repoDids) > 0 { - fetched, err := db.GetRepos(s.db, orm.FilterIn("repo_did", repoDids)) + fetched, err := db.GetRepos(t.db, orm.FilterIn("repo_did", repoDids)) if err != nil { return nil, err } @@ -165,7 +133,7 @@ // fetch issues by aturi issueByAtUri := make(map[string]*models.Issue) if len(issueAtUris) > 0 { - issues, err := db.GetIssues(s.db, orm.FilterIn("at_uri", issueAtUris)) + issues, err := db.GetIssues(t.db, orm.FilterIn("at_uri", issueAtUris)) if err != nil { return nil, err } @@ -177,7 +145,7 @@ // fetch pulls by aturi pullByAtUri := make(map[string]*models.Pull) if len(pullAtUris) > 0 { - fetched, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAtUris)) + fetched, err := db.GetPulls(t.db, orm.FilterIn("at_uri", pullAtUris)) if err != nil { return nil, err } @@ -217,13 +185,13 @@ // showNewsletter decides whether the newsletter widget/CTA should render. // Anonymous visitors always see it (they can dismiss via localStorage); // logged-in users whose newsletter_preferences row exists (either // subscribed or dismissed) do not. -func (s *State) showNewsletter(user *oauth.MultiAccountUser) bool { +func (t *Timeline) showNewsletter(user *oauth.MultiAccountUser) bool { if user == nil { return true } - pref, err := db.GetNewsletterPref(s.db, user.Did) + pref, err := db.GetNewsletterPref(t.db, user.Did) if err != nil { - s.logger.Error("failed to read newsletter preference", "did", user.Did, "err", err) + t.logger.Error("failed to read newsletter preference", "did", user.Did, "err", err) return true } return pref == nil diff --git a/appview/timeline/home.go b/appview/timeline/home.go new file mode 100644 --- /dev/null +++ b/appview/timeline/home.go @@ -0,0 +1,43 @@ +package timeline + +import ( + "net/http" + + "tangled.org/core/appview/db" + "tangled.org/core/appview/pages" +) + +func (t *Timeline) Home(w http.ResponseWriter, r *http.Request) { + // TODO: set this flag based on the UI + filtered := false + + user := t.oauth.GetMultiAccountUser(r) + + timeline, err := db.MakeTimeline(t.db, 50, "", filtered) + if err != nil { + t.logger.Error("failed to make timeline", "err", err) + t.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") + return + } + + blueskyPosts, err := db.GetBlueskyPosts(t.db, 8) + if err != nil { + t.logger.Error("failed to get bluesky posts", "err", err) + } + + t.pages.Home(w, pages.TimelineParams{ + LoggedInUser: user, + Timeline: timeline, + BlueskyPosts: blueskyPosts, + RecentBlogPosts: t.recentPosts, + ShowNewsletter: t.showNewsletter(user), + }) +} + +func (t *Timeline) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { + if t.oauth.GetMultiAccountUser(r) != nil { + t.Timeline(w, r) + return + } + t.Home(w, r) +} diff --git a/appview/timeline/router.go b/appview/timeline/router.go new file mode 100644 --- /dev/null +++ b/appview/timeline/router.go @@ -0,0 +1,105 @@ +package timeline + +import ( + "bytes" + "io/fs" + "log/slog" + "net/http" + "os" + "strings" + + "github.com/adrg/frontmatter" + "github.com/go-chi/chi/v5" + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + "tangled.org/core/appview/oauth" + "tangled.org/core/appview/pages" +) + +type postMeta struct { + Slug string `yaml:"slug"` + Title string `yaml:"title"` + Date string `yaml:"date"` + Draft bool `yaml:"draft"` +} + +type Timeline struct { + oauth *oauth.OAuth + db *db.DB + config *config.Config + pages *pages.Pages + logger *slog.Logger + recentPosts []pages.BlogPost +} + +func New( + oauth *oauth.OAuth, + db *db.DB, + config *config.Config, + pages *pages.Pages, + logger *slog.Logger, + postsDir string, +) *Timeline { + t := &Timeline{ + oauth: oauth, + db: db, + config: config, + pages: pages, + logger: logger, + } + t.recentPosts = loadRecentPosts(postsDir, logger) + return t +} + +func (t *Timeline) Router() http.Handler { + r := chi.NewRouter() + r.Get("/", t.HomeOrTimeline) + r.Get("/home", t.Home) + r.Get("/timeline", t.Timeline) + return r +} + +func loadRecentPosts(postsDir string, logger *slog.Logger) []pages.BlogPost { + fsys := os.DirFS(postsDir) + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + logger.Warn("failed to read blog posts dir", "dir", postsDir, "err", err) + return nil + } + + var posts []postMeta + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + data, err := fs.ReadFile(fsys, entry.Name()) + if err != nil { + continue + } + var meta postMeta + if _, err := frontmatter.Parse(bytes.NewReader(data), &meta); err != nil { + continue + } + if meta.Draft { + continue + } + posts = append(posts, meta) + } + + // sort newest-first by date string (format "2006-01-02" sorts lexicographically) + for i := 1; i < len(posts); i++ { + for j := i; j > 0 && posts[j].Date > posts[j-1].Date; j-- { + posts[j], posts[j-1] = posts[j-1], posts[j] + } + } + + if len(posts) > 3 { + posts = posts[:3] + } + + result := make([]pages.BlogPost, len(posts)) + for i, p := range posts { + result[i] = pages.BlogPost{Slug: p.Slug, Title: p.Title, Date: p.Date} + } + return result +} -- tangled.sh