diff --git a/README.md b/README.md index 142eac4..6aa3406 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ You can find Linux binaries under [releases](https://github.com/RasmusLindroth/t ### Currently supported commands * `:q` `:quit` exit -* `:timeline` home, local, federated, direct +* `:timeline` home, local, federated, direct, notifications +* `:tl` h, l, f, d, n (a shorter form of the former) Explanation of the non obvious keys when viewing a toot * `V` = view. In this mode you can scroll throught the text of the toot if it doesn't fit the screen @@ -45,11 +46,9 @@ you will have to add `go/bin` to your `$PATH`. ### On my TODO-list: * Support for config files (theme, default image/video viewer) * Multiple accounts -* View users profiles * Support search * Support tags * Support lists -* Notifications * Better error handling (in other words, don't crash the whole program) ### Thanks to diff --git a/api.go b/api.go index 84e7c71..739bd4c 100644 --- a/api.go +++ b/api.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "github.com/mattn/go-mastodon" ) @@ -98,27 +99,194 @@ func (api *API) GetThread(s *mastodon.Status) ([]*mastodon.Status, int, error) { return thread, len(cont.Ancestors), nil } -func (api *API) Boost(s *mastodon.Status) error { - _, err := api.Client.Reblog(context.Background(), s.ID) - return err +func (api *API) GetUserStatuses(u mastodon.Account) ([]*mastodon.Status, error) { + return api.Client.GetAccountStatuses(context.Background(), u.ID, nil) } -func (api *API) Unboost(s *mastodon.Status) error { - _, err := api.Client.Unreblog(context.Background(), s.ID) - return err +func (api *API) GetUserStatusesOlder(u mastodon.Account, s *mastodon.Status) ([]*mastodon.Status, bool, error) { + pg := &mastodon.Pagination{ + MaxID: s.ID, + } + + statuses, err := api.Client.GetAccountStatuses(context.Background(), u.ID, pg) + if err != nil { + return statuses, false, err + } + + if pg.MinID == "" { + return statuses, false, err + } + + return statuses, true, err } -func (api *API) Favorite(s *mastodon.Status) error { - _, err := api.Client.Favourite(context.Background(), s.ID) - return err +func (api *API) GetUserStatusesNewer(u mastodon.Account, s *mastodon.Status) ([]*mastodon.Status, bool, error) { + pg := &mastodon.Pagination{ + MinID: s.ID, + } + + statuses, err := api.Client.GetAccountStatuses(context.Background(), u.ID, pg) + if err != nil { + return statuses, false, err + } + + if pg.MaxID == "" { + return statuses, false, err + } + + return statuses, true, err } -func (api *API) Unfavorite(s *mastodon.Status) error { - _, err := api.Client.Unfavourite(context.Background(), s.ID) - return err +func (api *API) GetNotifications() ([]*mastodon.Notification, error) { + return api.Client.GetNotifications(context.Background(), nil) +} + +func (api *API) GetNotificationsOlder(n *mastodon.Notification) ([]*mastodon.Notification, bool, error) { + pg := &mastodon.Pagination{ + MaxID: n.ID, + } + + statuses, err := api.Client.GetNotifications(context.Background(), pg) + if err != nil { + return statuses, false, err + } + + if pg.MinID == "" { + return statuses, false, err + } + + return statuses, true, err +} + +func (api *API) GetNotificationsNewer(n *mastodon.Notification) ([]*mastodon.Notification, bool, error) { + pg := &mastodon.Pagination{ + MinID: n.ID, + } + + statuses, err := api.Client.GetNotifications(context.Background(), pg) + if err != nil { + return statuses, false, err + } + + if pg.MaxID == "" { + return statuses, false, err + } + + return statuses, true, err +} + +func (api *API) BoostToggle(s *mastodon.Status) (*mastodon.Status, error) { + if s == nil { + return nil, fmt.Errorf("No status") + } + + if s.Reblogged == true { + return api.Unboost(s) + } + return api.Boost(s) +} + +func (api *API) Boost(s *mastodon.Status) (*mastodon.Status, error) { + status, err := api.Client.Reblog(context.Background(), s.ID) + return status, err +} + +func (api *API) Unboost(s *mastodon.Status) (*mastodon.Status, error) { + status, err := api.Client.Unreblog(context.Background(), s.ID) + return status, err +} + +func (api *API) FavoriteToogle(s *mastodon.Status) (*mastodon.Status, error) { + if s == nil { + return nil, fmt.Errorf("No status") + } + + if s.Favourited == true { + return api.Unfavorite(s) + } + return api.Favorite(s) +} + +func (api *API) Favorite(s *mastodon.Status) (*mastodon.Status, error) { + status, err := api.Client.Favourite(context.Background(), s.ID) + return status, err +} + +func (api *API) Unfavorite(s *mastodon.Status) (*mastodon.Status, error) { + status, err := api.Client.Unfavourite(context.Background(), s.ID) + return status, err } func (api *API) DeleteStatus(s *mastodon.Status) error { //TODO: check user here? return api.Client.DeleteStatus(context.Background(), s.ID) } + +func (api *API) UserRelation(u mastodon.Account) (*mastodon.Relationship, error) { + relations, err := api.Client.GetAccountRelationships(context.Background(), []string{string(u.ID)}) + + if err != nil { + return nil, err + } + if len(relations) == 0 { + return nil, fmt.Errorf("no accounts found") + } + return relations[0], nil +} + +func (api *API) FollowToggle(u mastodon.Account) (*mastodon.Relationship, error) { + relation, err := api.UserRelation(u) + if err != nil { + return nil, err + } + if relation.Following { + return api.UnfollowUser(u) + } + return api.FollowUser(u) +} + +func (api *API) FollowUser(u mastodon.Account) (*mastodon.Relationship, error) { + return api.Client.AccountFollow(context.Background(), u.ID) +} + +func (api *API) UnfollowUser(u mastodon.Account) (*mastodon.Relationship, error) { + return api.Client.AccountUnfollow(context.Background(), u.ID) +} + +func (api *API) BlockToggle(u mastodon.Account) (*mastodon.Relationship, error) { + relation, err := api.UserRelation(u) + if err != nil { + return nil, err + } + if relation.Blocking { + return api.UnblockUser(u) + } + return api.BlockUser(u) +} + +func (api *API) BlockUser(u mastodon.Account) (*mastodon.Relationship, error) { + return api.Client.AccountBlock(context.Background(), u.ID) +} + +func (api *API) UnblockUser(u mastodon.Account) (*mastodon.Relationship, error) { + return api.Client.AccountUnblock(context.Background(), u.ID) +} + +func (api *API) MuteToggle(u mastodon.Account) (*mastodon.Relationship, error) { + relation, err := api.UserRelation(u) + if err != nil { + return nil, err + } + if relation.Blocking { + return api.UnmuteUser(u) + } + return api.MuteUser(u) +} + +func (api *API) MuteUser(u mastodon.Account) (*mastodon.Relationship, error) { + return api.Client.AccountMute(context.Background(), u.ID) +} + +func (api *API) UnmuteUser(u mastodon.Account) (*mastodon.Relationship, error) { + return api.Client.AccountUnmute(context.Background(), u.ID) +} diff --git a/config.go b/config.go index c82a152..0d2f011 100644 --- a/config.go +++ b/config.go @@ -8,8 +8,14 @@ import ( ) type Config struct { - Style StyleConfig - Media MediaConfig + General GeneralConfig + Style StyleConfig + Media MediaConfig +} + +type GeneralConfig struct { + DateTodayFormat string + DateFormat string } type StyleConfig struct { diff --git a/feed.go b/feed.go new file mode 100644 index 0000000..a863d48 --- /dev/null +++ b/feed.go @@ -0,0 +1,948 @@ +package main + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/gdamore/tcell" + "github.com/mattn/go-mastodon" + "github.com/rivo/tview" +) + +type FeedType uint + +const ( + TimelineFeed FeedType = iota + ThreadFeed + UserFeed + NotificationFeed +) + +type Feed interface { + GetFeedList() <-chan string + LoadNewer() int + LoadOlder() int + DrawList() + DrawToot() + FeedType() FeedType + GetSavedIndex() int + Input(event *tcell.EventKey) +} + +func showTootOptions(app *App, status *mastodon.Status, showSensitive bool) (string, string) { + var line string + width := app.UI.StatusView.GetTextWidth() + for i := 0; i < width; i++ { + line += "-" + } + line += "\n" + + shouldDisplay := !status.Sensitive || showSensitive + + var stripped string + var urls []URL + var u []URL + if status.Sensitive && !showSensitive { + stripped, u = cleanTootHTML(status.SpoilerText) + urls = append(urls, u...) + stripped += "\n" + line + stripped += "Press [s] to show hidden text" + + } else { + stripped, u = cleanTootHTML(status.Content) + urls = append(urls, u...) + + if status.Sensitive { + sens, u := cleanTootHTML(status.SpoilerText) + urls = append(urls, u...) + stripped = sens + "\n\n" + stripped + } + } + app.UI.LinkOverlay.SetURLs(urls) + + subtleColor := fmt.Sprintf("[#%x]", app.Config.Style.Subtle.Hex()) + special1 := fmt.Sprintf("[#%x]", app.Config.Style.TextSpecial1.Hex()) + special2 := fmt.Sprintf("[#%x]", app.Config.Style.TextSpecial2.Hex()) + var head string + if status.Reblog != nil { + if status.Account.DisplayName != "" { + head += fmt.Sprintf(subtleColor+"%s (%s)\n", status.Account.DisplayName, status.Account.Acct) + } else { + head += fmt.Sprintf(subtleColor+"%s\n", status.Account.Acct) + } + head += subtleColor + "Boosted\n" + head += subtleColor + line + status = status.Reblog + } + + if status.Account.DisplayName != "" { + head += fmt.Sprintf(special2+"%s\n", status.Account.DisplayName) + } + head += fmt.Sprintf(special1+"%s\n\n", status.Account.Acct) + output := head + content := tview.Escape(stripped) + if content != "" { + output += content + "\n\n" + } + + var poll string + if status.Poll != nil { + poll += subtleColor + "Poll\n" + poll += subtleColor + line + poll += fmt.Sprintf("Number of votes: %d\n\n", status.Poll.VotesCount) + votes := float64(status.Poll.VotesCount) + for _, o := range status.Poll.Options { + res := 0.0 + if votes != 0 { + res = float64(o.VotesCount) / votes * 100 + } + poll += fmt.Sprintf("%s - %.2f%% (%d)\n", tview.Escape(o.Title), res, o.VotesCount) + } + poll += "\n" + } + + var media string + for _, att := range status.MediaAttachments { + media += subtleColor + line + media += fmt.Sprintf(subtleColor+"Attached %s\n", att.Type) + media += fmt.Sprintf("%s\n", att.URL) + } + if len(status.MediaAttachments) > 0 { + media += "\n" + } + + var card string + if status.Card != nil { + card += subtleColor + "Card type: " + status.Card.Type + "\n" + card += subtleColor + line + if status.Card.Title != "" { + card += status.Card.Title + "\n\n" + } + desc := strings.TrimSpace(status.Card.Description) + if desc != "" { + card += desc + "\n\n" + } + card += status.Card.URL + } + + if shouldDisplay { + output += poll + media + card + } + + app.UI.StatusView.ScrollToBeginning() + var info []string + if status.Favourited == true { + info = append(info, ColorKey(app.Config.Style, "Un", "F", "avorite")) + } else { + info = append(info, ColorKey(app.Config.Style, "", "F", "avorite")) + } + if status.Reblogged == true { + info = append(info, ColorKey(app.Config.Style, "Un", "B", "boost")) + } else { + info = append(info, ColorKey(app.Config.Style, "", "B", "boost")) + } + info = append(info, ColorKey(app.Config.Style, "", "T", "hread")) + info = append(info, ColorKey(app.Config.Style, "", "R", "eply")) + info = append(info, ColorKey(app.Config.Style, "", "V", "iew")) + info = append(info, ColorKey(app.Config.Style, "", "U", "ser")) + if len(status.MediaAttachments) > 0 { + info = append(info, ColorKey(app.Config.Style, "", "M", "edia")) + } + if len(urls) > 0 { + info = append(info, ColorKey(app.Config.Style, "", "O", "pen")) + } + + if status.Account.ID == app.Me.ID { + info = append(info, ColorKey(app.Config.Style, "", "D", "elete")) + } + + controls := strings.Join(info, " ") + return output, controls +} + +func drawStatusList(statuses []*mastodon.Status) <-chan string { + ch := make(chan string) + go func() { + today := time.Now() + ty, tm, td := today.Date() + for _, s := range statuses { + + sLocal := s.CreatedAt.Local() + sy, sm, sd := sLocal.Date() + format := "2006-01-02 15:04" + if ty == sy && tm == sm && td == sd { + format = "15:04" + } + content := fmt.Sprintf("%s %s", sLocal.Format(format), s.Account.Acct) + ch <- content + } + close(ch) + }() + return ch +} + +func NewTimeline(app *App, tl TimelineType) *Timeline { + t := &Timeline{ + app: app, + timelineType: tl, + } + t.statuses, _ = t.app.API.GetStatuses(t.timelineType) + return t +} + +type Timeline struct { + app *App + timelineType TimelineType + statuses []*mastodon.Status + index int + showSpoiler bool +} + +func (t *Timeline) FeedType() FeedType { + return TimelineFeed +} + +func (t *Timeline) GetCurrentStatus() *mastodon.Status { + index := t.app.UI.StatusView.GetCurrentItem() + if index >= len(t.statuses) { + return nil + } + return t.statuses[t.app.UI.StatusView.GetCurrentItem()] +} + +func (t *Timeline) GetFeedList() <-chan string { + return drawStatusList(t.statuses) +} + +func (t *Timeline) LoadNewer() int { + var statuses []*mastodon.Status + var err error + if len(t.statuses) == 0 { + statuses, err = t.app.API.GetStatuses(t.timelineType) + } else { + statuses, _, err = t.app.API.GetStatusesNewer(t.timelineType, t.statuses[0]) + } + if err != nil { + log.Fatalln(err) + } + if len(statuses) == 0 { + return 0 + } + old := t.statuses + t.statuses = append(statuses, old...) + return len(statuses) +} + +func (t *Timeline) LoadOlder() int { + var statuses []*mastodon.Status + var err error + if len(t.statuses) == 0 { + statuses, err = t.app.API.GetStatuses(t.timelineType) + } else { + statuses, _, err = t.app.API.GetStatusesOlder(t.timelineType, t.statuses[len(t.statuses)-1]) + } + if err != nil { + log.Fatalln(err) + } + if len(statuses) == 0 { + return 0 + } + t.statuses = append(t.statuses, statuses...) + return len(statuses) +} + +func (t *Timeline) DrawList() { + t.app.UI.StatusView.SetList(t.GetFeedList()) +} + +func (t *Timeline) DrawToot() { + if len(t.statuses) == 0 { + t.app.UI.StatusView.SetText("") + t.app.UI.StatusView.SetControls("") + return + } + t.index = t.app.UI.StatusView.GetCurrentItem() + text, controls := showTootOptions(t.app, t.statuses[t.index], t.showSpoiler) + t.showSpoiler = false + t.app.UI.StatusView.SetText(text) + t.app.UI.StatusView.SetControls(controls) +} + +func (t *Timeline) redrawControls() { + status := t.GetCurrentStatus() + if status == nil { + return + } + _, controls := showTootOptions(t.app, status, t.showSpoiler) + t.app.UI.StatusView.SetControls(controls) +} + +func (t *Timeline) GetSavedIndex() int { + return t.index +} + +func (t *Timeline) Input(event *tcell.EventKey) { + status := t.GetCurrentStatus() + if status == nil { + return + } + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 't', 'T': + t.app.UI.StatusView.AddFeed( + NewThread(t.app, status), + ) + case 'u', 'U': + t.app.UI.StatusView.AddFeed( + NewUser(t.app, status.Account), + ) + case 's', 'S': + t.showSpoiler = true + t.DrawToot() + case 'c', 'C': + t.app.UI.NewToot() + case 'o', 'O': + t.app.UI.ShowLinks() + case 'r', 'R': + t.app.UI.Reply(status) + case 'm', 'M': + t.app.UI.OpenMedia(status) + case 'f', 'F': + index := t.app.UI.StatusView.GetCurrentItem() + newStatus, err := t.app.API.FavoriteToogle(status) + if err != nil { + log.Fatalln(err) + } + t.statuses[index] = newStatus + t.redrawControls() + + case 'b', 'B': + index := t.app.UI.StatusView.GetCurrentItem() + newStatus, err := t.app.API.BoostToggle(status) + if err != nil { + log.Fatalln(err) + } + t.statuses[index] = newStatus + t.redrawControls() + case 'd', 'D': + t.app.API.DeleteStatus(status) + } + } +} + +func NewThread(app *App, s *mastodon.Status) *Thread { + t := &Thread{ + app: app, + } + statuses, index, err := t.app.API.GetThread(s) + if err != nil { + log.Fatalln(err) + } + t.statuses = statuses + t.status = s + t.index = index + return t +} + +type Thread struct { + app *App + statuses []*mastodon.Status + status *mastodon.Status + index int + showSpoiler bool +} + +func (t *Thread) FeedType() FeedType { + return ThreadFeed +} + +func (t *Thread) GetCurrentStatus() *mastodon.Status { + index := t.app.UI.StatusView.GetCurrentItem() + if index >= len(t.statuses) { + return nil + } + return t.statuses[t.app.UI.StatusView.GetCurrentItem()] +} + +func (t *Thread) GetFeedList() <-chan string { + return drawStatusList(t.statuses) +} + +func (t *Thread) LoadNewer() int { + return 0 +} + +func (t *Thread) LoadOlder() int { + return 0 +} + +func (t *Thread) DrawList() { + t.app.UI.StatusView.SetList(t.GetFeedList()) +} + +func (t *Thread) DrawToot() { + status := t.GetCurrentStatus() + if status == nil { + t.app.UI.StatusView.SetText("") + t.app.UI.StatusView.SetControls("") + return + } + t.index = t.app.UI.StatusView.GetCurrentItem() + text, controls := showTootOptions(t.app, status, t.showSpoiler) + t.showSpoiler = false + t.app.UI.StatusView.SetText(text) + t.app.UI.StatusView.SetControls(controls) +} + +func (t *Thread) redrawControls() { + status := t.GetCurrentStatus() + if status == nil { + t.app.UI.StatusView.SetText("") + t.app.UI.StatusView.SetControls("") + return + } + _, controls := showTootOptions(t.app, status, t.showSpoiler) + t.app.UI.StatusView.SetControls(controls) +} + +func (t *Thread) GetSavedIndex() int { + return t.index +} + +func (t *Thread) Input(event *tcell.EventKey) { + status := t.GetCurrentStatus() + if status == nil { + return + } + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 't', 'T': + if t.status.ID != status.ID { + t.app.UI.StatusView.AddFeed( + NewThread(t.app, status), + ) + } + case 'u', 'U': + t.app.UI.StatusView.AddFeed( + NewUser(t.app, status.Account), + ) + case 's', 'S': + t.showSpoiler = true + t.DrawToot() + case 'c', 'C': + t.app.UI.NewToot() + case 'o', 'O': + t.app.UI.ShowLinks() + case 'r', 'R': + t.app.UI.Reply(status) + case 'm', 'M': + t.app.UI.OpenMedia(status) + case 'f', 'F': + index := t.app.UI.StatusView.GetCurrentItem() + newStatus, err := t.app.API.FavoriteToogle(status) + if err != nil { + log.Fatalln(err) + } + t.statuses[index] = newStatus + t.redrawControls() + + case 'b', 'B': + index := t.app.UI.StatusView.GetCurrentItem() + newStatus, err := t.app.API.BoostToggle(status) + if err != nil { + log.Fatalln(err) + } + t.statuses[index] = newStatus + t.redrawControls() + case 'd', 'D': + t.app.API.DeleteStatus(status) + } + } +} + +func NewUser(app *App, a mastodon.Account) *User { + u := &User{ + app: app, + } + statuses, err := app.API.GetUserStatuses(a) + if err != nil { + log.Fatalln(err) + } + u.statuses = statuses + relation, err := app.API.UserRelation(a) + if err != nil { + log.Fatalln(err) + } + u.relation = relation + u.user = a + return u +} + +type User struct { + app *App + statuses []*mastodon.Status + user mastodon.Account + relation *mastodon.Relationship + index int + showSpoiler bool +} + +func (u *User) FeedType() FeedType { + return UserFeed +} + +func (u *User) GetCurrentStatus() *mastodon.Status { + index := u.app.UI.app.UI.StatusView.GetCurrentItem() + if index > 0 && index-1 >= len(u.statuses) { + return nil + } + return u.statuses[index-1] +} + +func (u *User) GetFeedList() <-chan string { + ch := make(chan string) + go func() { + ch <- "Profile" + for s := range drawStatusList(u.statuses) { + ch <- s + } + close(ch) + }() + return ch +} + +func (u *User) LoadNewer() int { + var statuses []*mastodon.Status + var err error + if len(u.statuses) == 0 { + statuses, err = u.app.API.GetUserStatuses(u.user) + } else { + statuses, _, err = u.app.API.GetUserStatusesNewer(u.user, u.statuses[0]) + } + if err != nil { + log.Fatalln(err) + } + if len(statuses) == 0 { + return 0 + } + old := u.statuses + u.statuses = append(statuses, old...) + return len(statuses) +} + +func (u *User) LoadOlder() int { + var statuses []*mastodon.Status + var err error + if len(u.statuses) == 0 { + statuses, err = u.app.API.GetUserStatuses(u.user) + } else { + statuses, _, err = u.app.API.GetUserStatusesOlder(u.user, u.statuses[len(u.statuses)-1]) + } + if err != nil { + log.Fatalln(err) + } + if len(statuses) == 0 { + return 0 + } + u.statuses = append(u.statuses, statuses...) + return len(statuses) +} + +func (u *User) DrawList() { + u.app.UI.StatusView.SetList(u.GetFeedList()) +} + +func (u *User) DrawToot() { + u.index = u.app.UI.StatusView.GetCurrentItem() + + var text string + var controls string + + if u.index == 0 { + n := fmt.Sprintf("[#%x]", u.app.Config.Style.Text.Hex()) + s1 := fmt.Sprintf("[#%x]", u.app.Config.Style.TextSpecial1.Hex()) + s2 := fmt.Sprintf("[#%x]", u.app.Config.Style.TextSpecial2.Hex()) + + if u.user.DisplayName != "" { + text = fmt.Sprintf(s2+"%s\n", u.user.DisplayName) + } + text += fmt.Sprintf(s1+"%s\n\n", u.user.Acct) + + text += fmt.Sprintf("Toots %s%d %sFollowers %s%d %sFollowing %s%d\n\n", + s2, u.user.StatusesCount, n, s2, u.user.FollowersCount, n, s2, u.user.FollowingCount) + + note, urls := cleanTootHTML(u.user.Note) + text += note + "\n\n" + + for _, f := range u.user.Fields { + value, fu := cleanTootHTML(f.Value) + text += fmt.Sprintf("%s%s: %s%s\n", s2, f.Name, n, value) + urls = append(urls, fu...) + } + + u.app.UI.LinkOverlay.SetURLs(urls) + + var controlItems []string + if u.app.Me.ID != u.user.ID { + if u.relation.Following { + controlItems = append(controlItems, ColorKey(u.app.Config.Style, "Un", "F", "ollow")) + } else { + controlItems = append(controlItems, ColorKey(u.app.Config.Style, "", "F", "ollow")) + } + if u.relation.Blocking { + controlItems = append(controlItems, ColorKey(u.app.Config.Style, "Un", "B", "lock")) + } else { + controlItems = append(controlItems, ColorKey(u.app.Config.Style, "", "B", "lock")) + } + if u.relation.Muting { + controlItems = append(controlItems, ColorKey(u.app.Config.Style, "Un", "M", "ute")) + } else { + controlItems = append(controlItems, ColorKey(u.app.Config.Style, "", "M", "ute")) + } + if len(urls) > 0 { + controlItems = append(controlItems, ColorKey(u.app.Config.Style, "", "O", "pen")) + } + controls = strings.Join(controlItems, " ") + } + + } else { + status := u.GetCurrentStatus() + if status == nil { + text = "" + controls = "" + } else { + text, controls = showTootOptions(u.app, status, u.showSpoiler) + } + u.showSpoiler = false + } + + u.app.UI.StatusView.SetText(text) + u.app.UI.StatusView.SetControls(controls) +} + +func (u *User) redrawControls() { + var controls string + status := u.GetCurrentStatus() + if status == nil { + controls = "" + } else { + _, controls = showTootOptions(u.app, status, u.showSpoiler) + } + u.app.UI.StatusView.SetControls(controls) +} + +func (u *User) GetSavedIndex() int { + return u.index +} + +func (u *User) Input(event *tcell.EventKey) { + index := u.GetSavedIndex() + + if index == 0 { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'f', 'F': + var relation *mastodon.Relationship + var err error + if u.relation.Following { + relation, err = u.app.API.UnfollowUser(u.user) + } else { + relation, err = u.app.API.FollowUser(u.user) + } + if err != nil { + log.Fatalln(err) + } + u.relation = relation + u.DrawToot() + case 'b', 'B': + var relation *mastodon.Relationship + var err error + if u.relation.Blocking { + relation, err = u.app.API.UnblockUser(u.user) + } else { + relation, err = u.app.API.BlockUser(u.user) + } + if err != nil { + log.Fatalln(err) + } + u.relation = relation + u.DrawToot() + case 'm', 'M': + var relation *mastodon.Relationship + var err error + if u.relation.Muting { + relation, err = u.app.API.UnmuteUser(u.user) + } else { + relation, err = u.app.API.MuteUser(u.user) + } + if err != nil { + log.Fatalln(err) + } + u.relation = relation + u.DrawToot() + case 'r', 'R': + //toots and replies? + case 'o', 'O': + u.app.UI.ShowLinks() + } + } + return + } + + if event.Key() == tcell.KeyRune { + status := u.GetCurrentStatus() + if status == nil { + return + } + switch event.Rune() { + case 't', 'T': + u.app.UI.StatusView.AddFeed( + NewThread(u.app, status), + ) + case 'u', 'U': + if u.user.ID != status.Account.ID { + u.app.UI.StatusView.AddFeed( + NewUser(u.app, status.Account), + ) + } + case 's', 'S': + u.showSpoiler = true + u.DrawToot() + case 'c', 'C': + u.app.UI.NewToot() + case 'o', 'O': + u.app.UI.ShowLinks() + case 'r', 'R': + u.app.UI.Reply(status) + case 'm', 'M': + u.app.UI.OpenMedia(status) + case 'f', 'F': + index := u.app.UI.StatusView.GetCurrentItem() + newStatus, err := u.app.API.FavoriteToogle(status) + if err != nil { + log.Fatalln(err) + } + u.statuses[index-1] = newStatus + u.redrawControls() + + case 'b', 'B': + index := u.app.UI.StatusView.GetCurrentItem() + newStatus, err := u.app.API.BoostToggle(status) + if err != nil { + log.Fatalln(err) + } + u.statuses[index-1] = newStatus + u.redrawControls() + case 'd', 'D': + u.app.API.DeleteStatus(status) + } + } +} + +func NewNoticifations(app *App) *Notifications { + n := &Notifications{ + app: app, + } + n.notifications, _ = n.app.API.GetNotifications() + return n +} + +type Notifications struct { + app *App + timelineType TimelineType + notifications []*mastodon.Notification + index int + showSpoiler bool +} + +func (n *Notifications) FeedType() FeedType { + return NotificationFeed +} + +func (n *Notifications) GetCurrentNotification() *mastodon.Notification { + index := n.app.UI.StatusView.GetCurrentItem() + if index >= len(n.notifications) { + return nil + } + return n.notifications[index] +} + +func (n *Notifications) GetFeedList() <-chan string { + ch := make(chan string) + notifications := n.notifications + go func() { + today := time.Now() + ty, tm, td := today.Date() + for _, item := range notifications { + sLocal := item.CreatedAt.Local() + sy, sm, sd := sLocal.Date() + format := "2006-01-02 15:04" + if ty == sy && tm == sm && td == sd { + format = "15:04" + } + content := fmt.Sprintf("%s %s", sLocal.Format(format), item.Account.Acct) + ch <- content + } + close(ch) + }() + return ch +} + +func (n *Notifications) LoadNewer() int { + var notifications []*mastodon.Notification + var err error + if len(n.notifications) == 0 { + notifications, err = n.app.API.GetNotifications() + } else { + notifications, _, err = n.app.API.GetNotificationsNewer(n.notifications[0]) + } + if err != nil { + log.Fatalln(err) + } + if len(notifications) == 0 { + return 0 + } + old := n.notifications + n.notifications = append(notifications, old...) + return len(notifications) +} + +func (n *Notifications) LoadOlder() int { + var notifications []*mastodon.Notification + var err error + if len(n.notifications) == 0 { + notifications, err = n.app.API.GetNotifications() + } else { + notifications, _, err = n.app.API.GetNotificationsOlder(n.notifications[len(n.notifications)-1]) + } + if err != nil { + log.Fatalln(err) + } + if len(notifications) == 0 { + return 0 + } + n.notifications = append(n.notifications, notifications...) + return len(notifications) +} + +func (n *Notifications) DrawList() { + n.app.UI.StatusView.SetList(n.GetFeedList()) +} + +func (n *Notifications) DrawToot() { + n.index = n.app.UI.StatusView.GetCurrentItem() + notification := n.GetCurrentNotification() + if notification == nil { + n.app.UI.StatusView.SetText("") + n.app.UI.StatusView.SetControls("") + return + } + var text string + var controls string + defer func() { n.showSpoiler = false }() + + switch notification.Type { + case "follow": + text = SublteText(n.app.Config.Style, FormatUsername(notification.Account)+" started following you\n\n") + controls = ColorKey(n.app.Config.Style, "", "U", "ser") + case "favourite": + pre := SublteText(n.app.Config.Style, FormatUsername(notification.Account)+" favorited your toot") + "\n\n" + text, controls = showTootOptions(n.app, notification.Status, n.showSpoiler) + text = pre + text + case "reblog": + pre := SublteText(n.app.Config.Style, FormatUsername(notification.Account)+" boosted your toot") + "\n\n" + text, controls = showTootOptions(n.app, notification.Status, n.showSpoiler) + text = pre + text + case "mention": + pre := SublteText(n.app.Config.Style, FormatUsername(notification.Account)+" mentioned you") + "\n\n" + text, controls = showTootOptions(n.app, notification.Status, n.showSpoiler) + text = pre + text + case "poll": + pre := SublteText(n.app.Config.Style, "A poll of yours or one you participated in has ended") + "\n\n" + text, controls = showTootOptions(n.app, notification.Status, n.showSpoiler) + text = pre + text + } + + n.app.UI.StatusView.SetText(text) + n.app.UI.StatusView.SetControls(controls) +} + +func (n *Notifications) redrawControls() { + notification := n.GetCurrentNotification() + if notification == nil { + n.app.UI.StatusView.SetControls("") + return + } + switch notification.Type { + case "favourite", "reblog", "mention", "poll": + _, controls := showTootOptions(n.app, notification.Status, n.showSpoiler) + n.app.UI.StatusView.SetControls(controls) + } +} + +func (n *Notifications) GetSavedIndex() int { + return n.index +} + +func (n *Notifications) Input(event *tcell.EventKey) { + notification := n.GetCurrentNotification() + if notification == nil { + return + } + if notification.Type == "follow" { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'u', 'U': + n.app.UI.StatusView.AddFeed( + NewUser(n.app, notification.Account), + ) + } + } + return + } + + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 't', 'T': + n.app.UI.StatusView.AddFeed( + NewThread(n.app, notification.Status), + ) + case 'u', 'U': + n.app.UI.StatusView.AddFeed( + NewUser(n.app, notification.Account), + ) + case 's', 'S': + n.showSpoiler = true + n.DrawToot() + case 'c', 'C': + n.app.UI.NewToot() + case 'o', 'O': + n.app.UI.ShowLinks() + case 'r', 'R': + n.app.UI.Reply(notification.Status) + case 'm', 'M': + n.app.UI.OpenMedia(notification.Status) + case 'f', 'F': + index := n.app.UI.StatusView.GetCurrentItem() + status, err := n.app.API.FavoriteToogle(notification.Status) + if err != nil { + log.Fatalln(err) + } + n.notifications[index].Status = status + n.redrawControls() + + case 'b', 'B': + index := n.app.UI.StatusView.GetCurrentItem() + status, err := n.app.API.BoostToggle(notification.Status) + if err != nil { + log.Fatalln(err) + } + n.notifications[index].Status = status + n.redrawControls() + case 'd', 'D': + n.app.API.DeleteStatus(notification.Status) + } + } +} diff --git a/linkoverlay.go b/linkoverlay.go index d860b02..b059e98 100644 --- a/linkoverlay.go +++ b/linkoverlay.go @@ -14,6 +14,7 @@ func NewLinkOverlay(app *App) *LinkOverlay { } l.TextBottom.SetBackgroundColor(app.Config.Style.Background) + l.TextBottom.SetDynamicColors(true) l.List.SetBackgroundColor(app.Config.Style.Background) l.List.SetMainTextColor(app.Config.Style.Text) l.List.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground) @@ -21,8 +22,7 @@ func NewLinkOverlay(app *App) *LinkOverlay { l.List.ShowSecondaryText(false) l.List.SetHighlightFullLine(true) l.Flex.SetDrawFunc(app.Config.ClearContent) - - l.TextBottom.SetText("[O]pen") + l.TextBottom.SetText(ColorKey(app.Config.Style, "", "O", "pen")) return l } diff --git a/main.go b/main.go index a0d6add..c7266a6 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "strings" @@ -39,7 +40,6 @@ func main() { HaveAccount: false, Config: &config, } - app.UI = NewUI(app) if exists { accounts, err := GetAccounts(path) @@ -50,12 +50,21 @@ func main() { a := accounts.Accounts[0] client, err := a.Login() if err == nil { - app.API.Client = client + app.API.SetClient(client) app.HaveAccount = true + + me, err := app.API.Client.GetAccountCurrentUser(context.Background()) + if err != nil { + log.Fatalln(err) + } + app.Me = me } } } + app.UI = NewUI(app) + app.UI.Init() + if !app.HaveAccount { app.UI.SetFocus(AuthOverlayFocus) } else { @@ -169,53 +178,6 @@ func main() { return event } - if app.UI.Focus == LeftPaneFocus { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'v', 'V': - app.UI.SetFocus(RightPaneFocus) - return nil - case 'k', 'K': - app.UI.TootList.Prev() - return nil - case 'j', 'J': - app.UI.TootList.Next() - return nil - case 'q', 'Q': - if app.UI.TootList.Focus == TootListThreadFocus { - app.UI.TootList.GoBack() - } else { - app.UI.Root.Stop() - } - return nil - } - } else { - switch event.Key() { - case tcell.KeyUp: - app.UI.TootList.Prev() - return nil - case tcell.KeyDown: - app.UI.TootList.Next() - return nil - case tcell.KeyEsc: - app.UI.TootList.GoBack() - return nil - case tcell.KeyCtrlC: - app.UI.Root.Stop() - return nil - } - } - } - - if app.UI.Focus == RightPaneFocus { - if event.Key() != tcell.KeyRune { - switch event.Key() { - case tcell.KeyEsc: - app.UI.SetFocus(LeftPaneFocus) - } - } - } - if app.UI.Focus == LeftPaneFocus || app.UI.Focus == RightPaneFocus { if event.Key() == tcell.KeyRune { switch event.Rune() { @@ -223,28 +185,9 @@ func main() { app.UI.CmdBar.Input.SetText(":") app.UI.SetFocus(CmdBarFocus) return nil - case 't', 'T': - app.UI.ShowThread() - case 's', 'S': - app.UI.ShowSensetive() - case 'c', 'C': - app.UI.NewToot() - case 'o', 'O': - app.UI.ShowLinks() - case 'r', 'R': - app.UI.Reply() - case 'm', 'M': - app.UI.OpenMedia() - case 'f', 'F': - //TODO UPDATE TOOT IN LIST - app.UI.FavoriteEvent() - case 'b': - //TODO UPDATE TOOT IN LIST - app.UI.BoostEvent() - case 'd': - app.UI.DeleteStatus() } } + return app.UI.StatusView.Input(event) } return event @@ -254,7 +197,7 @@ func main() { app.UI.MediaOverlay.InputField.HandleChanges, ) - words := strings.Split(":q,:quit,:timeline", ",") + words := strings.Split(":q,:quit,:timeline,:tl", ",") app.UI.CmdBar.Input.SetAutocompleteFunc(func(currentText string) (entries []string) { if currentText == "" { return @@ -281,25 +224,29 @@ func main() { fallthrough case ":quit": app.UI.Root.Stop() - case ":timeline": + case ":timeline", ":tl": if len(parts) < 2 { break } switch parts[1] { - case "local": - app.UI.SetTimeline(TimelineLocal) + case "local", "l": + app.UI.StatusView.AddFeed(NewTimeline(app, TimelineLocal)) + app.UI.SetFocus(LeftPaneFocus) + app.UI.CmdBar.ClearInput() + case "federated", "f": + app.UI.StatusView.AddFeed(NewTimeline(app, TimelineFederated)) app.UI.SetFocus(LeftPaneFocus) app.UI.CmdBar.ClearInput() - case "federated": - app.UI.SetTimeline(TimelineFederated) + case "direct", "d": + app.UI.StatusView.AddFeed(NewTimeline(app, TimelineDirect)) app.UI.SetFocus(LeftPaneFocus) app.UI.CmdBar.ClearInput() - case "direct": - app.UI.SetTimeline(TimelineDirect) + case "home", "h": + app.UI.StatusView.AddFeed(NewTimeline(app, TimelineHome)) app.UI.SetFocus(LeftPaneFocus) app.UI.CmdBar.ClearInput() - case "home": - app.UI.SetTimeline(TimelineHome) + case "notifications", "n": + app.UI.StatusView.AddFeed(NewNoticifations(app)) app.UI.SetFocus(LeftPaneFocus) app.UI.CmdBar.ClearInput() } diff --git a/media.go b/media.go index 317935b..aa934c0 100644 --- a/media.go +++ b/media.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "github.com/rivo/tview" ) @@ -38,6 +39,7 @@ func NewMediaOverlay(app *App) *MediaView { m.TextBottom.SetBackgroundColor(app.Config.Style.Background) m.TextBottom.SetTextColor(app.Config.Style.Text) + m.TextBottom.SetDynamicColors(true) m.InputField.View.SetBackgroundColor(app.Config.Style.Background) m.InputField.View.SetFieldBackgroundColor(app.Config.Style.Background) @@ -75,7 +77,11 @@ func (m *MediaView) AddFile(f string) { func (m *MediaView) Draw() { m.TextTop.SetText("List of attached files:") - m.TextBottom.SetText("[A]dd file [D]elete file [Esc] Done") + var items []string + items = append(items, ColorKey(m.app.Config.Style, "", "A", "dd file")) + items = append(items, ColorKey(m.app.Config.Style, "", "D", "elete file")) + items = append(items, ColorKey(m.app.Config.Style, "", "Esc", " Done")) + m.TextBottom.SetText(strings.Join(items, " ")) } func (m *MediaView) SetFocus(f MediaFocus) { diff --git a/messagebox.go b/messagebox.go index 465277d..f263112 100644 --- a/messagebox.go +++ b/messagebox.go @@ -121,8 +121,13 @@ func (m *MessageBox) Post() { } func (m *MessageBox) Draw() { - info := "\n[P]ost [E]dit text, [T]oggle CW, [C]ontent warning text [M]edia attachment" - status := tview.Escape(info) + var items []string + items = append(items, ColorKey(m.app.Config.Style, "", "P", "ost")) + items = append(items, ColorKey(m.app.Config.Style, "", "E", "dit")) + items = append(items, ColorKey(m.app.Config.Style, "", "T", "oggle CW")) + items = append(items, ColorKey(m.app.Config.Style, "", "C", "ontent warning text")) + items = append(items, ColorKey(m.app.Config.Style, "", "M", "edia attachment")) + status := strings.Join(items, " ") m.Controls.SetText(status) var outputHead string diff --git a/paneview.go b/paneview.go new file mode 100644 index 0000000..9b1bf9e --- /dev/null +++ b/paneview.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +type PaneView interface { + GetLeftView() tview.Primitive + GetRightView() tview.Primitive + Input(event *tcell.EventKey) *tcell.EventKey +} diff --git a/statusview.go b/statusview.go new file mode 100644 index 0000000..d6a1ea8 --- /dev/null +++ b/statusview.go @@ -0,0 +1,268 @@ +package main + +import ( + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +func NewStatusView(app *App, tl TimelineType) *StatusView { + t := &StatusView{ + app: app, + timelineType: tl, + list: tview.NewList(), + text: tview.NewTextView(), + controls: tview.NewTextView(), + focus: LeftPaneFocus, + loadingNewer: false, + loadingOlder: false, + } + t.flex = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(t.text, 0, 9, false). + AddItem(t.controls, 1, 0, false) + + t.list.SetBackgroundColor(app.Config.Style.Background) + t.list.SetSelectedTextColor(app.Config.Style.ListSelectedText) + t.list.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground) + t.list.ShowSecondaryText(false) + t.list.SetHighlightFullLine(true) + + t.list.SetChangedFunc(func(i int, _ string, _ string, _ rune) { + if app.HaveAccount { + t.showToot(i) + } + }) + + t.text.SetWordWrap(true).SetDynamicColors(true) + t.text.SetBackgroundColor(app.Config.Style.Background) + t.text.SetTextColor(app.Config.Style.Text) + t.controls.SetDynamicColors(true) + t.controls.SetBackgroundColor(app.Config.Style.Background) + return t +} + +type StatusView struct { + app *App + timelineType TimelineType + list *tview.List + flex *tview.Flex + text *tview.TextView + controls *tview.TextView + feeds []Feed + focus FocusAt + loadingNewer bool + loadingOlder bool +} + +func (t *StatusView) AddFeed(f Feed) { + t.feeds = append(t.feeds, f) + f.DrawList() + t.list.SetCurrentItem(f.GetSavedIndex()) + f.DrawToot() +} + +func (t *StatusView) RemoveLatestFeed() { + t.feeds = t.feeds[:len(t.feeds)-1] + feed := t.feeds[len(t.feeds)-1] + feed.DrawList() + t.list.SetCurrentItem(feed.GetSavedIndex()) + feed.DrawToot() +} + +func (t *StatusView) GetLeftView() tview.Primitive { + if len(t.feeds) > 0 { + feed := t.feeds[len(t.feeds)-1] + feed.DrawList() + feed.DrawToot() + } + return t.list +} + +func (t *StatusView) GetRightView() tview.Primitive { + return t.flex +} + +func (t *StatusView) GetTextWidth() int { + _, _, width, _ := t.text.GetInnerRect() + return width +} + +func (t *StatusView) GetCurrentItem() int { + return t.list.GetCurrentItem() +} + +func (t *StatusView) ScrollToBeginning() { + t.text.ScrollToBeginning() +} + +func (t *StatusView) inputBoth(event *tcell.EventKey) { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'q', 'Q': + if len(t.feeds) > 1 { + t.RemoveLatestFeed() + } else { + t.app.UI.Root.Stop() + } + } + } else { + switch event.Key() { + case tcell.KeyCtrlC: + t.app.UI.Root.Stop() + } + } + if len(t.feeds) > 0 { + feed := t.feeds[len(t.feeds)-1] + feed.Input(event) + } +} + +func (t *StatusView) inputLeft(event *tcell.EventKey) { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'v', 'V': + t.app.UI.FocusAt(t.text, "--VIEW--") + t.focus = RightPaneFocus + case 'k', 'K': + t.prev() + case 'j', 'J': + t.next() + } + } else { + switch event.Key() { + case tcell.KeyUp: + t.prev() + case tcell.KeyDown: + t.next() + case tcell.KeyEsc: + if len(t.feeds) > 1 { + t.RemoveLatestFeed() + } + } + } +} + +func (t *StatusView) inputRight(event *tcell.EventKey) { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + + } + } else { + switch event.Key() { + case tcell.KeyEsc: + t.app.UI.FocusAt(nil, "--LIST--") + t.focus = LeftPaneFocus + } + } +} + +func (t *StatusView) Input(event *tcell.EventKey) *tcell.EventKey { + t.inputBoth(event) + if len(t.feeds) == 0 { + return event + } + + if t.focus == LeftPaneFocus { + t.inputLeft(event) + return nil + } else { + t.inputRight(event) + } + + return event +} + +func (t *StatusView) SetList(items <-chan string) { + t.list.Clear() + for s := range items { + t.list.AddItem(s, "", 0, nil) + } +} +func (t *StatusView) SetText(text string) { + t.text.SetText(text) +} + +func (t *StatusView) SetControls(text string) { + t.controls.SetText(text) +} + +func (t *StatusView) showToot(index int) { +} + +func (t *StatusView) showTootOptions(index int, showSensitive bool) { +} + +func (t *StatusView) prev() { + current := t.list.GetCurrentItem() + if current-1 >= 0 { + current-- + } + t.list.SetCurrentItem(current) + t.feeds[len(t.feeds)-1].DrawToot() + + if current < 4 { + t.loadNewer() + } +} + +func (t *StatusView) next() { + t.list.SetCurrentItem( + t.list.GetCurrentItem() + 1, + ) + t.feeds[len(t.feeds)-1].DrawToot() + + count := t.list.GetItemCount() + current := t.list.GetCurrentItem() + if (count - current + 1) < 5 { + t.loadOlder() + } +} + +func (t *StatusView) loadNewer() { + if t.loadingNewer { + return + } + t.loadingNewer = true + feedIndex := len(t.feeds) - 1 + go func() { + new := t.feeds[feedIndex].LoadNewer() + if new == 0 { + return + } + if feedIndex != len(t.feeds)-1 { + return + } + t.app.UI.Root.QueueUpdateDraw(func() { + index := t.list.GetCurrentItem() + t.feeds[feedIndex].DrawList() + newIndex := index + new + if index == 0 && t.feeds[feedIndex].FeedType() == UserFeed { + newIndex = 0 + } + t.list.SetCurrentItem(newIndex) + t.loadingNewer = false + }) + }() +} + +func (t *StatusView) loadOlder() { + if t.loadingOlder { + return + } + t.loadingOlder = true + feedIndex := len(t.feeds) - 1 + go func() { + new := t.feeds[feedIndex].LoadOlder() + if new == 0 { + return + } + if feedIndex != len(t.feeds)-1 { + return + } + t.app.UI.Root.QueueUpdateDraw(func() { + index := t.list.GetCurrentItem() + t.feeds[feedIndex].DrawList() + t.list.SetCurrentItem(index) + t.loadingOlder = false + }) + }() +} diff --git a/tootlist.go b/tootlist.go deleted file mode 100644 index ac16b5e..0000000 --- a/tootlist.go +++ /dev/null @@ -1,257 +0,0 @@ -package main - -import ( - "fmt" - "log" - "time" - - "github.com/mattn/go-mastodon" - "github.com/rivo/tview" -) - -type TootListFocus int - -const ( - TootListFeedFocus TootListFocus = iota - TootListThreadFocus -) - -type TootList struct { - app *App - Index int - Statuses []*mastodon.Status - Thread []*mastodon.Status - ThreadIndex int - List *tview.List - Focus TootListFocus - loadingFeedOld bool - loadingFeedNew bool -} - -func NewTootList(app *App) *TootList { - t := &TootList{ - app: app, - Index: 0, - Focus: TootListFeedFocus, - List: tview.NewList(), - } - t.List.SetBackgroundColor(app.Config.Style.Background) - t.List.SetSelectedTextColor(app.Config.Style.ListSelectedText) - t.List.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground) - t.List.ShowSecondaryText(false) - t.List.SetHighlightFullLine(true) - - t.List.SetChangedFunc(func(index int, _ string, _ string, _ rune) { - if app.HaveAccount { - app.UI.TootView.ShowToot(index) - } - }) - - return t -} - -func (t *TootList) GetStatuses() []*mastodon.Status { - if t.Focus == TootListThreadFocus { - return t.GetThread() - } - return t.GetFeed() -} - -func (t *TootList) GetStatus(index int) (*mastodon.Status, error) { - if t.Focus == TootListThreadFocus { - return t.GetThreadStatus(index) - } - return t.GetFeedStatus(index) -} - -func (t *TootList) SetFeedStatuses(s []*mastodon.Status) { - t.Statuses = s - t.Draw() -} - -func (t *TootList) PrependFeedStatuses(s []*mastodon.Status) { - t.Statuses = append(s, t.Statuses...) - t.SetFeedIndex( - t.GetFeedIndex() + len(s), - ) - t.List.SetCurrentItem(t.GetFeedIndex()) -} - -func (t *TootList) AppendFeedStatuses(s []*mastodon.Status) { - t.Statuses = append(t.Statuses, s...) -} - -func (t *TootList) GetFeed() []*mastodon.Status { - return t.Statuses -} - -func (t *TootList) GetFeedStatus(index int) (*mastodon.Status, error) { - statuses := t.GetFeed() - if index < len(statuses) { - return statuses[index], nil - } - return nil, fmt.Errorf("no status with that index") -} - -func (t *TootList) GetIndex() int { - if t.Focus == TootListThreadFocus { - return t.GetThreadIndex() - } - return t.GetFeedIndex() -} - -func (t *TootList) SetIndex(index int) { - switch t.Focus { - case TootListFeedFocus: - t.SetFeedIndex(index) - case TootListThreadFocus: - t.SetThreadIndex(index) - } -} - -func (t *TootList) GetFeedIndex() int { - return t.Index -} - -func (t *TootList) SetFeedIndex(index int) { - t.Index = index -} - -func (t *TootList) GetThreadIndex() int { - return t.ThreadIndex -} - -func (t *TootList) SetThreadIndex(index int) { - t.ThreadIndex = index -} - -func (t *TootList) Prev() { - index := t.GetIndex() - statuses := t.GetStatuses() - - if index-1 > -1 { - index-- - } - - if index < 5 && t.Focus == TootListFeedFocus { - go func() { - if t.loadingFeedNew { - return - } - t.loadingFeedNew = true - t.app.UI.LoadNewer(statuses[0]) - t.app.UI.Root.QueueUpdateDraw(func() { - t.Draw() - t.loadingFeedNew = false - }) - }() - } - t.SetIndex(index) - t.List.SetCurrentItem(index) -} - -func (t *TootList) Next() { - index := t.GetIndex() - statuses := t.GetStatuses() - - if index+1 < len(statuses) { - index++ - } - - if (len(statuses)-index) < 10 && t.Focus == TootListFeedFocus { - go func() { - if t.loadingFeedOld || len(statuses) == 0 { - return - } - t.loadingFeedOld = true - t.app.UI.LoadOlder(statuses[len(statuses)-1]) - t.app.UI.Root.QueueUpdateDraw(func() { - t.Draw() - t.loadingFeedOld = false - }) - }() - } - t.SetIndex(index) - t.List.SetCurrentItem(index) -} - -func (t *TootList) Draw() { - t.List.Clear() - - var statuses []*mastodon.Status - var index int - - switch t.Focus { - case TootListFeedFocus: - statuses = t.GetFeed() - index = t.GetFeedIndex() - case TootListThreadFocus: - statuses = t.GetThread() - index = t.GetThreadIndex() - } - if len(statuses) == 0 { - return - } - - today := time.Now() - ty, tm, td := today.Date() - currRow := 0 - for _, s := range statuses { - sLocal := s.CreatedAt.Local() - sy, sm, sd := sLocal.Date() - format := "2006-01-02 15:04" - if ty == sy && tm == sm && td == sd { - format = "15:04" - } - content := fmt.Sprintf("%s %s", sLocal.Format(format), s.Account.Acct) - t.List.InsertItem(currRow, content, "", 0, nil) - currRow++ - } - t.List.SetCurrentItem(index) - t.app.UI.TootView.ShowToot(index) -} - -func (t *TootList) SetThread(s []*mastodon.Status, index int) { - t.Thread = s - t.SetThreadIndex(index) -} - -func (t *TootList) GetThread() []*mastodon.Status { - return t.Thread -} - -func (t *TootList) GetThreadStatus(index int) (*mastodon.Status, error) { - statuses := t.GetThread() - if index < len(statuses) { - return statuses[index], nil - } - return nil, fmt.Errorf("no status with that index") -} - -func (t *TootList) FocusFeed() { - t.Focus = TootListFeedFocus -} - -func (t *TootList) FocusThread() { - t.Focus = TootListThreadFocus -} - -func (t *TootList) GoBack() { - t.Focus = TootListFeedFocus - t.Draw() -} - -func (t *TootList) Reply() { - status, err := t.GetStatus(t.GetIndex()) - if err != nil { - log.Fatalln(err) - } - if status.Reblog != nil { - status = status.Reblog - } - - users := []string{"@" + status.Account.Acct} - for _, m := range status.Mentions { - users = append(users, "@"+m.Acct) - } -} diff --git a/tootview.go b/tootview.go deleted file mode 100644 index c28563c..0000000 --- a/tootview.go +++ /dev/null @@ -1,167 +0,0 @@ -package main - -import ( - "fmt" - "log" - "strings" - - "github.com/rivo/tview" -) - -func NewTootView(app *App) *TootView { - t := &TootView{ - app: app, - Index: 0, - Text: tview.NewTextView(), - Controls: tview.NewTextView(), - } - - t.Text.SetWordWrap(true).SetDynamicColors(true) - t.Text.SetBackgroundColor(app.Config.Style.Background) - t.Text.SetTextColor(app.Config.Style.Text) - t.Controls.SetDynamicColors(true) - t.Controls.SetBackgroundColor(app.Config.Style.Background) - - return t -} - -type TootView struct { - app *App - Index int - Text *tview.TextView - Controls *tview.TextView -} - -func (s *TootView) ShowToot(index int) { - s.ShowTootOptions(index, false) -} - -func (s *TootView) ShowTootOptions(index int, showSensitive bool) { - status, err := s.app.UI.TootList.GetStatus(index) - if err != nil { - log.Fatalln(err) - } - - var line string - _, _, width, _ := s.Text.GetInnerRect() - for i := 0; i < width; i++ { - line += "-" - } - line += "\n" - - shouldDisplay := !status.Sensitive || showSensitive - - var stripped string - var urls []URL - var u []URL - if status.Sensitive && !showSensitive { - stripped, u = cleanTootHTML(status.SpoilerText) - urls = append(urls, u...) - stripped += "\n" + line - stripped += "Press [s] to show hidden text" - - } else { - stripped, u = cleanTootHTML(status.Content) - urls = append(urls, u...) - - if status.Sensitive { - sens, u := cleanTootHTML(status.SpoilerText) - urls = append(urls, u...) - stripped = sens + "\n\n" + stripped - } - } - s.app.UI.LinkOverlay.SetURLs(urls) - - subtleColor := fmt.Sprintf("[#%x]", s.app.Config.Style.Subtle.Hex()) - special1 := fmt.Sprintf("[#%x]", s.app.Config.Style.TextSpecial1.Hex()) - special2 := fmt.Sprintf("[#%x]", s.app.Config.Style.TextSpecial2.Hex()) - var head string - if status.Reblog != nil { - if status.Account.DisplayName != "" { - head += fmt.Sprintf(subtleColor+"%s (%s)\n", status.Account.DisplayName, status.Account.Acct) - } else { - head += fmt.Sprintf(subtleColor+"%s\n", status.Account.Acct) - } - head += subtleColor + "Boosted\n" - head += subtleColor + line - status = status.Reblog - } - - if status.Account.DisplayName != "" { - head += fmt.Sprintf(special2+"%s\n", status.Account.DisplayName) - } - head += fmt.Sprintf(special1+"%s\n\n", status.Account.Acct) - output := head - content := tview.Escape(stripped) - if content != "" { - output += content + "\n\n" - } - - var poll string - if status.Poll != nil { - poll += subtleColor + "Poll\n" - poll += subtleColor + line - poll += fmt.Sprintf("Number of votes: %d\n\n", status.Poll.VotesCount) - votes := float64(status.Poll.VotesCount) - for _, o := range status.Poll.Options { - res := 0.0 - if votes != 0 { - res = float64(o.VotesCount) / votes * 100 - } - poll += fmt.Sprintf("%s - %.2f%% (%d)\n", tview.Escape(o.Title), res, o.VotesCount) - } - poll += "\n" - } - - var media string - for _, att := range status.MediaAttachments { - media += subtleColor + line - media += fmt.Sprintf(subtleColor+"Attached %s\n", att.Type) - media += fmt.Sprintf("%s\n", att.URL) - } - - var card string - if status.Card != nil { - card += subtleColor + "Card type: " + status.Card.Type + "\n" - card += subtleColor + line - if status.Card.Title != "" { - card += status.Card.Title + "\n\n" - } - desc := strings.TrimSpace(status.Card.Description) - if desc != "" { - card += desc + "\n\n" - } - card += status.Card.URL - } - - if shouldDisplay { - output += poll + media + card - } - - s.Text.SetText(output) - s.Text.ScrollToBeginning() - var info []string - if status.Favourited == true { - info = append(info, "Un[F]avorite") - } else { - info = append(info, "[F]avorite") - } - if status.Reblogged == true { - info = append(info, "Un[B]oost") - } else { - info = append(info, "[B]oost") - } - info = append(info, "[T]hread", "[R]eply", "[V]iew") - if len(status.MediaAttachments) > 0 { - info = append(info, "[M]edia") - } - if len(urls) > 0 { - info = append(info, "[O]pen") - } - - if status.Account.ID == s.app.Me.ID { - info = append(info, "[D]elete") - } - - s.Controls.SetText(tview.Escape(strings.Join(info, " "))) -} diff --git a/ui.go b/ui.go index 2d1f764..1e72222 100644 --- a/ui.go +++ b/ui.go @@ -24,42 +24,44 @@ const ( func NewUI(app *App) *UI { ui := &UI{ - app: app, - Root: tview.NewApplication(), - Top: NewTop(app), - Pages: tview.NewPages(), - Timeline: TimelineHome, - TootList: NewTootList(app), - TootView: NewTootView(app), - CmdBar: NewCmdBar(app), - StatusBar: NewStatusBar(app), - MessageBox: NewMessageBox(app), - LinkOverlay: NewLinkOverlay(app), - AuthOverlay: NewAuthOverlay(app), - MediaOverlay: NewMediaOverlay(app), + app: app, + Root: tview.NewApplication(), } - verticalLine := tview.NewBox().SetBackgroundColor(app.Config.Style.Background) + return ui +} + +func (ui *UI) Init() { + ui.Top = NewTop(ui.app) + ui.Pages = tview.NewPages() + ui.Timeline = TimelineHome + ui.CmdBar = NewCmdBar(ui.app) + ui.StatusBar = NewStatusBar(ui.app) + ui.MessageBox = NewMessageBox(ui.app) + ui.LinkOverlay = NewLinkOverlay(ui.app) + ui.AuthOverlay = NewAuthOverlay(ui.app) + ui.MediaOverlay = NewMediaOverlay(ui.app) + ui.StatusView = NewStatusView(ui.app, ui.Timeline) + + verticalLine := tview.NewBox().SetBackgroundColor(ui.app.Config.Style.Background) verticalLine.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { for cy := y; cy < y+height; cy++ { - screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, tcell.StyleDefault.Foreground(app.Config.Style.Subtle)) + screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, tcell.StyleDefault.Foreground(ui.app.Config.Style.Subtle)) } return 0, 0, 0, 0 }) - ui.Pages.SetBackgroundColor(app.Config.Style.Background) + ui.Pages.SetBackgroundColor(ui.app.Config.Style.Background) ui.Pages.AddPage("main", tview.NewFlex(). AddItem(tview.NewFlex().SetDirection(tview.FlexRow). AddItem(ui.Top.Text, 1, 0, false). AddItem(tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(ui.TootList.List, 0, 2, false). + AddItem(ui.StatusView.GetLeftView(), 0, 2, false). AddItem(verticalLine, 1, 0, false). - AddItem(tview.NewBox().SetBackgroundColor(app.Config.Style.Background), 1, 0, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(ui.TootView.Text, 0, 9, false). - AddItem(ui.TootView.Controls, 1, 0, false), + AddItem(tview.NewBox().SetBackgroundColor(ui.app.Config.Style.Background), 1, 0, false). + AddItem(ui.StatusView.GetRightView(), 0, 4, false), 0, 1, false). AddItem(ui.StatusBar.Text, 1, 1, false). @@ -111,8 +113,6 @@ func NewUI(app *App) *UI { screen.Clear() return false }) - - return ui } type UI struct { @@ -120,8 +120,6 @@ type UI struct { Root *tview.Application Focus FocusAt Top *Top - TootView *TootView - TootList *TootList MessageBox *MessageBox CmdBar *CmdBar StatusBar *StatusBar @@ -130,6 +128,18 @@ type UI struct { AuthOverlay *AuthOverlay MediaOverlay *MediaView Timeline TimelineType + StatusView *StatusView +} + +func (ui *UI) FocusAt(p tview.Primitive, s string) { + if p == nil { + ui.Root.SetFocus(ui.Pages) + } else { + ui.Root.SetFocus(p) + } + if s != "" { + ui.StatusBar.SetText(s) + } } func (ui *UI) SetFocus(f FocusAt) { @@ -137,7 +147,6 @@ func (ui *UI) SetFocus(f FocusAt) { switch f { case RightPaneFocus: ui.StatusBar.SetText("-- VIEW --") - ui.Root.SetFocus(ui.TootView.Text) case CmdBarFocus: ui.StatusBar.SetText("-- CMD --") ui.Root.SetFocus(ui.CmdBar.Input) @@ -166,40 +175,6 @@ func (ui *UI) SetFocus(f FocusAt) { } } -func (ui *UI) SetTimeline(tl TimelineType) { - ui.Timeline = tl - statuses, err := ui.app.API.GetStatuses(tl) - if err != nil { - log.Fatalln(err) - } - ui.TootList.SetFeedStatuses(statuses) -} - -func (ui *UI) ShowThread() { - status, err := ui.TootList.GetStatus(ui.TootList.GetIndex()) - if err != nil { - log.Fatalln(err) - } - - if status.Reblog != nil { - status = status.Reblog - } - - thread, index, err := ui.app.API.GetThread(status) - if err != nil { - log.Fatalln(err) - } - - ui.TootList.SetThread(thread, index) - ui.TootList.FocusThread() - ui.SetFocus(LeftPaneFocus) - ui.TootList.Draw() -} - -func (ui *UI) ShowSensetive() { - ui.TootView.ShowTootOptions(ui.TootList.GetIndex(), true) -} - func (ui *UI) NewToot() { ui.Root.SetFocus(ui.MessageBox.View) ui.MediaOverlay.Reset() @@ -208,11 +183,7 @@ func (ui *UI) NewToot() { ui.SetFocus(MessageFocus) } -func (ui *UI) Reply() { - status, err := ui.TootList.GetStatus(ui.TootList.GetIndex()) - if err != nil { - log.Fatalln(err) - } +func (ui *UI) Reply(status *mastodon.Status) { if status.Reblog != nil { status = status.Reblog } @@ -226,11 +197,7 @@ func (ui *UI) ShowLinks() { ui.SetFocus(LinkOverlayFocus) } -func (ui *UI) OpenMedia() { - status, err := ui.TootList.GetStatus(ui.TootList.GetIndex()) - if err != nil { - log.Fatalln(err) - } +func (ui *UI) OpenMedia(status *mastodon.Status) { if status.Reblog != nil { status = status.Reblog } @@ -267,64 +234,9 @@ func (ui *UI) LoggedIn() { log.Fatalln(err) } ui.app.Me = me - - ui.SetTimeline(ui.Timeline) -} - -func (ui *UI) LoadNewer(status *mastodon.Status) int { - statuses, _, err := ui.app.API.GetStatusesNewer(ui.Timeline, status) - if err != nil { - log.Fatalln(err) - } - ui.TootList.PrependFeedStatuses(statuses) - return len(statuses) -} - -func (ui *UI) LoadOlder(status *mastodon.Status) int { - statuses, _, err := ui.app.API.GetStatusesOlder(ui.Timeline, status) - if err != nil { - log.Fatalln(err) - } - ui.TootList.AppendFeedStatuses(statuses) - return len(statuses) -} - -func (ui *UI) FavoriteEvent() { - status, err := ui.TootList.GetStatus(ui.TootList.GetIndex()) - if err != nil { - log.Fatalln(err) - } - if status.Favourited == true { - err = ui.app.API.Unfavorite(status) - } else { - err = ui.app.API.Favorite(status) - } -} - -func (ui *UI) BoostEvent() { - status, err := ui.TootList.GetStatus(ui.TootList.GetIndex()) - if err != nil { - log.Fatalln(err) - } - if status.Reblogged == true { - err = ui.app.API.Unboost(status) - } else { - err = ui.app.API.Boost(status) - } - if err != nil { - log.Fatalln(err) - } -} - -func (ui *UI) DeleteStatus() { - status, err := ui.TootList.GetStatus(ui.TootList.GetIndex()) - if err != nil { - log.Fatalln(err) - } - err = ui.app.API.DeleteStatus(status) - if err != nil { - log.Fatalln(err) - } + ui.StatusView.AddFeed( + NewTimeline(ui.app, TimelineHome), + ) } func (conf *Config) ClearContent(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { diff --git a/util.go b/util.go index 3160b87..508807a 100644 --- a/util.go +++ b/util.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io" "io/ioutil" "log" @@ -11,6 +12,7 @@ import ( "regexp" "strings" + "github.com/mattn/go-mastodon" "github.com/microcosm-cc/bluemonday" "github.com/rivo/tview" "golang.org/x/net/html" @@ -54,6 +56,7 @@ func cleanTootHTML(content string) (string, []URL) { urls := getURLs(stripped) stripped = bluemonday.NewPolicy().AllowElements("p", "br").Sanitize(content) stripped = strings.ReplaceAll(stripped, "
", "\n") + stripped = strings.ReplaceAll(stripped, "
", "\n") stripped = strings.ReplaceAll(stripped, "

", "") stripped = strings.ReplaceAll(stripped, "

", "\n\n") stripped = strings.TrimSpace(stripped) @@ -211,3 +214,23 @@ func FindFiles(s string) []string { } return files } + +func ColorKey(style StyleConfig, pre, key, end string) string { + color := fmt.Sprintf("[#%x]", style.TextSpecial2.Hex()) + normal := fmt.Sprintf("[#%x]", style.Text.Hex()) + key = tview.Escape("[" + key + "]") + text := fmt.Sprintf("%s%s%s%s%s", pre, color, key, normal, end) + return text +} + +func FormatUsername(a mastodon.Account) string { + if a.DisplayName != "" { + return fmt.Sprintf("%s (%s)", a.DisplayName, a.Acct) + } + return a.Acct +} + +func SublteText(style StyleConfig, text string) string { + subtle := fmt.Sprintf("[#%x]", style.Subtle.Hex()) + return fmt.Sprintf("%s%s", subtle, text) +}