diff --git a/api/cast.go b/api/cast.go index 33c1bf9..0cdfe2c 100644 --- a/api/cast.go +++ b/api/cast.go @@ -29,6 +29,7 @@ type Reactions struct { } type Cast struct { + Object string `json:"object"` Hash string `json:"hash"` ThreadHash string `json:"thread_hash"` ParentHash string `json:"parent_hash"` diff --git a/api/notifications.go b/api/notifications.go new file mode 100644 index 0000000..ae99b88 --- /dev/null +++ b/api/notifications.go @@ -0,0 +1,64 @@ +package api + +import ( + "context" + "fmt" + "time" +) + +type NotificationsType string +type CastReactionObjType string + +const ( + NotificationsTypeFollows NotificationsType = "follows" + NotificationsTypeLikes NotificationsType = "likes" + NotificationsTypeRecasts NotificationsType = "recasts" + NotificationsTypeMention NotificationsType = "mention" + NotificationsTypeReply NotificationsType = "reply" + + CastReactionObjTypeLikes CastReactionObjType = "likes" + CastReactionObjTypeRecasts CastReactionObjType = "recasts" +) + +type NotificationsResponse struct { + Notifications []*Notification `json:"notifications"` + Next struct { + Cursor *string `json:"cursor"` + } +} + +type Notification struct { + Object string `json:"object"` + MostRecentTimestamp time.Time `json:"most_recent_timestamp"` + Type NotificationsType `json:"type"` + Cast *Cast `json:"cast"` + Follows []FollowNotification `json:"follows"` + Reactions []ReactionNotification `json:"reactions"` +} + +type FollowNotification struct { + Object string `json:"object"` + User User `json:"user"` +} + +type ReactionNotification struct { + Object CastReactionObjType `json:"object"` + Cast NotificationCast `json:"cast"` + User User `json:"user"` +} + +type NotificationCast struct { + Cast // may be cast_dehydrated which only has hash, specifically for reactions +} + +func (c *Client) GetNotifications(fid uint64, opts ...RequestOption) (*NotificationsResponse, error) { + path := fmt.Sprintf("/notifications") + + opts = append(opts, WithFID(fid)) + + var resp NotificationsResponse + if err := c.doRequestInto(context.TODO(), path, &resp, opts...); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/ui/app.go b/ui/app.go index 1538bd4..fde75b8 100644 --- a/ui/app.go +++ b/ui/app.go @@ -51,20 +51,21 @@ type AppContext struct { } type App struct { - ctx *AppContext - client *api.Client - cfg *config.Config - focusedModel tea.Model - focused string - navname string - sidebar *Sidebar - showSidebar bool - prev string - prevName string - quickSelect *QuickSelect - publish *PublishInput - statusLine *StatusLine - // signinPrompt *SigninPrompt + ctx *AppContext + client *api.Client + cfg *config.Config + focusedModel tea.Model + focused string + navname string + sidebar *Sidebar + showSidebar bool + prev string + prevName string + quickSelect *QuickSelect + publish *PublishInput + statusLine *StatusLine + notifications *NotificationsView + splash *SplashView help *HelpView @@ -135,6 +136,7 @@ func NewApp(cfg *config.Config, ctx *AppContext) *App { a.publish = NewPublishInput(a) a.statusLine = NewStatusLine(a) a.help = NewHelpView(a) + a.notifications = NewNotificationsView(a) a.splash = NewSplashView(a) a.splash.SetActive(true) if a.ctx.signer == nil { @@ -162,6 +164,27 @@ func (a *App) focusMain() { if a.help.IsFull() { a.help.SetFull(false) } + if a.notifications.Active() { + a.notifications.SetActive(false) + } +} + +func (a *App) FocusPublish() { + a.publish.SetActive(true) + a.publish.SetFocus(true) +} +func (a *App) FocusHelp() { + a.help.SetFull(!a.help.IsFull()) +} +func (a *App) FocusQuickSelect() { + a.quickSelect.SetActive(true) +} +func (a *App) FocusNotifications() tea.Cmd { + a.notifications.SetActive(true) + return a.notifications.Init() +} +func (a *App) ToggleHelp() { + a.help.SetFull(!a.help.IsFull()) } func (a *App) FocusFeed() tea.Cmd { @@ -216,7 +239,11 @@ func (a *App) FocusPrev() tea.Cmd { func (a *App) Init() tea.Cmd { cmds := []tea.Cmd{} - cmds = append(cmds, a.splash.Init(), a.sidebar.Init(), a.quickSelect.Init(), a.publish.Init()) + cmds = append(cmds, + a.splash.Init(), a.sidebar.Init(), + a.quickSelect.Init(), a.publish.Init(), + a.notifications.Init(), + ) focus := a.GetFocused() if focus != nil { cmds = append(cmds, focus.Init()) @@ -231,6 +258,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _, sbcmd := a.statusLine.Update(msg) cmds = append(cmds, sbcmd) switch msg := msg.(type) { + case *notificationsMsg: + _, cmd := a.notifications.Update(msg) + return a, cmd case *UpdateSignerMsg: a.ctx.signer = msg.Signer a.splash.ShowSignin(false) @@ -296,10 +326,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { my := min(wy, int(float64(wy)*0.9)) - dialogX, dialogY := int(float64(mx)*0.8), int(float64(my)*0.8) + dialogX, dialogY := int(float64(mx)*0.8), int(float64(my)*0.9) a.publish.SetSize(dialogX, dialogY) a.quickSelect.SetSize(dialogX, dialogY) a.help.SetSize(dialogX, dialogY) + a.notifications.SetSize(dialogX, dialogY) childMsg := tea.WindowSizeMsg{ Width: mx, @@ -318,7 +349,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c", "q": return a, tea.Quit } - + cmd := NavKeyMap.HandleMsg(a, msg) + if cmd != nil { + return a, cmd + } + if a.sidebar.Active() { + _, cmd := a.sidebar.Update(msg) + return a, cmd + } if a.splash.Active() { _, cmd := a.splash.Update(msg) return a, cmd @@ -328,9 +366,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } - cmd := NavKeyMap.HandleMsg(a, msg) - if cmd != nil { - return a, cmd + if a.notifications.Active() { + _, cmd := a.notifications.Update(msg) + if cmd != nil { + return a, cmd + } } case *currentAccountMsg: @@ -383,6 +423,9 @@ func (a *App) View() string { main = lipgloss.Place(GetWidth(), GetHeight(), lipgloss.Center, lipgloss.Center, a.splash.View()) return main } + if a.notifications.Active() { + main = a.notifications.View() + } if a.publish.Active() { main = a.publish.View() diff --git a/ui/keybindings.go b/ui/keybindings.go index ee0c435..1cf0e6f 100644 --- a/ui/keybindings.go +++ b/ui/keybindings.go @@ -85,12 +85,14 @@ type navKeymap struct { ToggleSidebarFocus key.Binding ToggleSidebarVisibility key.Binding Previous key.Binding + ViewNotifications key.Binding } func (k navKeymap) ShortHelp() []key.Binding { return []key.Binding{ k.Feed, k.QuickSelect, + k.ViewNotifications, k.Help, } } @@ -99,6 +101,7 @@ func (k navKeymap) All() []key.Binding { return []key.Binding{ k.Feed, k.QuickSelect, k.Publish, + k.ViewNotifications, k.Previous, k.Help, k.ToggleSidebarFocus, k.ToggleSidebarVisibility, @@ -134,6 +137,10 @@ var NavKeyMap = navKeymap{ key.WithKeys("esc"), key.WithHelp("esc", "focus previous"), ), + ViewNotifications: key.NewBinding( + key.WithKeys("N"), + key.WithHelp("N", "view notifications"), + ), } func (k navKeymap) HandleMsg(a *App, msg tea.KeyMsg) tea.Cmd { @@ -147,16 +154,19 @@ func (k navKeymap) HandleMsg(a *App, msg tea.KeyMsg) tea.Cmd { return tea.Sequence(cmd, a.FocusFeed()) case key.Matches(msg, k.Publish): - a.publish.SetActive(true) - a.publish.SetFocus(true) + a.FocusPublish() return noOp() case key.Matches(msg, k.QuickSelect): - a.quickSelect.SetActive(true) + a.FocusQuickSelect() return nil case key.Matches(msg, k.Help): - a.help.SetFull(!a.help.IsFull()) + a.FocusHelp() + + case key.Matches(msg, k.ViewNotifications): + log.Println("ViewNotifications") + return a.FocusNotifications() case key.Matches(msg, k.Previous): return a.FocusPrev() diff --git a/ui/notifications.go b/ui/notifications.go new file mode 100644 index 0000000..a56cfed --- /dev/null +++ b/ui/notifications.go @@ -0,0 +1,200 @@ +package ui + +import ( + "fmt" + "log" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + + "github.com/treethought/tofui/api" +) + +type notificationsMsg struct { + notifications []*api.Notification +} + +func getNotificationsCmd(client *api.Client, signer *api.Signer) tea.Cmd { + return func() tea.Msg { + if signer == nil { + return nil + } + resp, err := client.GetNotifications(signer.FID) + if err != nil { + log.Println("error getting notifications: ", err) + return nil + } + return ¬ificationsMsg{notifications: resp.Notifications} + } +} + +type notifItem struct { + *api.Notification +} + +func (n *notifItem) FilterValue() string { + return string(n.Type) +} + +func buildUserList(users []api.User) string { + s := "" + for i, u := range users { + if i > 3 { + s += fmt.Sprintf(" and %d others", len(users)-i) + return s + } + s += u.DisplayName + if i < len(users)-1 { + s += ", " + } + } + return s +} + +func (n *notifItem) Title() string { + switch n.Type { + case api.NotificationsTypeFollows: + users := []api.User{} + for _, f := range n.Follows { + users = append(users, f.User) + } + userStr := buildUserList(users) + return fmt.Sprintf("%s %s followed you ", EmojiPerson, userStr) + case api.NotificationsTypeLikes: + users := []api.User{} + for _, r := range n.Reactions { + if r.Object == api.CastReactionObjTypeLikes { + users = append(users, r.User) + } + } + userStr := buildUserList(users) + return fmt.Sprintf("%s %s liked your post", EmojiLike, userStr) + case api.NotificationsTypeRecasts: + users := []api.User{} + for _, r := range n.Reactions { + if r.Object == api.CastReactionObjTypeRecasts { + users = append(users, r.User) + } + } + userStr := buildUserList(users) + return fmt.Sprintf("%s %s recasted your post", EmojiRecyle, userStr) + case api.NotificationsTypeReply: + return fmt.Sprintf("%s %s replied to your post", EmojiComment, n.Cast.Author.DisplayName) + + default: + return "unknown notification type: " + string(n.Type) + + } +} + +func (i *notifItem) Description() string { + switch i.Type { + case api.NotificationsTypeLikes, api.NotificationsTypeRecasts: + if i.Cast != nil { + return i.Cast.Text + } + for _, r := range i.Reactions { + if r.Object == api.CastReactionObjTypeLikes { + if r.Cast.Object == "cast_dehydrated" { + return r.Cast.Hash + } + return r.Cast.Text + } + } + return "?" + case api.NotificationsTypeReply: + return i.Cast.Text + + } + return "" + +} + +type NotificationsView struct { + app *App + list *list.Model + w, h int + active bool + items []list.Item +} + +func NewNotificationsView(app *App) *NotificationsView { + d := list.NewDefaultDelegate() + d.SetHeight(2) + d.ShowDescription = true + + l := list.New([]list.Item{}, d, 100, 100) + l.KeyMap.CursorUp.SetKeys("k", "up") + l.KeyMap.CursorDown.SetKeys("j", "down") + l.KeyMap.Quit.SetKeys("ctrl+c") + l.Title = "notifications" + l.SetShowTitle(true) + l.SetFilteringEnabled(false) + l.SetShowFilter(false) + l.SetShowHelp(true) + l.SetShowStatusBar(true) + l.SetShowPagination(true) + + return &NotificationsView{app: app, list: &l} +} + +func (m *NotificationsView) SetSize(w, h int) { + m.w, m.h = w, h + m.list.SetWidth(w) + m.list.SetHeight(h) +} +func (m *NotificationsView) Active() bool { + return m.active +} +func (m *NotificationsView) SetActive(active bool) { + m.active = active +} + +func (m *NotificationsView) Init() tea.Cmd { + return getNotificationsCmd(m.app.client, m.app.ctx.signer) +} + +func (m *NotificationsView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.SetSize(msg.Width, msg.Height) + return m, nil + + case *notificationsMsg: + items := []list.Item{} + for _, n := range msg.notifications { + items = append(items, ¬ifItem{n}) + } + m.items = items + m.list.SetItems(items) + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "q": + return m, tea.Quit + case "enter": + item, ok := m.list.SelectedItem().(*notifItem) + if !ok { + return m, noOp() + } + switch item.Type { + case api.NotificationsTypeLikes, api.NotificationsTypeRecasts, api.NotificationsTypeReply: + return m, tea.Sequence( + m.app.FocusCast(), + selectCast(item.Cast), + ) + } + return m, noOp() + } + l, cmd := m.list.Update(msg) + m.list = &l + return m, cmd + } + + return m, nil +} + +func (m *NotificationsView) View() string { + return NewStyle().Width(m.w).Height(m.h).Render(m.list.View()) +} diff --git a/ui/sidebar.go b/ui/sidebar.go index e63014b..1176202 100644 --- a/ui/sidebar.go +++ b/ui/sidebar.go @@ -101,6 +101,7 @@ func (m *Sidebar) navHeader() []list.Item { items := []list.Item{} if api.GetSigner(m.app.ctx.pk) != nil { items = append(items, &sidebarItem{name: "profile"}) + items = append(items, &sidebarItem{name: "notifications"}) } items = append(items, &sidebarItem{name: "feed"}) items = append(items, &sidebarItem{name: "--channels---", value: "--channels--", icon: "🏠"}) @@ -137,6 +138,9 @@ func (m *Sidebar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.nav.SetItems(items) case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } if !m.active { return m, nil } @@ -155,6 +159,11 @@ func (m *Sidebar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { getUserFeedCmd(m.app.client, fid, m.app.ctx.signer.FID), ) } + if currentItem.name == "notifications" { + m.SetActive(false) + log.Println("notifications selected") + return m, tea.Sequence(m.app.FocusNotifications()) + } if currentItem.name == "feed" { m.SetActive(false) log.Println("feed selected")