From 4c69bb0908e0f6c0081541bc4cc8ec8013bf8b76 Mon Sep 17 00:00:00 2001 From: Wibowo Arindrarto Date: Tue, 26 Dec 2023 09:50:06 +0100 Subject: [PATCH] feat(reader): Initial population of feeds pane --- internal/model.go | 49 +++++++++++ internal/reader/feeds_pane.go | 146 ++++++++++++++++++++++++++++----- internal/reader/reader.go | 24 ++++-- internal/reader/reader_test.go | 7 ++ internal/reader/theme.go | 12 +++ 5 files changed, 212 insertions(+), 26 deletions(-) diff --git a/internal/model.go b/internal/model.go index df63f2b..796bde2 100644 --- a/internal/model.go +++ b/internal/model.go @@ -146,6 +146,25 @@ func (f *Feed) Outline() (*opml.Outline, error) { return &outl, nil } +func FromFeedPb(pb *api.Feed) *Feed { + if pb == nil { + return nil + } + return &Feed{ + ID: pb.GetId(), + Title: pb.GetTitle(), + Description: pb.Description, + FeedURL: pb.GetFeedUrl(), + SiteURL: pb.SiteUrl, + Subscribed: *FromTimestampPb(pb.GetSubTime()), + LastPulled: *FromTimestampPb(pb.GetLastPullTime()), + Updated: FromTimestampPb(pb.GetUpdateTime()), + IsStarred: pb.GetIsStarred(), + Tags: pb.GetTags(), + Entries: fromEntryPbs(pb.GetEntries()), + } +} + type FeedEditOp struct { ID ID Title *string @@ -168,6 +187,36 @@ type Entry struct { URL *string } +func FromEntryPb(pb *api.Entry) *Entry { + if pb == nil { + return nil + } + return &Entry{ + ID: pb.GetId(), + FeedID: pb.GetFeedId(), + Title: pb.GetTitle(), + IsRead: pb.GetIsRead(), + IsBookmarked: pb.GetIsBookmarked(), + ExtID: pb.GetExtId(), + Updated: FromTimestampPb(pb.GetUpdateTime()), + Published: FromTimestampPb(pb.GetUpdateTime()), + Description: pb.Description, + Content: pb.Content, + URL: pb.Url, + } +} + +func fromEntryPbs(pbs []*api.Entry) []*Entry { + entries := make([]*Entry, 0) + for _, pb := range pbs { + if pb == nil { + continue + } + entries = append(entries, FromEntryPb(pb)) + } + return entries +} + type EntryEditOp struct { ID ID IsRead *bool diff --git a/internal/reader/feeds_pane.go b/internal/reader/feeds_pane.go index 6c2b045..240097b 100644 --- a/internal/reader/feeds_pane.go +++ b/internal/reader/feeds_pane.go @@ -5,30 +5,93 @@ package reader import ( "fmt" + "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "github.com/bow/lens/internal" ) type feedsPane struct { *tview.TreeView theme *Theme + + groupOrder []*tview.TreeNode + groupNodes map[feedUpdatedGroup]*tview.TreeNode + feedNodes map[string]*tview.TreeNode + + feeds <-chan *internal.Feed } -func newFeedsPane(theme *Theme) *feedsPane { +func newFeedsPane(theme *Theme, feeds <-chan *internal.Feed) *feedsPane { - fp := feedsPane{theme: theme} - fp.setupNavTree() + fp := feedsPane{ + theme: theme, + feeds: feeds, + groupOrder: make([]*tview.TreeNode, 0), + groupNodes: make(map[feedUpdatedGroup]*tview.TreeNode), + feedNodes: make(map[string]*tview.TreeNode), + } + + fp.initTree() focusf, unfocusf := fp.makeDrawFuncs() fp.SetDrawFunc(unfocusf) fp.SetFocusFunc(func() { fp.SetDrawFunc(focusf) }) fp.SetBlurFunc(func() { fp.SetDrawFunc(unfocusf) }) + go fp.listenForUpdates() + return &fp } +// TODO: How to handle feeds being removed altogether? +func (fp *feedsPane) listenForUpdates() { + root := fp.GetRoot() + for feed := range fp.feeds { + fnode, exists := fp.feedNodes[feed.FeedURL] + newGroup := whenUpdated(feed) + if exists { + oldGroup := whenUpdated(fnode.GetReference().(*internal.Feed)) + if oldGroup != newGroup { + fp.groupNodes[oldGroup].RemoveChild(fnode) + } + } else { + fnode = feedNode(feed, fp.theme) + fp.feedNodes[feed.FeedURL] = fnode + } + fp.groupNodes[newGroup].AddChild(fnode) + + root.ClearChildren() + for _, gnode := range fp.groupOrder { + if len(gnode.GetChildren()) > 0 { + root.AddChild(gnode) + } + } + } +} + +func (fp *feedsPane) initTree() { + + root := tview.NewTreeNode("") + + tree := tview.NewTreeView(). + SetRoot(root). + SetCurrentNode(root). + SetTopLevel(1) + + fp.TreeView = tree + + for i := uint8(0); i < uint8(updatedUnknown); i++ { + ug := feedUpdatedGroup(i) + gnode := groupNode(ug, fp.theme) + fp.groupNodes[ug] = gnode + fp.groupOrder = append(fp.groupOrder, gnode) + } +} + func (fp *feedsPane) makeDrawFuncs() (focusf, unfocusf drawFunc) { var titleUF, titleF string @@ -83,29 +146,72 @@ func (fp *feedsPane) makeDrawFuncs() (focusf, unfocusf drawFunc) { return focusf, unfocusf } -func (fp *feedsPane) setupNavTree() { - - root := tview.NewTreeNode("") +func (fp *feedsPane) refreshColors() { + for _, node := range fp.TreeView.GetRoot().GetChildren() { + node.SetColor(fp.theme.FeedsGroup) + } +} - tree := tview.NewTreeView(). - SetRoot(root). - SetCurrentNode(root). - SetTopLevel(1) +type feedUpdatedGroup uint8 - updateGroups := []string{"Today", "This Week", "This Month", "This Year"} +const ( + updatedToday feedUpdatedGroup = iota + updatedThisWeek + updatedThisMonth + updatedEarlier + updatedUnknown +) - for _, ug := range updateGroups { - node := tview.NewTreeNode(ug). - SetSelectable(true). - SetColor(fp.theme.FeedsGroup) - root.AddChild(node) +func (ug feedUpdatedGroup) Text(theme *Theme) string { + switch ug { + case updatedToday: + return theme.UpdatedTodayText + case updatedThisWeek: + return theme.UpdatedThisWeekText + case updatedThisMonth: + return theme.UpdatedThisMonthText + case updatedEarlier: + return theme.UpdatedEarlier + case updatedUnknown: + return theme.UpdatedUnknownText + default: + return theme.UpdatedUnknownText } +} - fp.TreeView = tree +func feedNode(feed *internal.Feed, _ *Theme) *tview.TreeNode { + return tview.NewTreeNode(feed.Title). + SetReference(feed.FeedURL). + SetColor(tcell.ColorWhite). + SetSelectable(true) } -func (fp *feedsPane) refreshColors() { - for _, node := range fp.TreeView.GetRoot().GetChildren() { - node.SetColor(fp.theme.FeedsGroup) +func groupNode(ug feedUpdatedGroup, theme *Theme) *tview.TreeNode { + return tview.NewTreeNode(ug.Text(theme)). + SetReference(ug). + SetColor(theme.FeedsGroup). + SetSelectable(false) +} + +func whenUpdated(feed *internal.Feed) feedUpdatedGroup { + if feed.Updated == nil { + return updatedUnknown + } + + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + lastWeek := now.AddDate(0, 0, -7) + lastMonth := now.AddDate(0, -1, 0) + + ft := *feed.Updated + switch { + case ft.Before(lastMonth): + return updatedEarlier + case ft.Before(lastWeek): + return updatedThisMonth + case ft.Before(yesterday): + return updatedThisWeek + default: + return updatedToday } } diff --git a/internal/reader/reader.go b/internal/reader/reader.go index 1aa07ff..0568f19 100644 --- a/internal/reader/reader.go +++ b/internal/reader/reader.go @@ -57,6 +57,7 @@ type Reader struct { readingPane *tview.Box bar *statusBar + feedsCh chan *internal.Feed statsCache *internal.Stats focusStack tview.Primitive } @@ -154,11 +155,12 @@ func (b *Builder) Build() (*Reader, error) { } rdr := Reader{ - ctx: b.ctx, - client: client, - addr: b.addr, - screen: screen, - theme: b.theme, + ctx: b.ctx, + client: client, + addr: b.addr, + screen: screen, + theme: b.theme, + feedsCh: make(chan *internal.Feed), } rdr.setupLayout() @@ -217,6 +219,15 @@ To close this message, press [yellow][-]. defer r.initialize() } + rsp, err := r.client.ListFeeds(r.ctx, &api.ListFeedsRequest{}) + if err != nil { + panic(err) + } + for _, feed := range rsp.GetFeeds() { + feed := feed + go func() { r.feedsCh <- internal.FromFeedPb(feed) }() + } + stop := r.bar.startEventPoll() defer stop() @@ -225,7 +236,7 @@ To close this message, press [yellow][-]. func (r *Reader) setupMainPage() { - feedsPane := newFeedsPane(r.theme) + feedsPane := newFeedsPane(r.theme, r.feedsCh) feedsPane.SetInputCapture(r.feedsPaneKeyHandler()) entriesPane := r.newPane(r.theme.EntriesPaneTitle, false) @@ -528,6 +539,7 @@ func (r *Reader) feedsPaneKeyHandler() func(event *tcell.EventKey) *tcell.EventK r.bar.errEventf("Failed to pull %s: %s", rsp.GetUrl(), serr) errCount++ } else { + rsp.GetFeed() okCount++ } totalCount++ diff --git a/internal/reader/reader_test.go b/internal/reader/reader_test.go index 6d8de3e..e7e6f21 100644 --- a/internal/reader/reader_test.go +++ b/internal/reader/reader_test.go @@ -86,6 +86,13 @@ func setupReaderTest( r := require.New(t) client := NewMockLensClient(gomock.NewController(t)) + // Needed since we call the list feeds endpoint prior to Show. + client.EXPECT(). + ListFeeds(gomock.Any(), gomock.Any()). + Return( + &api.ListFeedsResponse{Feeds: nil}, + nil, + ) // Needed since we call the stats endpoint prior to Show. client.EXPECT(). GetStats(gomock.Any(), gomock.Any()). diff --git a/internal/reader/theme.go b/internal/reader/theme.go index 339d29a..6d2ab3c 100644 --- a/internal/reader/theme.go +++ b/internal/reader/theme.go @@ -17,6 +17,12 @@ type Theme struct { AboutPopupTitle string WelcomePopupTitle string + UpdatedTodayText string + UpdatedThisWeekText string + UpdatedThisMonthText string + UpdatedEarlier string + UpdatedUnknownText string + Background tcell.Color BorderForeground tcell.Color @@ -86,6 +92,12 @@ var DarkTheme = &Theme{ AboutPopupTitle: "About", WelcomePopupTitle: "Welcome", + UpdatedTodayText: "Today", + UpdatedThisWeekText: "This Week", + UpdatedThisMonthText: "This Month", + UpdatedEarlier: "Earlier", + UpdatedUnknownText: "Unknown", + Background: tcell.ColorBlack, BorderForeground: tcell.ColorWhite,