From 88646ae0e51982674b74e73379af721c0947e92a Mon Sep 17 00:00:00 2001 From: Rasmus Lindroth Date: Thu, 5 May 2022 21:32:04 +0200 Subject: [PATCH] 1.0.0 (#130) New major version --- .gitignore | 1 + LICENSE | 2 +- account.go | 128 -- api.go | 510 ------ api/feed.go | 271 ++++ api/item.go | 216 +++ api/poll.go | 11 + api/status.go | 92 ++ api/stream.go | 227 +++ api/types.go | 19 + api/user.go | 73 + app.go | 54 - auth/add.go | 113 ++ auth/file.go | 59 + auth/load.go | 23 + auth/types.go | 13 + authoverlay.go | 114 -- cmdbar.go | 191 --- config.example.ini | 68 +- config.go => config/config.go | 147 +- conftext.go => config/default_config.go | 70 +- help.tmpl => config/help.tmpl | 0 config/keys.go | 32 + config/load.go | 32 + {themes => config/themes}/default.ini | 0 {themes => config/themes}/nord.ini | 0 config/themes/papercolor-light.ini | 21 + toot.tmpl => config/toot.tmpl | 0 user.tmpl => config/user.tmpl | 0 xrdb.go => config/xrtb.go | 2 +- controls.go | 15 - feed.go | 1929 ----------------------- feed/feed.go | 930 +++++++++++ go.mod | 40 +- go.sum | 654 +------- helpoverlay.go | 64 - linkoverlay.go | 211 --- main.go | 354 +---- media.go | 272 ---- messagebox.go | 296 ---- notifications.go | 93 -- paneview.go | 12 - status.go | 22 - statusview.go | 705 --------- top.go | 18 - ui.go | 548 ------- ui/bottom.go | 22 + ui/cliview.go | 58 + ui/cmdbar.go | 262 +++ ui/composeview.go | 577 +++++++ ui/feed.go | 531 +++++++ ui/helpers.go | 24 + ui/helpview.go | 49 + ui/input.go | 675 ++++++++ ui/item.go | 128 ++ ui/item_list.go | 20 + ui/item_notification.go | 42 + ui/item_status.go | 226 +++ ui/item_user.go | 129 ++ ui/linkview.go | 161 ++ ui/list_helper.go | 36 + ui/loginview.go | 47 + ui/mainview.go | 122 ++ ui/media.go | 191 +++ ui/modalview.go | 56 + ui/open.go | 95 ++ ui/shared.go | 13 + ui/statusbar.go | 62 + ui/styled_elements.go | 96 ++ ui/timeline.go | 228 +++ ui/top.go | 31 + ui/tutview.go | 152 ++ ui/view.go | 167 ++ ui/voteview.go | 147 ++ userselectoverlay.go | 87 - util.go | 503 ------ util/terminal_input.go | 15 + util/util.go | 159 ++ util/xdg.go | 7 + visibilityoverlay.go | 116 -- voteoverlay.go | 173 -- 81 files changed, 6889 insertions(+), 7170 deletions(-) delete mode 100644 account.go delete mode 100644 api.go create mode 100644 api/feed.go create mode 100644 api/item.go create mode 100644 api/poll.go create mode 100644 api/status.go create mode 100644 api/stream.go create mode 100644 api/types.go create mode 100644 api/user.go delete mode 100644 app.go create mode 100644 auth/add.go create mode 100644 auth/file.go create mode 100644 auth/load.go create mode 100644 auth/types.go delete mode 100644 authoverlay.go delete mode 100644 cmdbar.go rename config.go => config/config.go (88%) rename conftext.go => config/default_config.go (90%) rename help.tmpl => config/help.tmpl (100%) create mode 100644 config/keys.go create mode 100644 config/load.go rename {themes => config/themes}/default.ini (100%) rename {themes => config/themes}/nord.ini (100%) create mode 100644 config/themes/papercolor-light.ini rename toot.tmpl => config/toot.tmpl (100%) rename user.tmpl => config/user.tmpl (100%) rename xrdb.go => config/xrtb.go (98%) delete mode 100644 controls.go delete mode 100644 feed.go create mode 100644 feed/feed.go delete mode 100644 helpoverlay.go delete mode 100644 linkoverlay.go delete mode 100644 media.go delete mode 100644 messagebox.go delete mode 100644 notifications.go delete mode 100644 paneview.go delete mode 100644 status.go delete mode 100644 statusview.go delete mode 100644 top.go delete mode 100644 ui.go create mode 100644 ui/bottom.go create mode 100644 ui/cliview.go create mode 100644 ui/cmdbar.go create mode 100644 ui/composeview.go create mode 100644 ui/feed.go create mode 100644 ui/helpers.go create mode 100644 ui/helpview.go create mode 100644 ui/input.go create mode 100644 ui/item.go create mode 100644 ui/item_list.go create mode 100644 ui/item_notification.go create mode 100644 ui/item_status.go create mode 100644 ui/item_user.go create mode 100644 ui/linkview.go create mode 100644 ui/list_helper.go create mode 100644 ui/loginview.go create mode 100644 ui/mainview.go create mode 100644 ui/media.go create mode 100644 ui/modalview.go create mode 100644 ui/open.go create mode 100644 ui/shared.go create mode 100644 ui/statusbar.go create mode 100644 ui/styled_elements.go create mode 100644 ui/timeline.go create mode 100644 ui/top.go create mode 100644 ui/tutview.go create mode 100644 ui/view.go create mode 100644 ui/voteview.go delete mode 100644 userselectoverlay.go delete mode 100644 util.go create mode 100644 util/terminal_input.go create mode 100644 util/util.go create mode 100644 util/xdg.go delete mode 100644 visibilityoverlay.go delete mode 100644 voteoverlay.go diff --git a/.gitignore b/.gitignore index f96a7d6..bf46d43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode Makefile bin/ +TODO.md diff --git a/LICENSE b/LICENSE index efe36cc..5b017b5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Rasmus Lindroth +Copyright (c) 2020-2022 Rasmus Lindroth Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/account.go b/account.go deleted file mode 100644 index ebdff3b..0000000 --- a/account.go +++ /dev/null @@ -1,128 +0,0 @@ -package main - -import ( - "context" - "io/ioutil" - "log" - "os" - "strings" - - "github.com/mattn/go-mastodon" - "github.com/pelletier/go-toml/v2" -) - -func GetSecret(s string) string { - var err error - if strings.HasPrefix(s, "!CMD!") { - s, err = CmdToString(s) - if err != nil { - log.Fatalf("Couldn't run CMD on auth-file. Error; %v", err) - } - } - return s -} - -func GetAccounts(filepath string) (*AccountData, error) { - f, err := os.Open(filepath) - if err != nil { - return &AccountData{}, err - } - defer f.Close() - data, err := ioutil.ReadAll(f) - if err != nil { - return &AccountData{}, err - } - accounts := &AccountData{} - err = toml.Unmarshal(data, accounts) - - for i, acc := range accounts.Accounts { - accounts.Accounts[i].ClientID = GetSecret(acc.ClientID) - accounts.Accounts[i].ClientSecret = GetSecret(acc.ClientSecret) - accounts.Accounts[i].AccessToken = GetSecret(acc.AccessToken) - } - - return accounts, err -} - -type AccountData struct { - Accounts []Account `yaml:"accounts"` -} - -func (ad *AccountData) Save(filepath string) error { - marshaled, err := toml.Marshal(ad) - if err != nil { - return err - } - f, err := os.OpenFile(filepath, os.O_RDWR|os.O_CREATE, 0600) - if err != nil { - return err - } - defer f.Close() - - _, err = f.Write(marshaled) - return err -} - -type Account struct { - Name string - Server string - ClientID string - ClientSecret string - AccessToken string -} - -func (a *Account) Login() (*mastodon.Client, error) { - config := &mastodon.Config{ - Server: a.Server, - ClientID: a.ClientID, - ClientSecret: a.ClientSecret, - AccessToken: a.AccessToken, - } - client := mastodon.NewClient(config) - _, err := client.GetAccountCurrentUser(context.Background()) - - return client, err -} - -func TryInstance(server string) (*mastodon.Instance, error) { - client := mastodon.NewClient(&mastodon.Config{ - Server: server, - }) - inst, err := client.GetInstance(context.Background()) - return inst, err -} - -func Authorize(server string) (AccountRegister, error) { - app, err := mastodon.RegisterApp(context.Background(), &mastodon.AppConfig{ - Server: server, - ClientName: "tut-tui", - Scopes: "read write follow", - RedirectURIs: "urn:ietf:wg:oauth:2.0:oob", - Website: "https://github.com/RasmusLindroth/tut", - }) - if err != nil { - return AccountRegister{}, err - } - - acc := AccountRegister{ - Account: Account{ - Server: server, - ClientID: app.ClientID, - ClientSecret: app.ClientSecret, - }, - AuthURI: app.AuthURI, - } - - return acc, nil -} - -func AuthorizationCode(acc AccountRegister, code string) (*mastodon.Client, error) { - client := mastodon.NewClient(&mastodon.Config{ - Server: acc.Account.Server, - ClientID: acc.Account.ClientID, - ClientSecret: acc.Account.ClientSecret, - }) - - err := client.AuthenticateToken(context.Background(), code, "urn:ietf:wg:oauth:2.0:oob") - return client, err -} diff --git a/api.go b/api.go deleted file mode 100644 index 671079a..0000000 --- a/api.go +++ /dev/null @@ -1,510 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - - "github.com/mattn/go-mastodon" -) - -type TimelineType uint - -const ( - TimelineHome TimelineType = iota - TimelineDirect - TimelineLocal - TimelineFederated - TimelineBookmarked - TimelineFavorited - TimelineList -) - -type UserListType uint - -const ( - UserListSearch UserListType = iota - UserListBoosts - UserListFavorites - UserListFollowers - UserListFollowing - UserListBlocking - UserListMuting -) - -type API struct { - Client *mastodon.Client -} - -type AccountRegister struct { - Account - AuthURI string -} - -func (api *API) SetClient(c *mastodon.Client) { - api.Client = c -} - -func (api *API) getStatuses(tl TimelineType, listInfo *ListInfo, pg *mastodon.Pagination) ([]*mastodon.Status, mastodon.ID, mastodon.ID, error) { - var err error - var statuses []*mastodon.Status - - if pg == nil { - pg = &mastodon.Pagination{} - } - - switch tl { - case TimelineHome: - statuses, err = api.Client.GetTimelineHome(context.Background(), pg) - case TimelineDirect: - var conv []*mastodon.Conversation - conv, err = api.Client.GetConversations(context.Background(), pg) - var cStatuses []*mastodon.Status - for _, c := range conv { - cStatuses = append(cStatuses, c.LastStatus) - } - statuses = cStatuses - case TimelineLocal: - statuses, err = api.Client.GetTimelinePublic(context.Background(), true, pg) - case TimelineFederated: - statuses, err = api.Client.GetTimelinePublic(context.Background(), false, pg) - case TimelineBookmarked: - statuses, err = api.Client.GetBookmarks(context.Background(), pg) - case TimelineFavorited: - statuses, err = api.Client.GetFavourites(context.Background(), pg) - case TimelineList: - if listInfo == nil { - err = errors.New("no list id") - return statuses, "", "", err - } - statuses, err = api.Client.GetTimelineList(context.Background(), listInfo.id, pg) - default: - err = errors.New("no timeline selected") - } - - if err != nil { - return statuses, "", "", err - } - - min := mastodon.ID("") - max := mastodon.ID("") - if pg != nil { - min = pg.MinID - max = pg.MaxID - if min == "" { - min = "-1" - } - if max == "" { - max = "-1" - } - } - return statuses, min, max, err -} - -func (api *API) GetStatuses(t *TimelineFeed) ([]*mastodon.Status, error) { - statuses, pgmin, pgmax, err := api.getStatuses(t.timelineType, t.listInfo, nil) - switch t.timelineType { - case TimelineBookmarked, TimelineFavorited: - if err == nil { - t.linkPrev = pgmin - t.linkNext = pgmax - } - } - return statuses, err -} - -func (api *API) GetStatusesOlder(t *TimelineFeed) ([]*mastodon.Status, error) { - if len(t.statuses) == 0 { - return api.GetStatuses(t) - } - - switch t.timelineType { - case TimelineBookmarked, TimelineFavorited: - if t.linkNext == "-1" { - return []*mastodon.Status{}, nil - } - pg := &mastodon.Pagination{ - MaxID: t.linkNext, - } - statuses, _, max, err := api.getStatuses(t.timelineType, t.listInfo, pg) - if err == nil { - t.linkNext = max - } - return statuses, err - default: - pg := &mastodon.Pagination{ - MaxID: t.statuses[len(t.statuses)-1].ID, - } - statuses, _, _, err := api.getStatuses(t.timelineType, t.listInfo, pg) - return statuses, err - } -} - -func (api *API) GetStatusesNewer(t *TimelineFeed) ([]*mastodon.Status, error) { - if len(t.statuses) == 0 { - return api.GetStatuses(t) - } - - switch t.timelineType { - case TimelineBookmarked, TimelineFavorited: - if t.linkPrev == "-1" { - return []*mastodon.Status{}, nil - } - pg := &mastodon.Pagination{ - MinID: mastodon.ID(t.linkPrev), - } - statuses, min, _, err := api.getStatuses(t.timelineType, t.listInfo, pg) - if err == nil { - t.linkPrev = min - } - return statuses, err - default: - pg := &mastodon.Pagination{ - MinID: t.statuses[0].ID, - } - statuses, _, _, err := api.getStatuses(t.timelineType, t.listInfo, pg) - return statuses, err - } -} - -func (api *API) GetTags(tag string) ([]*mastodon.Status, error) { - return api.Client.GetTimelineHashtag(context.Background(), tag, false, nil) -} - -func (api *API) GetTagsOlder(tag string, s *mastodon.Status) ([]*mastodon.Status, error) { - pg := &mastodon.Pagination{ - MaxID: s.ID, - } - - return api.Client.GetTimelineHashtag(context.Background(), tag, false, pg) -} - -func (api *API) GetTagsNewer(tag string, s *mastodon.Status) ([]*mastodon.Status, error) { - pg := &mastodon.Pagination{ - MinID: s.ID, - } - - return api.Client.GetTimelineHashtag(context.Background(), tag, false, pg) -} - -func (api *API) GetThread(s *mastodon.Status) ([]*mastodon.Status, int, error) { - cont, err := api.Client.GetStatusContext(context.Background(), s.ID) - if err != nil { - return nil, 0, err - } - thread := cont.Ancestors - thread = append(thread, s) - thread = append(thread, cont.Descendants...) - return thread, len(cont.Ancestors), nil -} - -func (api *API) GetUserStatuses(u mastodon.Account) ([]*mastodon.Status, error) { - return api.Client.GetAccountStatuses(context.Background(), u.ID, nil) -} - -func (api *API) GetUserStatusesOlder(u mastodon.Account, s *mastodon.Status) ([]*mastodon.Status, error) { - pg := &mastodon.Pagination{ - MaxID: s.ID, - } - - return api.Client.GetAccountStatuses(context.Background(), u.ID, pg) -} - -func (api *API) GetUserStatusesNewer(u mastodon.Account, s *mastodon.Status) ([]*mastodon.Status, error) { - pg := &mastodon.Pagination{ - MinID: s.ID, - } - - return api.Client.GetAccountStatuses(context.Background(), u.ID, pg) -} - -func (api *API) getNotifications(pg *mastodon.Pagination) ([]*Notification, error) { - var notifications []*Notification - - mnot, err := api.Client.GetNotifications(context.Background(), pg) - if err != nil { - return []*Notification{}, err - } - - for _, np := range mnot { - var r *mastodon.Relationship - if np.Type == "follow" { - r, err = api.GetRelation(&np.Account) - if err != nil { - return notifications, err - } - } - notifications = append(notifications, &Notification{N: np, R: r}) - } - - return notifications, err -} - -func (api *API) GetNotifications() ([]*Notification, error) { - return api.getNotifications(nil) -} - -func (api *API) GetNotificationsOlder(n *Notification) ([]*Notification, error) { - pg := &mastodon.Pagination{ - MaxID: n.N.ID, - } - return api.getNotifications(pg) -} - -func (api *API) GetNotificationsNewer(n *Notification) ([]*Notification, error) { - pg := &mastodon.Pagination{ - MinID: n.N.ID, - } - return api.getNotifications(pg) -} - -type UserData struct { - User *mastodon.Account - Relationship *mastodon.Relationship -} - -func (api *API) GetUsers(s string) ([]*UserData, error) { - var ud []*UserData - users, err := api.Client.AccountsSearch(context.Background(), s, 10) - if err != nil { - return nil, err - } - for _, u := range users { - r, err := api.GetRelation(u) - if err != nil { - return ud, err - } - ud = append(ud, &UserData{User: u, Relationship: r}) - } - - return ud, nil -} - -func (api *API) GetRelation(u *mastodon.Account) (*mastodon.Relationship, error) { - return api.UserRelation(*u) -} - -func (api *API) getUserList(t UserListType, id string, pg *mastodon.Pagination) ([]*UserData, error) { - - var ud []*UserData - var users []*mastodon.Account - var err error - var pgMin = mastodon.ID("") - var pgMax = mastodon.ID("") - if pg != nil { - pgMin = pg.MinID - pgMax = pg.MinID - } - - switch t { - case UserListSearch: - users, err = api.Client.AccountsSearch(context.Background(), id, 10) - case UserListBoosts: - users, err = api.Client.GetRebloggedBy(context.Background(), mastodon.ID(id), pg) - case UserListFavorites: - users, err = api.Client.GetFavouritedBy(context.Background(), mastodon.ID(id), pg) - case UserListFollowers: - users, err = api.Client.GetAccountFollowers(context.Background(), mastodon.ID(id), pg) - case UserListFollowing: - users, err = api.Client.GetAccountFollowing(context.Background(), mastodon.ID(id), pg) - case UserListBlocking: - users, err = api.Client.GetBlocks(context.Background(), pg) - case UserListMuting: - users, err = api.Client.GetMutes(context.Background(), pg) - } - - if err != nil { - return ud, err - } - - if pg != nil && len(users) > 0 { - if pgMin != "" && users[0].ID == pgMin { - return ud, nil - } else if pgMax != "" && users[len(users)-1].ID == pgMax { - return ud, nil - } - } - - for _, u := range users { - r, err := api.UserRelation(*u) - if err != nil { - return ud, err - } - ud = append(ud, &UserData{User: u, Relationship: r}) - } - return ud, nil -} - -func (api *API) GetUserList(t UserListType, id string) ([]*UserData, error) { - return api.getUserList(t, id, nil) -} - -func (api *API) GetUserListOlder(t UserListType, id string, user *mastodon.Account) ([]*UserData, error) { - if t == UserListSearch { - return []*UserData{}, nil - } - pg := &mastodon.Pagination{ - MaxID: user.ID, - } - return api.getUserList(t, id, pg) -} - -func (api *API) GetUserListNewer(t UserListType, id string, user *mastodon.Account) ([]*UserData, error) { - if t == UserListSearch { - return []*UserData{}, nil - } - pg := &mastodon.Pagination{ - MinID: user.ID, - } - return api.getUserList(t, id, pg) -} - -func (api *API) GetUserByID(id mastodon.ID) (*mastodon.Account, error) { - a, err := api.Client.GetAccount(context.Background(), id) - return a, err -} - -func (api *API) GetLists() ([]*mastodon.List, error) { - return api.Client.GetLists(context.Background()) -} - -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) BookmarkToogle(s *mastodon.Status) (*mastodon.Status, error) { - if s == nil { - return nil, fmt.Errorf("no status") - } - - if s.Bookmarked == true { - return api.Unbookmark(s) - } - return api.Bookmark(s) -} - -func (api *API) Bookmark(s *mastodon.Status) (*mastodon.Status, error) { - status, err := api.Client.Bookmark(context.Background(), s.ID) - return status, err -} - -func (api *API) Unbookmark(s *mastodon.Status) (*mastodon.Status, error) { - status, err := api.Client.Unbookmark(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) -} - -func (api *API) Vote(poll *mastodon.Poll, choices ...int) (*mastodon.Poll, error) { - return api.Client.PollVote(context.Background(), poll.ID, choices...) -} diff --git a/api/feed.go b/api/feed.go new file mode 100644 index 0000000..417ad03 --- /dev/null +++ b/api/feed.go @@ -0,0 +1,271 @@ +package api + +import ( + "context" + + "github.com/RasmusLindroth/go-mastodon" +) + +type TimelineType uint + +func (ac *AccountClient) GetTimeline(pg *mastodon.Pagination) ([]Item, error) { + var items []Item + statuses, err := ac.Client.GetTimelineHome(context.Background(), pg) + if err != nil { + return items, err + } + for _, s := range statuses { + items = append(items, NewStatusItem(s)) + } + return items, nil +} + +func (ac *AccountClient) GetTimelineFederated(pg *mastodon.Pagination) ([]Item, error) { + var items []Item + statuses, err := ac.Client.GetTimelinePublic(context.Background(), false, pg) + if err != nil { + return items, err + } + for _, s := range statuses { + items = append(items, NewStatusItem(s)) + } + return items, nil +} + +func (ac *AccountClient) GetTimelineLocal(pg *mastodon.Pagination) ([]Item, error) { + var items []Item + statuses, err := ac.Client.GetTimelinePublic(context.Background(), true, pg) + if err != nil { + return items, err + } + for _, s := range statuses { + items = append(items, NewStatusItem(s)) + } + return items, nil +} + +func (ac *AccountClient) GetNotifications(pg *mastodon.Pagination) ([]Item, error) { + var items []Item + notifications, err := ac.Client.GetNotifications(context.Background(), pg) + if err != nil { + return items, err + } + ids := []string{} + for _, n := range notifications { + ids = append(ids, string(n.Account.ID)) + } + rel, err := ac.Client.GetAccountRelationships(context.Background(), ids) + if err != nil { + return items, err + } + for _, n := range notifications { + for _, r := range rel { + if n.Account.ID == r.ID { + items = append(items, NewNotificationItem(n, &User{ + Data: &n.Account, + Relation: r, + })) + break + } + } + } + return items, nil +} + +func (ac *AccountClient) GetThread(status *mastodon.Status) ([]Item, int, error) { + var items []Item + statuses, err := ac.Client.GetStatusContext(context.Background(), status.ID) + if err != nil { + return items, 0, err + } + for _, s := range statuses.Ancestors { + items = append(items, NewStatusItem(s)) + } + items = append(items, NewStatusItem(status)) + for _, s := range statuses.Descendants { + items = append(items, NewStatusItem(s)) + } + return items, len(statuses.Ancestors), nil +} + +func (ac *AccountClient) GetFavorites(pg *mastodon.Pagination) ([]Item, error) { + var items []Item + statuses, err := ac.Client.GetFavourites(context.Background(), pg) + if err != nil { + return items, err + } + for _, s := range statuses { + items = append(items, NewStatusItem(s)) + } + return items, nil +} + +func (ac *AccountClient) GetBookmarks(pg *mastodon.Pagination) ([]Item, error) { + var items []Item + statuses, err := ac.Client.GetBookmarks(context.Background(), pg) + if err != nil { + return items, err + } + for _, s := range statuses { + items = append(items, NewStatusItem(s)) + } + return items, nil +} + +func (ac *AccountClient) GetConversations(pg *mastodon.Pagination) ([]Item, error) { + var items []Item + conversations, err := ac.Client.GetConversations(context.Background(), pg) + if err != nil { + return items, err + } + for _, c := range conversations { + items = append(items, NewStatusItem(c.LastStatus)) + } + return items, nil +} + +func (ac *AccountClient) GetUsers(search string) ([]Item, error) { + var items []Item + users, err := ac.Client.AccountsSearch(context.Background(), search, 10) + if err != nil { + return items, err + } + ids := []string{} + for _, u := range users { + ids = append(ids, string(u.ID)) + } + rel, err := ac.Client.GetAccountRelationships(context.Background(), ids) + if err != nil { + return items, err + } + for _, u := range users { + for _, r := range rel { + if u.ID == r.ID { + items = append(items, NewUserItem(&User{ + Data: u, + Relation: r, + }, false)) + break + } + } + } + return items, nil +} + +func (ac *AccountClient) GetBoostsStatus(pg *mastodon.Pagination, id mastodon.ID) ([]Item, error) { + fn := func() ([]*mastodon.Account, error) { + return ac.Client.GetRebloggedBy(context.Background(), id, pg) + } + return ac.getUserSimilar(fn) +} + +func (ac *AccountClient) GetFavoritesStatus(pg *mastodon.Pagination, id mastodon.ID) ([]Item, error) { + fn := func() ([]*mastodon.Account, error) { + return ac.Client.GetFavouritedBy(context.Background(), id, pg) + } + return ac.getUserSimilar(fn) +} + +func (ac *AccountClient) GetFollowers(pg *mastodon.Pagination, id mastodon.ID) ([]Item, error) { + fn := func() ([]*mastodon.Account, error) { + return ac.Client.GetAccountFollowers(context.Background(), id, pg) + } + return ac.getUserSimilar(fn) +} + +func (ac *AccountClient) GetFollowing(pg *mastodon.Pagination, id mastodon.ID) ([]Item, error) { + fn := func() ([]*mastodon.Account, error) { + return ac.Client.GetAccountFollowing(context.Background(), id, pg) + } + return ac.getUserSimilar(fn) +} + +func (ac *AccountClient) GetBlocking(pg *mastodon.Pagination) ([]Item, error) { + fn := func() ([]*mastodon.Account, error) { + return ac.Client.GetBlocks(context.Background(), pg) + } + return ac.getUserSimilar(fn) +} + +func (ac *AccountClient) GetMuting(pg *mastodon.Pagination) ([]Item, error) { + fn := func() ([]*mastodon.Account, error) { + return ac.Client.GetMutes(context.Background(), pg) + } + return ac.getUserSimilar(fn) +} + +func (ac *AccountClient) getUserSimilar(fn func() ([]*mastodon.Account, error)) ([]Item, error) { + var items []Item + users, err := fn() + if err != nil { + return items, err + } + ids := []string{} + for _, u := range users { + ids = append(ids, string(u.ID)) + } + rel, err := ac.Client.GetAccountRelationships(context.Background(), ids) + if err != nil { + return items, err + } + for _, u := range users { + for _, r := range rel { + if u.ID == r.ID { + items = append(items, NewUserItem(&User{ + Data: u, + Relation: r, + }, false)) + break + } + } + } + return items, nil +} + +func (ac *AccountClient) GetUser(pg *mastodon.Pagination, id mastodon.ID) ([]Item, error) { + var items []Item + statuses, err := ac.Client.GetAccountStatuses(context.Background(), id, pg) + if err != nil { + return items, err + } + for _, s := range statuses { + items = append(items, NewStatusItem(s)) + } + return items, nil +} + +func (ac *AccountClient) GetLists() ([]Item, error) { + var items []Item + lists, err := ac.Client.GetLists(context.Background()) + if err != nil { + return items, err + } + for _, l := range lists { + items = append(items, NewListsItem(l)) + } + return items, nil +} + +func (ac *AccountClient) GetListStatuses(pg *mastodon.Pagination, id mastodon.ID) ([]Item, error) { + var items []Item + statuses, err := ac.Client.GetTimelineList(context.Background(), id, pg) + if err != nil { + return items, err + } + for _, s := range statuses { + items = append(items, NewStatusItem(s)) + } + return items, nil +} + +func (ac *AccountClient) GetTag(pg *mastodon.Pagination, search string) ([]Item, error) { + var items []Item + statuses, err := ac.Client.GetTimelineHashtag(context.Background(), search, false, pg) + if err != nil { + return items, err + } + for _, s := range statuses { + items = append(items, NewStatusItem(s)) + } + return items, nil +} diff --git a/api/item.go b/api/item.go new file mode 100644 index 0000000..127bd18 --- /dev/null +++ b/api/item.go @@ -0,0 +1,216 @@ +package api + +import ( + "sync" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/util" +) + +var id uint = 0 +var idMux sync.Mutex + +func newID() uint { + idMux.Lock() + defer idMux.Unlock() + id = id + 1 + return id +} + +type Item interface { + ID() uint + Type() MastodonType + ToggleSpoiler() + ShowSpoiler() bool + Raw() interface{} + URLs() ([]util.URL, []mastodon.Mention, []mastodon.Tag, int) +} + +func NewStatusItem(item *mastodon.Status) Item { + return &StatusItem{id: newID(), item: item, showSpoiler: false} +} + +type StatusItem struct { + id uint + item *mastodon.Status + showSpoiler bool +} + +func (s *StatusItem) ID() uint { + return s.id +} + +func (s *StatusItem) Type() MastodonType { + return StatusType +} + +func (s *StatusItem) ToggleSpoiler() { + s.showSpoiler = !s.showSpoiler +} + +func (s *StatusItem) ShowSpoiler() bool { + return s.showSpoiler +} + +func (s *StatusItem) Raw() interface{} { + return s.item +} + +func (s *StatusItem) URLs() ([]util.URL, []mastodon.Mention, []mastodon.Tag, int) { + status := s.item + if status.Reblog != nil { + status = status.Reblog + } + _, urls := util.CleanHTML(status.Content) + if status.Sensitive { + _, u := util.CleanHTML(status.SpoilerText) + urls = append(urls, u...) + } + + realUrls := []util.URL{} + for _, url := range urls { + isNotMention := true + for _, mention := range status.Mentions { + if mention.URL == url.URL { + isNotMention = false + } + } + if isNotMention { + realUrls = append(realUrls, url) + } + } + + length := len(realUrls) + len(status.Mentions) + len(status.Tags) + return realUrls, status.Mentions, status.Tags, length +} + +func NewUserItem(item *User, profile bool) Item { + return &UserItem{id: newID(), item: item, profile: profile} +} + +type UserItem struct { + id uint + item *User + profile bool +} + +func (u *UserItem) ID() uint { + return u.id +} + +func (u *UserItem) Type() MastodonType { + if u.profile { + return ProfileType + } + return UserType +} + +func (u *UserItem) ToggleSpoiler() { +} + +func (u *UserItem) ShowSpoiler() bool { + return false +} + +func (u *UserItem) Raw() interface{} { + return u.item +} + +func (u *UserItem) URLs() ([]util.URL, []mastodon.Mention, []mastodon.Tag, int) { + user := u.item.Data + var urls []util.URL + user.Note, urls = util.CleanHTML(user.Note) + for _, f := range user.Fields { + _, fu := util.CleanHTML(f.Value) + urls = append(urls, fu...) + } + + return urls, []mastodon.Mention{}, []mastodon.Tag{}, len(urls) +} + +func NewNotificationItem(item *mastodon.Notification, user *User) Item { + n := &NotificationItem{ + id: newID(), + item: item, + showSpoiler: false, + user: NewUserItem(user, false), + status: NewStatusItem(item.Status), + } + + return n +} + +type NotificationItem struct { + id uint + item *mastodon.Notification + showSpoiler bool + status Item + user Item +} + +type NotificationData struct { + Item *mastodon.Notification + Status Item + User Item +} + +func (n *NotificationItem) ID() uint { + return n.id +} + +func (n *NotificationItem) Type() MastodonType { + return NotificationType +} + +func (n *NotificationItem) ToggleSpoiler() { + n.showSpoiler = !n.showSpoiler +} + +func (n *NotificationItem) ShowSpoiler() bool { + return n.showSpoiler +} + +func (n *NotificationItem) Raw() interface{} { + return &NotificationData{ + Item: n.item, + Status: n.status, + User: n.user, + } +} + +func (n *NotificationItem) URLs() ([]util.URL, []mastodon.Mention, []mastodon.Tag, int) { + return nil, nil, nil, 0 +} + +func NewListsItem(item *mastodon.List) Item { + return &ListItem{id: newID(), item: item, showSpoiler: true} +} + +type ListItem struct { + id uint + item *mastodon.List + showSpoiler bool +} + +func (s *ListItem) ID() uint { + return s.id +} + +func (s *ListItem) Type() MastodonType { + return ListsType +} + +func (s *ListItem) ToggleSpoiler() { +} + +func (s *ListItem) ShowSpoiler() bool { + return true +} + +func (s *ListItem) Raw() interface{} { + return s.item +} + +func (s *ListItem) URLs() ([]util.URL, []mastodon.Mention, []mastodon.Tag, int) { + return nil, nil, nil, 0 +} diff --git a/api/poll.go b/api/poll.go new file mode 100644 index 0000000..4e394d5 --- /dev/null +++ b/api/poll.go @@ -0,0 +1,11 @@ +package api + +import ( + "context" + + "github.com/RasmusLindroth/go-mastodon" +) + +func (ac *AccountClient) Vote(poll *mastodon.Poll, choices ...int) (*mastodon.Poll, error) { + return ac.Client.PollVote(context.Background(), poll.ID, choices...) +} diff --git a/api/status.go b/api/status.go new file mode 100644 index 0000000..282e732 --- /dev/null +++ b/api/status.go @@ -0,0 +1,92 @@ +package api + +import ( + "context" + "fmt" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/util" +) + +type statusToggleFunc func(s *mastodon.Status) (*mastodon.Status, error) + +func toggleHelper(s *mastodon.Status, comp bool, on, off statusToggleFunc) (*mastodon.Status, error) { + if s == nil { + return nil, fmt.Errorf("no status") + } + so := s + reblogged := false + if s.Reblog != nil { + s = s.Reblog + reblogged = true + } + + var ns *mastodon.Status + var err error + if comp { + ns, err = off(s) + } else { + ns, err = on(s) + } + if err != nil { + return nil, err + } + if reblogged { + so.Reblog = ns + return so, nil + } + return ns, nil +} + +func (ac *AccountClient) BoostToggle(s *mastodon.Status) (*mastodon.Status, error) { + return toggleHelper(s, + util.StatusOrReblog(s).Reblogged, + ac.Boost, ac.Unboost, + ) +} + +func (ac *AccountClient) Boost(s *mastodon.Status) (*mastodon.Status, error) { + return ac.Client.Reblog(context.Background(), s.ID) +} + +func (ac *AccountClient) Unboost(s *mastodon.Status) (*mastodon.Status, error) { + return ac.Client.Unreblog(context.Background(), s.ID) +} + +func (ac *AccountClient) FavoriteToogle(s *mastodon.Status) (*mastodon.Status, error) { + return toggleHelper(s, + util.StatusOrReblog(s).Favourited, + ac.Favorite, ac.Unfavorite, + ) +} + +func (ac *AccountClient) Favorite(s *mastodon.Status) (*mastodon.Status, error) { + status, err := ac.Client.Favourite(context.Background(), s.ID) + return status, err +} + +func (ac *AccountClient) Unfavorite(s *mastodon.Status) (*mastodon.Status, error) { + status, err := ac.Client.Unfavourite(context.Background(), s.ID) + return status, err +} + +func (ac *AccountClient) BookmarkToogle(s *mastodon.Status) (*mastodon.Status, error) { + return toggleHelper(s, + util.StatusOrReblog(s).Bookmarked, + ac.Bookmark, ac.Unbookmark, + ) +} + +func (ac *AccountClient) Bookmark(s *mastodon.Status) (*mastodon.Status, error) { + status, err := ac.Client.Bookmark(context.Background(), s.ID) + return status, err +} + +func (ac *AccountClient) Unbookmark(s *mastodon.Status) (*mastodon.Status, error) { + status, err := ac.Client.Unbookmark(context.Background(), s.ID) + return status, err +} + +func (ac *AccountClient) DeleteStatus(s *mastodon.Status) error { + return ac.Client.DeleteStatus(context.Background(), util.StatusOrReblog(s).ID) +} diff --git a/api/stream.go b/api/stream.go new file mode 100644 index 0000000..c8522e9 --- /dev/null +++ b/api/stream.go @@ -0,0 +1,227 @@ +package api + +import ( + "context" + "sync" + + "github.com/RasmusLindroth/go-mastodon" +) + +type MastodonType uint + +const ( + StatusType MastodonType = iota + UserType + ProfileType + NotificationType + ListsType +) + +type StreamType uint + +const ( + HomeStream StreamType = iota + LocalStream + FederatedStream + DirectStream + TagStream + ListStream +) + +type Stream struct { + id string + receivers []*Receiver + incoming chan mastodon.Event + closed bool + mux sync.Mutex +} + +type Receiver struct { + Ch chan mastodon.Event + Closed bool + mux sync.Mutex +} + +func (s *Stream) ID() string { + return s.id +} + +func (s *Stream) AddReceiver() *Receiver { + ch := make(chan mastodon.Event) + rec := &Receiver{ + Ch: ch, + Closed: false, + } + s.receivers = append(s.receivers, rec) + return rec +} + +func (s *Stream) RemoveReceiver(r *Receiver) { + index := -1 + for i, rec := range s.receivers { + if rec.Ch == r.Ch { + index = i + break + } + } + if index == -1 { + return + } + s.receivers[index].mux.Lock() + if !s.receivers[index].Closed { + close(s.receivers[index].Ch) + s.receivers[index].Closed = true + } + s.receivers[index].mux.Unlock() + s.receivers = append(s.receivers[:index], s.receivers[index+1:]...) +} + +func (s *Stream) listen() { + for e := range s.incoming { + switch e.(type) { + case *mastodon.UpdateEvent, *mastodon.NotificationEvent, *mastodon.DeleteEvent, *mastodon.ErrorEvent: + for _, r := range s.receivers { + go func(rec *Receiver) { + rec.mux.Lock() + if rec.Closed { + return + } + rec.mux.Unlock() + rec.Ch <- e + }(r) + } + } + } +} + +func newStream(id string, inc chan mastodon.Event) (*Stream, *Receiver) { + stream := &Stream{ + id: id, + incoming: inc, + } + rec := stream.AddReceiver() + go stream.listen() + return stream, rec +} + +func (ac *AccountClient) NewGenericStream(st StreamType, data string) (rec *Receiver, err error) { + var id string + switch st { + case HomeStream: + id = "HomeStream" + case LocalStream: + id = "LocalStream" + case FederatedStream: + id = "FederatedStream" + case DirectStream: + id = "DirectStream" + case TagStream: + id = "TagStream" + data + case ListStream: + id = "ListStream" + data + default: + panic("invalid StreamType") + } + for _, s := range ac.Streams { + if s.ID() == id { + rec = s.AddReceiver() + return rec, nil + } + } + var ch chan mastodon.Event + switch st { + case HomeStream: + ch, err = ac.Client.StreamingUser(context.Background()) + case LocalStream: + ch, err = ac.Client.StreamingPublic(context.Background(), true) + case FederatedStream: + ch, err = ac.Client.StreamingPublic(context.Background(), false) + case DirectStream: + ch, err = ac.Client.StreamingDirect(context.Background()) + case TagStream: + ch, err = ac.Client.StreamingHashtag(context.Background(), data, false) + case ListStream: + ch, err = ac.Client.StreamingList(context.Background(), mastodon.ID(data)) + default: + panic("invalid StreamType") + } + if err != nil { + return nil, err + } + stream, rec := newStream(id, ch) + ac.Streams[stream.ID()] = stream + return rec, nil +} + +func (ac *AccountClient) NewHomeStream() (*Receiver, error) { + return ac.NewGenericStream(HomeStream, "") +} + +func (ac *AccountClient) NewLocalStream() (*Receiver, error) { + return ac.NewGenericStream(LocalStream, "") +} + +func (ac *AccountClient) NewFederatedStream() (*Receiver, error) { + return ac.NewGenericStream(FederatedStream, "") +} + +func (ac *AccountClient) NewDirectStream() (*Receiver, error) { + return ac.NewGenericStream(DirectStream, "") +} + +func (ac *AccountClient) NewListStream(id mastodon.ID) (*Receiver, error) { + return ac.NewGenericStream(ListStream, string(id)) +} + +func (ac *AccountClient) NewTagStream(tag string) (*Receiver, error) { + return ac.NewGenericStream(TagStream, tag) +} + +func (ac *AccountClient) RemoveGenericReceiver(rec *Receiver, st StreamType, data string) { + var id string + switch st { + case HomeStream: + id = "HomeStream" + case LocalStream: + id = "LocalStream" + case FederatedStream: + id = "FederatedStream" + case TagStream: + id = "TagStream" + data + case ListStream: + id = "ListStream" + data + default: + panic("invalid StreamType") + } + stream, ok := ac.Streams[id] + if !ok { + return + } + stream.RemoveReceiver(rec) + stream.mux.Lock() + if len(stream.receivers) == 0 && !stream.closed { + stream.closed = true + delete(ac.Streams, id) + } + stream.mux.Unlock() +} + +func (ac *AccountClient) RemoveHomeReceiver(rec *Receiver) { + ac.RemoveGenericReceiver(rec, HomeStream, "") +} + +func (ac *AccountClient) RemoveLocalReceiver(rec *Receiver) { + ac.RemoveGenericReceiver(rec, LocalStream, "") +} + +func (ac *AccountClient) RemoveFederatedReceiver(rec *Receiver) { + ac.RemoveGenericReceiver(rec, FederatedStream, "") +} + +func (ac *AccountClient) RemoveListReceiver(rec *Receiver, id mastodon.ID) { + ac.RemoveGenericReceiver(rec, ListStream, string(id)) +} + +func (ac *AccountClient) RemoveTagReceiver(rec *Receiver, tag string) { + ac.RemoveGenericReceiver(rec, TagStream, tag) +} diff --git a/api/types.go b/api/types.go new file mode 100644 index 0000000..2064d18 --- /dev/null +++ b/api/types.go @@ -0,0 +1,19 @@ +package api + +import "github.com/RasmusLindroth/go-mastodon" + +type RequestData struct { + MinID mastodon.ID + MaxID mastodon.ID +} + +type AccountClient struct { + Client *mastodon.Client + Streams map[string]*Stream + Me *mastodon.Account +} + +type User struct { + Data *mastodon.Account + Relation *mastodon.Relationship +} diff --git a/api/user.go b/api/user.go new file mode 100644 index 0000000..7d5400b --- /dev/null +++ b/api/user.go @@ -0,0 +1,73 @@ +package api + +import ( + "context" + "fmt" + + "github.com/RasmusLindroth/go-mastodon" +) + +func (ac *AccountClient) GetUserByID(id mastodon.ID) (Item, error) { + var item Item + acc, err := ac.Client.GetAccount(context.Background(), id) + if err != nil { + return nil, err + } + rel, err := ac.Client.GetAccountRelationships(context.Background(), []string{string(acc.ID)}) + if err != nil { + return nil, err + } + if len(rel) == 0 { + return nil, fmt.Errorf("couldn't find user relationship") + } + item = NewUserItem(&User{ + Data: acc, + Relation: rel[0], + }, false) + return item, nil +} + +func (ac *AccountClient) FollowToggle(u *User) (*mastodon.Relationship, error) { + if u.Relation.Following { + return ac.UnfollowUser(u.Data) + } + return ac.FollowUser(u.Data) +} + +func (ac *AccountClient) FollowUser(u *mastodon.Account) (*mastodon.Relationship, error) { + return ac.Client.AccountFollow(context.Background(), u.ID) +} + +func (ac *AccountClient) UnfollowUser(u *mastodon.Account) (*mastodon.Relationship, error) { + return ac.Client.AccountUnfollow(context.Background(), u.ID) +} + +func (ac *AccountClient) BlockToggle(u *User) (*mastodon.Relationship, error) { + if u.Relation.Blocking { + return ac.UnblockUser(u.Data) + } + return ac.BlockUser(u.Data) +} + +func (ac *AccountClient) BlockUser(u *mastodon.Account) (*mastodon.Relationship, error) { + return ac.Client.AccountBlock(context.Background(), u.ID) +} + +func (ac *AccountClient) UnblockUser(u *mastodon.Account) (*mastodon.Relationship, error) { + return ac.Client.AccountUnblock(context.Background(), u.ID) +} + +func (ac *AccountClient) MuteToggle(u *User) (*mastodon.Relationship, error) { + if u.Relation.Blocking { + return ac.UnmuteUser(u.Data) + } + return ac.MuteUser(u.Data) +} + +func (ac *AccountClient) MuteUser(u *mastodon.Account) (*mastodon.Relationship, error) { + return ac.Client.AccountMute(context.Background(), u.ID) +} + +func (ac *AccountClient) UnmuteUser(u *mastodon.Account) (*mastodon.Relationship, error) { + return ac.Client.AccountUnmute(context.Background(), u.ID) +} diff --git a/app.go b/app.go deleted file mode 100644 index ca94325..0000000 --- a/app.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "context" - "log" - "strings" - - "github.com/mattn/go-mastodon" -) - -type App struct { - UI *UI - Me *mastodon.Account - API *API - Config *Config - FullUsername string - HaveAccount bool - Accounts *AccountData - FileList []string -} - -func (a *App) Login(index int) { - if index >= len(a.Accounts.Accounts) { - log.Fatalln("Tried to login with an account that doesn't exist") - } - acc := a.Accounts.Accounts[index] - client, err := acc.Login() - if err == nil { - a.API.SetClient(client) - a.HaveAccount = true - - me, err := a.API.Client.GetAccountCurrentUser(context.Background()) - if err != nil { - log.Fatalln(err) - } - a.Me = me - if acc.Name == "" { - a.Accounts.Accounts[index].Name = me.Username - - path, _, err := CheckConfig("accounts.toml") - if err != nil { - log.Fatalf("Couldn't open the account file for reading. Error: %v", err) - } - err = a.Accounts.Save(path) - if err != nil { - log.Fatalf("Couldn't update the account file. Error: %v", err) - } - } - - host := strings.TrimPrefix(acc.Server, "https://") - host = strings.TrimPrefix(host, "http://") - a.FullUsername = me.Username + "@" + host - } -} diff --git a/auth/add.go b/auth/add.go new file mode 100644 index 0000000..3191833 --- /dev/null +++ b/auth/add.go @@ -0,0 +1,113 @@ +package auth + +import ( + "bufio" + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/util" +) + +func AddAccount(ad *AccountData) *mastodon.Client { + reader := bufio.NewReader(os.Stdin) + fmt.Println("You will have to log in to your Mastodon instance to be able") + fmt.Println("to use Tut. The default protocol is https:// so you won't need") + fmt.Println("it. E.g. write fosstodon.org and press .") + fmt.Println("--------------------------------------------------------------") + + var server string + for { + var err error + fmt.Print("Instance: ") + server, err = util.ReadLine(reader) + if err != nil { + log.Fatalln(err) + } + if !(strings.HasPrefix(server, "https://") || strings.HasPrefix(server, "http://")) { + server = "https://" + server + } + client := mastodon.NewClient(&mastodon.Config{ + Server: server, + }) + _, err = client.GetInstance(context.Background()) + if err != nil { + fmt.Printf("\nCouldn't connect to instance: %s\nTry again or press ^C.\n", server) + fmt.Println("--------------------------------------------------------------") + } else { + break + } + } + srv, err := mastodon.RegisterApp(context.Background(), &mastodon.AppConfig{ + Server: server, + ClientName: "tut-tui", + Scopes: "read write follow", + RedirectURIs: "urn:ietf:wg:oauth:2.0:oob", + Website: "https://github.com/RasmusLindroth/tut", + }) + if err != nil { + fmt.Printf("Couldn't register the app. Error: %v\n\nExiting...\n", err) + os.Exit(1) + } + + util.OpenURL(srv.AuthURI) + fmt.Println("You need to autorize Tut to use your account. Your browser") + fmt.Println("should've opened. If not you can use the URL below.") + fmt.Printf("\n%s\n\n", srv.AuthURI) + + var client *mastodon.Client + for { + var err error + fmt.Print("Authorization code: ") + code, err := util.ReadLine(reader) + if err != nil { + log.Fatalln(err) + } + client = mastodon.NewClient(&mastodon.Config{ + Server: server, + ClientID: srv.ClientID, + ClientSecret: srv.ClientSecret, + }) + + err = client.AuthenticateToken(context.Background(), code, "urn:ietf:wg:oauth:2.0:oob") + if err != nil { + fmt.Printf("\nError: %v\nTry again or press ^C.\n", err) + fmt.Println("--------------------------------------------------------------") + } else { + break + } + } + me, err := client.GetAccountCurrentUser(context.Background()) + if err != nil { + fmt.Printf("\nCouldnät get user. Error: %v\nExiting...\n", err) + os.Exit(1) + } + acc := Account{ + Name: me.Username, + Server: client.Config.Server, + ClientID: client.Config.ClientID, + ClientSecret: client.Config.ClientSecret, + AccessToken: client.Config.AccessToken, + } + if ad == nil { + ad = &AccountData{ + Accounts: []Account{acc}, + } + } else { + ad.Accounts = append(ad.Accounts, acc) + } + path, _, err := util.CheckConfig("accounts.toml") + if err != nil { + fmt.Printf("Couldn't open the account file for reading. Error: %v\n", err) + os.Exit(1) + } + err = ad.Save(path) + if err != nil { + fmt.Printf("Couldn't update the account file. Error: %v\n", err) + os.Exit(1) + } + return client +} diff --git a/auth/file.go b/auth/file.go new file mode 100644 index 0000000..341afbd --- /dev/null +++ b/auth/file.go @@ -0,0 +1,59 @@ +package auth + +import ( + "io/ioutil" + "log" + "os" + "strings" + + "github.com/RasmusLindroth/tut/util" + "github.com/pelletier/go-toml/v2" +) + +func GetSecret(s string) string { + var err error + if strings.HasPrefix(s, "!CMD!") { + s, err = util.CmdToString(s) + if err != nil { + log.Fatalf("Couldn't run CMD on auth-file. Error; %v", err) + } + } + return s +} + +func GetAccounts(filepath string) (*AccountData, error) { + f, err := os.Open(filepath) + if err != nil { + return &AccountData{}, err + } + defer f.Close() + data, err := ioutil.ReadAll(f) + if err != nil { + return &AccountData{}, err + } + accounts := &AccountData{} + err = toml.Unmarshal(data, accounts) + + for i, acc := range accounts.Accounts { + accounts.Accounts[i].ClientID = GetSecret(acc.ClientID) + accounts.Accounts[i].ClientSecret = GetSecret(acc.ClientSecret) + accounts.Accounts[i].AccessToken = GetSecret(acc.AccessToken) + } + + return accounts, err +} + +func (ad *AccountData) Save(filepath string) error { + marshaled, err := toml.Marshal(ad) + if err != nil { + return err + } + f, err := os.OpenFile(filepath, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return err + } + defer f.Close() + + _, err = f.Write(marshaled) + return err +} diff --git a/auth/load.go b/auth/load.go new file mode 100644 index 0000000..8595117 --- /dev/null +++ b/auth/load.go @@ -0,0 +1,23 @@ +package auth + +import ( + "log" + + "github.com/RasmusLindroth/tut/util" +) + +func StartAuth(newUser bool) *AccountData { + path, exists, err := util.CheckConfig("accounts.toml") + if err != nil { + log.Fatalf("Couldn't open the account file for reading. Error: %v", err) + } + var accs *AccountData + if exists { + accs, err = GetAccounts(path) + } + if err != nil || accs == nil || len(accs.Accounts) == 0 || newUser { + AddAccount(nil) + return StartAuth(false) + } + return accs +} diff --git a/auth/types.go b/auth/types.go new file mode 100644 index 0000000..b20fc4c --- /dev/null +++ b/auth/types.go @@ -0,0 +1,13 @@ +package auth + +type Account struct { + Name string + Server string + ClientID string + ClientSecret string + AccessToken string +} + +type AccountData struct { + Accounts []Account `yaml:"accounts"` +} diff --git a/authoverlay.go b/authoverlay.go deleted file mode 100644 index ed312da..0000000 --- a/authoverlay.go +++ /dev/null @@ -1,114 +0,0 @@ -package main - -import ( - "fmt" - "log" - "strings" - - "github.com/rivo/tview" -) - -type authStep int - -const ( - authNoneStep authStep = iota - authInstanceStep - authCodeStep -) - -func NewAuthOverlay(app *App) *AuthOverlay { - a := &AuthOverlay{ - app: app, - Flex: tview.NewFlex(), - Input: tview.NewInputField(), - Text: tview.NewTextView(), - authStep: authNoneStep, - } - - a.Flex.SetBackgroundColor(app.Config.Style.Background) - a.Input.SetBackgroundColor(app.Config.Style.Background) - a.Input.SetFieldBackgroundColor(app.Config.Style.Background) - a.Input.SetFieldTextColor(app.Config.Style.Text) - a.Text.SetBackgroundColor(app.Config.Style.Background) - a.Text.SetTextColor(app.Config.Style.Text) - a.Flex.SetDrawFunc(app.Config.ClearContent) - a.Draw() - return a -} - -type AuthOverlay struct { - app *App - Flex *tview.Flex - Input *tview.InputField - Text *tview.TextView - authStep authStep - account AccountRegister -} - -func (a *AuthOverlay) GotInput() { - input := strings.TrimSpace(a.Input.GetText()) - switch a.authStep { - case authInstanceStep: - if !(strings.HasPrefix(input, "https://") || strings.HasPrefix(input, "http://")) { - input = "https://" + input - } - - _, err := TryInstance(input) - if err != nil { - a.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't connect to instance %s\n", input)) - return - } - - acc, err := Authorize(input) - if err != nil { - a.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't authorize. Error: %v\n", err)) - return - } - a.account = acc - openURL(a.app.UI.Root, a.app.Config.Media, a.app.Config.OpenPattern, acc.AuthURI) - a.Input.SetText("") - a.authStep = authCodeStep - a.Draw() - case authCodeStep: - client, err := AuthorizationCode(a.account, input) - if err != nil { - a.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't verify the code. Error: %v\n", err)) - a.Input.SetText("") - return - } - path, _, err := CheckConfig("accounts.toml") - if err != nil { - log.Fatalf("Couldn't open the account file for reading. Error: %v", err) - } - ad := Account{ - Server: client.Config.Server, - ClientID: client.Config.ClientID, - ClientSecret: client.Config.ClientSecret, - AccessToken: client.Config.AccessToken, - } - a.app.Accounts.Accounts = append(a.app.Accounts.Accounts, ad) - err = a.app.Accounts.Save(path) - if err != nil { - log.Fatalf("Couldn't save the account file. Error: %v", err) - } - index := len(a.app.Accounts.Accounts) - 1 - a.app.Login(index) - a.app.UI.LoggedIn() - } -} - -func (a *AuthOverlay) Draw() { - switch a.authStep { - case authNoneStep: - a.authStep = authInstanceStep - a.Input.SetText("") - a.Draw() - return - case authInstanceStep: - a.Input.SetLabel("Instance: ") - a.Text.SetText("Enter the url of your instance. Will default to https://\nPress Enter when done") - case authCodeStep: - a.Text.SetText(fmt.Sprintf("The login URL has opened in your browser. If it didn't work open this URL\n%s", a.account.AuthURI)) - a.Input.SetLabel("Authorization code: ") - } -} diff --git a/cmdbar.go b/cmdbar.go deleted file mode 100644 index 2db3b5f..0000000 --- a/cmdbar.go +++ /dev/null @@ -1,191 +0,0 @@ -package main - -import ( - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func NewCmdBar(app *App) *CmdBar { - c := &CmdBar{ - app: app, - Input: tview.NewInputField(), - } - - c.Input.SetBackgroundColor(app.Config.Style.Background) - c.Input.SetFieldBackgroundColor(app.Config.Style.Background) - c.Input.SetFieldTextColor(app.Config.Style.Text) - c.Input.SetDoneFunc(c.DoneFunc) - - return c -} - -type CmdBar struct { - app *App - Input *tview.InputField -} - -func (c *CmdBar) GetInput() string { - return strings.TrimSpace(c.Input.GetText()) -} - -func (c *CmdBar) ShowError(s string) { - c.Input.SetFieldTextColor(c.app.Config.Style.WarningText) - c.Input.SetText(s) -} - -func (c *CmdBar) ShowMsg(s string) { - c.Input.SetFieldTextColor(c.app.Config.Style.StatusBarText) - c.Input.SetText(s) -} - -func (c *CmdBar) ClearInput() { - c.Input.SetFieldTextColor(c.app.Config.Style.Text) - c.Input.SetText("") - c.Input.Autocomplete() -} - -func (c *CmdBar) DoneFunc(key tcell.Key) { - if key == tcell.KeyTAB { - return - } - input := c.GetInput() - parts := strings.Split(input, " ") - if len(parts) == 0 { - return - } - switch parts[0] { - case ":q": - fallthrough - case ":quit": - c.app.UI.Root.Stop() - case ":compose": - c.app.UI.NewToot() - c.app.UI.CmdBar.ClearInput() - case ":blocking": - c.app.UI.StatusView.AddFeed(NewUserListFeed(c.app, UserListBlocking, "")) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case ":bookmarks", ":saved": - c.app.UI.StatusView.AddFeed(NewTimelineFeed(c.app, TimelineBookmarked, nil)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case ":favorited": - c.app.UI.StatusView.AddFeed(NewTimelineFeed(c.app, TimelineFavorited, nil)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case ":boosts": - c.app.UI.CmdBar.ClearInput() - status := c.app.UI.StatusView.GetCurrentStatus() - if status == nil { - return - } - - if status.Reblog != nil { - status = status.Reblog - } - c.app.UI.StatusView.AddFeed(NewUserListFeed(c.app, UserListBoosts, string(status.ID))) - c.app.UI.SetFocus(LeftPaneFocus) - case ":favorites": - c.app.UI.CmdBar.ClearInput() - status := c.app.UI.StatusView.GetCurrentStatus() - if status == nil { - return - } - if status.Reblog != nil { - status = status.Reblog - } - c.app.UI.StatusView.AddFeed(NewUserListFeed(c.app, UserListFavorites, string(status.ID))) - c.app.UI.SetFocus(LeftPaneFocus) - /* - case ":followers": - app.UI.CmdBar.ClearInput() - user := app.UI.StatusView.GetCurrentUser() - if user == nil { - return - } - app.UI.StatusView.AddFeed(NewUserListFeed(app, UserListFollowers, string(user.ID))) - app.UI.SetFocus(LeftPaneFocus) - case ":following": - app.UI.CmdBar.ClearInput() - user := app.UI.StatusView.GetCurrentUser() - if user == nil { - return - } - app.UI.StatusView.AddFeed(NewUserListFeed(app, UserListFollowing, string(user.ID))) - app.UI.SetFocus(LeftPaneFocus) - */ - case ":muting": - c.app.UI.StatusView.AddFeed(NewUserListFeed(c.app, UserListMuting, "")) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case ":profile": - c.app.UI.CmdBar.ClearInput() - if c.app.Me == nil { - return - } - c.app.UI.StatusView.AddFeed(NewUserFeed(c.app, *c.app.Me)) - c.app.UI.SetFocus(LeftPaneFocus) - case ":timeline", ":tl": - if len(parts) < 2 { - break - } - switch parts[1] { - case "local", "l": - c.app.UI.StatusView.AddFeed(NewTimelineFeed(c.app, TimelineLocal, nil)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case "federated", "f": - c.app.UI.StatusView.AddFeed(NewTimelineFeed(c.app, TimelineFederated, nil)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case "direct", "d": - c.app.UI.StatusView.AddFeed(NewTimelineFeed(c.app, TimelineDirect, nil)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case "home", "h": - c.app.UI.StatusView.AddFeed(NewTimelineFeed(c.app, TimelineHome, nil)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case "notifications", "n": - c.app.UI.StatusView.AddFeed(NewNotificationFeed(c.app, false)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case "favorited", "fav": - c.app.UI.StatusView.AddFeed(NewNotificationFeed(c.app, false)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - } - case ":tag": - if len(parts) < 2 { - break - } - tag := strings.TrimSpace(strings.TrimPrefix(parts[1], "#")) - if len(tag) == 0 { - break - } - c.app.UI.StatusView.AddFeed(NewTagFeed(c.app, tag)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case ":user": - if len(parts) < 2 { - break - } - user := strings.TrimSpace(parts[1]) - if len(user) == 0 { - break - } - c.app.UI.StatusView.AddFeed(NewUserListFeed(c.app, UserListSearch, user)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case ":lists": - c.app.UI.StatusView.AddFeed(NewListFeed(c.app)) - c.app.UI.SetFocus(LeftPaneFocus) - c.app.UI.CmdBar.ClearInput() - case ":help", ":h": - c.app.UI.SetFocus(HelpOverlayFocus) - c.app.UI.CmdBar.ClearInput() - } - -} diff --git a/config.example.ini b/config.example.ini index bbfb075..f737cdb 100644 --- a/config.example.ini +++ b/config.example.ini @@ -1,17 +1,10 @@ # Configuration file for tut [general] -# If the program should check for new toots without user interaction. If you -# don't enable this the program will only look for new toots when you reach the -# bottom or top of your feed. With this enabled it will check for new toots -# every x second. +# Shows a confirmation view before actions such as favorite, delete toot, boost +# etc. # default=true -auto-load-newer=true - -# How many seconds between each pulling of new toots if you have enabled -# auto-load-newer -# default=60 -auto-load-seconds=60 +confirmation=true # The date format to be used. See https://godoc.org/time#Time.Format # default=2006-01-02 15:04 @@ -176,24 +169,24 @@ link-terminal=false # as tut. Set cX-terminal to true. The name will show up in the UI, so keep it # short so all five fits. # -# c1-name=img -# c1-use=imv +# c1-name=name +# c1-use=program # c1-terminal=false -# -# c2-name= -# c2-use= +# +# c2-name=name +# c2-use=program # c2-terminal=false -# -# c3-name= -# c3-use= +# +# c3-name=name +# c3-use=program # c3-terminal=false -# -# c4-name= -# c4-use= +# +# c4-name=name +# c4-use=program # c4-terminal=false -# -# c5-name= -# c5-use= +# +# c5-name=name +# c5-use=program # c5-terminal=false [open-pattern] @@ -243,17 +236,6 @@ poll=false posts=false [style] -# You can use some themes that comes bundled with tut -# check out the themes available on the URL below. -# if a theme is named "nord.ini" you just write theme=nord -# -# https://github.com/RasmusLindroth/tut/tree/master/themes - -# If you want to use your own theme set theme to none -# then you can create your own theme below -# default=default -theme=default - # All styles can be represented in their HEX value like #ffffff or with their # name, so in this case white. The only special value is "default" which equals # to transparent, so it will be the same color as your terminal. @@ -262,14 +244,23 @@ theme=default # prefixed with an * first then look for URxvt or XTerm if it can't find any # color prefixed with an asterik. If you don't want tut to guess the prefix you # can set the prefix yourself. If the xrdb color can't be found a preset color -# will be used. -# -# REMEMBER to set theme to none above, otherwise this does nothing +# will be used. You'll have to set theme=none for this to work. # The xrdb prefix used for colors in .Xresources. # default=guess xrdb-prefix=guess +# You can use some themes that comes bundled with tut check out the themes +# available on the URL below. If a theme is named "nord.ini" you just write +# theme=nord +# +# https://github.com/RasmusLindroth/tut/tree/master/themes +# +# If you want to use your own theme set theme to none then you can create your +# own theme below +# default=default +theme=default + # The background color used on most elements. # default=xrdb:background background=xrdb:background @@ -325,4 +316,3 @@ list-selected-background=xrdb:color5 # The text color of selected list items. # default=xrdb:background list-selected-text=xrdb:background - diff --git a/config.go b/config/config.go similarity index 88% rename from config.go rename to config/config.go index 7daa638..b1ee270 100644 --- a/config.go +++ b/config/config.go @@ -1,4 +1,4 @@ -package main +package config import ( "embed" @@ -10,6 +10,7 @@ import ( "strings" "text/template" + "github.com/RasmusLindroth/tut/feed" "github.com/gdamore/tcell/v2" "github.com/gobwas/glob" "gopkg.in/ini.v1" @@ -28,23 +29,22 @@ var helpTemplate string var themesFS embed.FS type Config struct { - General GeneralConfig - Style StyleConfig - Media MediaConfig - OpenPattern OpenPatternConfig - OpenCustom OpenCustomConfig - NotificationConfig NotificationConfig - Templates TemplatesConfig + General General + Style Style + Media Media + OpenPattern OpenPattern + OpenCustom OpenCustom + NotificationConfig Notification + Templates Templates } -type GeneralConfig struct { - AutoLoadNewer bool - AutoLoadSeconds int +type General struct { + Confirmation bool DateTodayFormat string DateFormat string DateRelative int MaxWidth int - StartTimeline TimelineType + StartTimeline feed.FeedType NotificationFeed bool QuoteReply bool CharLimit int @@ -59,7 +59,7 @@ type GeneralConfig struct { RedrawUI bool } -type StyleConfig struct { +type Style struct { Theme string Background tcell.Color @@ -84,7 +84,7 @@ type StyleConfig struct { ListSelectedText tcell.Color } -type MediaConfig struct { +type Media struct { ImageViewer string ImageArgs []string ImageTerminal bool @@ -114,19 +114,19 @@ type Pattern struct { Terminal bool } -type OpenPatternConfig struct { +type OpenPattern struct { Patterns []Pattern } -type OpenCustom struct { +type Custom struct { Index int Name string Program string Args []string Terminal bool } -type OpenCustomConfig struct { - OpenCustoms []OpenCustom +type OpenCustom struct { + OpenCustoms []Custom } type ListPlacement uint @@ -148,7 +148,7 @@ const ( type NotificationType uint const ( - NotificationFollower = iota + NotificationFollower NotificationType = iota NotificationFavorite NotificationMention NotificationBoost @@ -156,7 +156,7 @@ const ( NotificationPost ) -type NotificationConfig struct { +type Notification struct { NotificationFollower bool NotificationFavorite bool NotificationMention bool @@ -165,10 +165,23 @@ type NotificationConfig struct { NotificationPost bool } -type TemplatesConfig struct { - TootTemplate *template.Template - UserTemplate *template.Template - HelpTemplate *template.Template +type Templates struct { + Toot *template.Template + User *template.Template + Help *template.Template +} + +func CreateDefaultConfig(filepath string) error { + f, err := os.Create(filepath) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(conftext) + if err != nil { + return err + } + return nil } func parseColor(input string, def string, xrdb map[string]string) tcell.Color { @@ -187,7 +200,7 @@ func parseColor(input string, def string, xrdb map[string]string) tcell.Color { return tcell.GetColor(input) } -func parseStyle(cfg *ini.File) StyleConfig { +func parseStyle(cfg *ini.File) Style { var xrdbColors map[string]string xrdbMap, _ := GetXrdbColors() prefix := cfg.Section("style").Key("xrdb-prefix").String() @@ -209,10 +222,10 @@ func parseStyle(cfg *ini.File) StyleConfig { } } - style := StyleConfig{} + style := Style{} theme := cfg.Section("style").Key("theme").String() if theme != "none" && theme != "" { - themes, err := GetThemes() + themes, err := getThemes() if err != nil { log.Fatalf("Couldn't load themes. Error: %s\n", err) } @@ -226,7 +239,7 @@ func parseStyle(cfg *ini.File) StyleConfig { if !found { log.Fatalf("Couldn't find theme %s\n", theme) } - tcfg, err := GetTheme(theme) + tcfg, err := getTheme(theme) if err != nil { log.Fatalf("Couldn't load theme. Error: %s\n", err) } @@ -318,16 +331,10 @@ func parseStyle(cfg *ini.File) StyleConfig { return style } -func parseGeneral(cfg *ini.File) GeneralConfig { - general := GeneralConfig{} - - general.AutoLoadNewer = cfg.Section("general").Key("auto-load-newer").MustBool(true) - autoLoadSeconds, err := cfg.Section("general").Key("auto-load-seconds").Int() - if err != nil { - autoLoadSeconds = 60 - } - general.AutoLoadSeconds = autoLoadSeconds +func parseGeneral(cfg *ini.File) General { + general := General{} + general.Confirmation = cfg.Section("general").Key("confirmation").MustBool(true) dateFormat := cfg.Section("general").Key("date-format").String() if dateFormat == "" { dateFormat = "2006-01-02 15:04" @@ -348,14 +355,14 @@ func parseGeneral(cfg *ini.File) GeneralConfig { tl := cfg.Section("general").Key("timeline").In("home", []string{"home", "direct", "local", "federated"}) switch tl { - case "home": - general.StartTimeline = TimelineHome case "direct": - general.StartTimeline = TimelineDirect + general.StartTimeline = feed.Conversations case "local": - general.StartTimeline = TimelineLocal + general.StartTimeline = feed.TimelineLocal case "federated": - general.StartTimeline = TimelineFederated + general.StartTimeline = feed.TimelineFederated + default: + general.StartTimeline = feed.TimelineHome } general.NotificationFeed = cfg.Section("general").Key("notification-feed").MustBool(true) @@ -401,8 +408,8 @@ func parseGeneral(cfg *ini.File) GeneralConfig { return general } -func parseMedia(cfg *ini.File) MediaConfig { - media := MediaConfig{} +func parseMedia(cfg *ini.File) Media { + media := Media{} imageViewerComponents := strings.Fields(cfg.Section("media").Key("image-viewer").String()) if len(imageViewerComponents) == 0 { media.ImageViewer = "xdg-open" @@ -452,8 +459,8 @@ func parseMedia(cfg *ini.File) MediaConfig { return media } -func ParseOpenPattern(cfg *ini.File) OpenPatternConfig { - om := OpenPatternConfig{} +func parseOpenPattern(cfg *ini.File) OpenPattern { + om := OpenPattern{} keys := cfg.Section("open-pattern").KeyStrings() pairs := make(map[string]Pattern) @@ -511,8 +518,8 @@ func ParseOpenPattern(cfg *ini.File) OpenPatternConfig { return om } -func ParseCustom(cfg *ini.File) OpenCustomConfig { - oc := OpenCustomConfig{} +func parseCustom(cfg *ini.File) OpenCustom { + oc := OpenCustom{} for i := 1; i < 6; i++ { name := cfg.Section("open-custom").Key(fmt.Sprintf("c%d-name", i)).MustString("") @@ -522,7 +529,7 @@ func ParseCustom(cfg *ini.File) OpenCustomConfig { continue } comp := strings.Fields(use) - c := OpenCustom{} + c := Custom{} c.Index = i c.Name = name c.Program = comp[0] @@ -533,8 +540,8 @@ func ParseCustom(cfg *ini.File) OpenCustomConfig { return oc } -func ParseNotifications(cfg *ini.File) NotificationConfig { - nc := NotificationConfig{} +func parseNotifications(cfg *ini.File) Notification { + nc := Notification{} nc.NotificationFollower = cfg.Section("desktop-notification").Key("followers").MustBool(false) nc.NotificationFavorite = cfg.Section("desktop-notification").Key("favorite").MustBool(false) nc.NotificationMention = cfg.Section("desktop-notification").Key("mention").MustBool(false) @@ -544,11 +551,11 @@ func ParseNotifications(cfg *ini.File) NotificationConfig { return nc } -func ParseTemplates(cfg *ini.File) TemplatesConfig { +func parseTemplates(cfg *ini.File) Templates { var tootTmpl *template.Template - tootTmplPath, exists, err := CheckConfig("toot.tmpl") + tootTmplPath, exists, err := checkConfig("toot.tmpl") if err != nil { - log.Fatalln( + log.Fatalf( fmt.Sprintf("Couldn't access toot.tmpl. Error: %v", err), ) } @@ -568,9 +575,9 @@ func ParseTemplates(cfg *ini.File) TemplatesConfig { log.Fatalf("Couldn't parse toot.tmpl. Error: %v", err) } var userTmpl *template.Template - userTmplPath, exists, err := CheckConfig("user.tmpl") + userTmplPath, exists, err := checkConfig("user.tmpl") if err != nil { - log.Fatalln( + log.Fatalf( fmt.Sprintf("Couldn't access user.tmpl. Error: %v", err), ) } @@ -597,14 +604,14 @@ func ParseTemplates(cfg *ini.File) TemplatesConfig { if err != nil { log.Fatalf("Couldn't parse help.tmpl. Error: %v", err) } - return TemplatesConfig{ - TootTemplate: tootTmpl, - UserTemplate: userTmpl, - HelpTemplate: helpTmpl, + return Templates{ + Toot: tootTmpl, + User: userTmpl, + Help: helpTmpl, } } -func ParseConfig(filepath string) (Config, error) { +func parseConfig(filepath string) (Config, error) { cfg, err := ini.LoadSources(ini.LoadOptions{ SpaceBeforeInlineComment: true, }, filepath) @@ -615,15 +622,15 @@ func ParseConfig(filepath string) (Config, error) { conf.General = parseGeneral(cfg) conf.Media = parseMedia(cfg) conf.Style = parseStyle(cfg) - conf.OpenPattern = ParseOpenPattern(cfg) - conf.OpenCustom = ParseCustom(cfg) - conf.NotificationConfig = ParseNotifications(cfg) - conf.Templates = ParseTemplates(cfg) + conf.OpenPattern = parseOpenPattern(cfg) + conf.OpenCustom = parseCustom(cfg) + conf.NotificationConfig = parseNotifications(cfg) + conf.Templates = parseTemplates(cfg) return conf, nil } -func CreateConfigDir() error { +func createConfigDir() error { cd, err := os.UserConfigDir() if err != nil { log.Fatalf("couldn't find $HOME. Err %v", err) @@ -632,7 +639,7 @@ func CreateConfigDir() error { return os.MkdirAll(path, os.ModePerm) } -func CheckConfig(filename string) (path string, exists bool, err error) { +func checkConfig(filename string) (path string, exists bool, err error) { cd, err := os.UserConfigDir() if err != nil { log.Fatalf("couldn't find $HOME. Err %v", err) @@ -648,7 +655,7 @@ func CheckConfig(filename string) (path string, exists bool, err error) { return path, true, err } -func CreateDefaultConfig(filepath string) error { +func createDefaultConfig(filepath string) error { f, err := os.Create(filepath) if err != nil { return err @@ -661,7 +668,7 @@ func CreateDefaultConfig(filepath string) error { return nil } -func GetThemes() ([]string, error) { +func getThemes() ([]string, error) { entries, err := themesFS.ReadDir("themes") files := []string{} if err != nil { @@ -677,7 +684,7 @@ func GetThemes() ([]string, error) { return files, nil } -func GetTheme(fname string) (*ini.File, error) { +func getTheme(fname string) (*ini.File, error) { f, err := themesFS.Open(fmt.Sprintf("themes/%s.ini", strings.TrimSpace(fname))) if err != nil { return nil, err diff --git a/conftext.go b/config/default_config.go similarity index 90% rename from conftext.go rename to config/default_config.go index ab206d2..c8dce57 100644 --- a/conftext.go +++ b/config/default_config.go @@ -1,19 +1,12 @@ -package main +package config var conftext = `# Configuration file for tut [general] -# If the program should check for new toots without user interaction. If you -# don't enable this the program will only look for new toots when you reach the -# bottom or top of your feed. With this enabled it will check for new toots -# every x second. +# Shows a confirmation view before actions such as favorite, delete toot, boost +# etc. # default=true -auto-load-newer=true - -# How many seconds between each pulling of new toots if you have enabled -# auto-load-newer -# default=60 -auto-load-seconds=60 +confirmation=true # The date format to be used. See https://godoc.org/time#Time.Format # default=2006-01-02 15:04 @@ -178,24 +171,24 @@ link-terminal=false # as tut. Set cX-terminal to true. The name will show up in the UI, so keep it # short so all five fits. # -# c1-name=img -# c1-use=imv +# c1-name=name +# c1-use=program # c1-terminal=false -# -# c2-name= -# c2-use= +# +# c2-name=name +# c2-use=program # c2-terminal=false -# -# c3-name= -# c3-use= +# +# c3-name=name +# c3-use=program # c3-terminal=false -# -# c4-name= -# c4-use= +# +# c4-name=name +# c4-use=program # c4-terminal=false -# -# c5-name= -# c5-use= +# +# c5-name=name +# c5-use=program # c5-terminal=false [open-pattern] @@ -245,17 +238,6 @@ poll=false posts=false [style] -# You can use some themes that comes bundled with tut -# check out the themes available on the URL below. -# if a theme is named "nord.ini" you just write theme=nord -# -# https://github.com/RasmusLindroth/tut/tree/master/themes - -# If you want to use your own theme set theme to none -# then you can create your own theme below -# default=default -theme=default - # All styles can be represented in their HEX value like #ffffff or with their # name, so in this case white. The only special value is "default" which equals # to transparent, so it will be the same color as your terminal. @@ -264,14 +246,23 @@ theme=default # prefixed with an * first then look for URxvt or XTerm if it can't find any # color prefixed with an asterik. If you don't want tut to guess the prefix you # can set the prefix yourself. If the xrdb color can't be found a preset color -# will be used. -# -# REMEMBER to set theme to none above, otherwise this does nothing +# will be used. You'll have to set theme=none for this to work. # The xrdb prefix used for colors in .Xresources. # default=guess xrdb-prefix=guess +# You can use some themes that comes bundled with tut check out the themes +# available on the URL below. If a theme is named "nord.ini" you just write +# theme=nord +# +# https://github.com/RasmusLindroth/tut/tree/master/themes +# +# If you want to use your own theme set theme to none then you can create your +# own theme below +# default=default +theme=default + # The background color used on most elements. # default=xrdb:background background=xrdb:background @@ -327,5 +318,4 @@ list-selected-background=xrdb:color5 # The text color of selected list items. # default=xrdb:background list-selected-text=xrdb:background - ` diff --git a/help.tmpl b/config/help.tmpl similarity index 100% rename from help.tmpl rename to config/help.tmpl diff --git a/config/keys.go b/config/keys.go new file mode 100644 index 0000000..0366468 --- /dev/null +++ b/config/keys.go @@ -0,0 +1,32 @@ +package config + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" +) + +func ColorKey(c *Config, pre, key, end string) string { + color := ColorMark(c.Style.TextSpecial2) + normal := ColorMark(c.Style.Text) + key = TextFlags("b") + key + TextFlags("-") + if c.General.ShortHints { + pre = "" + end = "" + } + text := fmt.Sprintf("%s%s%s%s%s%s", normal, pre, color, key, normal, end) + return text +} + +func ColorMark(color tcell.Color) string { + return fmt.Sprintf("[#%06x]", color.Hex()) +} + +func TextFlags(s string) string { + return fmt.Sprintf("[::%s]", s) +} + +func SublteText(c *Config, text string) string { + subtle := ColorMark(c.Style.Subtle) + return fmt.Sprintf("%s%s", subtle, text) +} diff --git a/config/load.go b/config/load.go new file mode 100644 index 0000000..e6e3a91 --- /dev/null +++ b/config/load.go @@ -0,0 +1,32 @@ +package config + +import ( + "fmt" + "os" +) + +func Load() *Config { + err := createConfigDir() + if err != nil { + fmt.Printf("Couldn't create or access the configuration dir. Error: %v\n", err) + os.Exit(1) + } + path, exists, err := checkConfig("config.ini") + if err != nil { + fmt.Printf("Couldn't access config.ini. Error: %v\n", err) + os.Exit(1) + } + if !exists { + err = createDefaultConfig(path) + if err != nil { + fmt.Printf("Couldn't create default config. Error: %v\n", err) + os.Exit(1) + } + } + config, err := parseConfig(path) + if err != nil { + fmt.Printf("Couldn't open or parse the config. Error: %v\n", err) + os.Exit(1) + } + return &config +} diff --git a/themes/default.ini b/config/themes/default.ini similarity index 100% rename from themes/default.ini rename to config/themes/default.ini diff --git a/themes/nord.ini b/config/themes/nord.ini similarity index 100% rename from themes/nord.ini rename to config/themes/nord.ini diff --git a/config/themes/papercolor-light.ini b/config/themes/papercolor-light.ini new file mode 100644 index 0000000..83d80ab --- /dev/null +++ b/config/themes/papercolor-light.ini @@ -0,0 +1,21 @@ +; This is an adaption of NLKNguyen (https://github.com/NLKNguyen)'s +; PaperColor Light theme for tut (https://github.com/RasmusLindroth/tut). +; +; @author: stoerdebegga +; @source: https://codeberg.org/stoerdebegga/papercolor-light-contrib +; @source99: https://github.com/stoerdebegga/papercolor-light-contrib +; +background=#EEEEEE +text=#4D4D4C +subtle=#4271AE +warning-text=#D7005F +text-special-one=#969694 +text-special-two=#D7005F +top-bar-background=#4271AE +top-bar-text=#EEEEEE +status-bar-background=#4271AE +status-bar-text=#EEEEEE +status-bar-view-background=#718C00 +status-bar-view-text=#f5f5f5 +list-selected-background=#D7005F +list-selected-text=#f5f5f5 diff --git a/toot.tmpl b/config/toot.tmpl similarity index 100% rename from toot.tmpl rename to config/toot.tmpl diff --git a/user.tmpl b/config/user.tmpl similarity index 100% rename from user.tmpl rename to config/user.tmpl diff --git a/xrdb.go b/config/xrtb.go similarity index 98% rename from xrdb.go rename to config/xrtb.go index 7a99f95..39b84ec 100644 --- a/xrdb.go +++ b/config/xrtb.go @@ -1,4 +1,4 @@ -package main +package config import ( "os/exec" diff --git a/controls.go b/controls.go deleted file mode 100644 index e7c581f..0000000 --- a/controls.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import "github.com/rivo/tview" - -func NewControls(app *App, view *tview.TextView) *Controls { - return &Controls{ - app: app, - View: view, - } -} - -type Controls struct { - app *App - View *tview.TextView -} diff --git a/feed.go b/feed.go deleted file mode 100644 index 898fe38..0000000 --- a/feed.go +++ /dev/null @@ -1,1929 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/mattn/go-mastodon" - "github.com/rivo/tview" -) - -type FeedType uint - -const ( - TimelineFeedType FeedType = iota - ThreadFeedType - UserFeedType - UserListFeedType - NotificationFeedType - TagFeedType - ListFeedType -) - -type Feed interface { - GetFeedList() <-chan ListItem - LoadNewer() int - LoadOlder() int - DrawList() - DrawToot() - DrawSpoiler() - RedrawPoll(*mastodon.Poll) - RedrawControls() - GetCurrentUser() *mastodon.Account - GetCurrentStatus() *mastodon.Status - FeedType() FeedType - GetSavedIndex() int - GetDesc() string - Input(event *tcell.EventKey) -} - -type Poll struct { - ID string - ExpiresAt time.Time - Expired bool - Multiple bool - VotesCount int64 - Options []PollOption - Voted bool -} - -type PollOption struct { - Title string - VotesCount int64 - Percent string -} - -type Media struct { - Type string - Description string - URL string -} - -type Card struct { - Type string - Title string - Description string - URL string -} - -type Toot struct { - Visibility string - Boosted bool - BoostedDisplayName string - BoostedAcct string - Bookmarked bool - AccountDisplayName string - Account string - Spoiler bool - SpoilerText string - ShowSpoiler bool - ContentText string - Width int - HasExtra bool - Poll Poll - Media []Media - Card Card - Replies int - Boosts int - Favorites int - Controls string -} - -type DisplayTootData struct { - Toot Toot - Style StyleConfig -} - -func showTootOptions(app *App, status *mastodon.Status, showSensitive bool) (string, string) { - var strippedContent string - var strippedSpoiler string - var urls []URL - var u []URL - - so := status - if status.Reblog != nil { - status = status.Reblog - } - - strippedContent, urls = cleanTootHTML(status.Content) - strippedContent = tview.Escape(strippedContent) - - toot := Toot{ - Width: app.UI.StatusView.GetTextWidth(), - ContentText: strippedContent, - Boosted: so.Reblog != nil, - BoostedDisplayName: tview.Escape(so.Account.DisplayName), - BoostedAcct: tview.Escape(so.Account.Acct), - ShowSpoiler: showSensitive, - } - - toot.AccountDisplayName = tview.Escape(status.Account.DisplayName) - toot.Account = tview.Escape(status.Account.Acct) - toot.Bookmarked = status.Bookmarked == true - toot.Visibility = status.Visibility - toot.Spoiler = status.Sensitive - - if status.Poll != nil { - app.UI.VoteOverlay.SetPoll(status.Poll) - p := *status.Poll - toot.Poll = Poll{ - ID: string(p.ID), - ExpiresAt: p.ExpiresAt, - Expired: p.Expired, - Multiple: p.Multiple, - VotesCount: p.VotesCount, - Voted: p.Voted, - Options: []PollOption{}, - } - for _, item := range p.Options { - percent := 0.0 - if p.VotesCount > 0 { - percent = float64(item.VotesCount) / float64(p.VotesCount) * 100 - } - - o := PollOption{ - Title: tview.Escape(item.Title), - VotesCount: item.VotesCount, - Percent: fmt.Sprintf("%.2f", percent), - } - toot.Poll.Options = append(toot.Poll.Options, o) - } - - } else { - toot.Poll = Poll{} - } - - if status.Sensitive { - strippedSpoiler, u = cleanTootHTML(status.SpoilerText) - strippedSpoiler = tview.Escape(strippedSpoiler) - urls = append(urls, u...) - } - - toot.SpoilerText = strippedSpoiler - app.UI.LinkOverlay.SetLinks(urls, status) - - media := []Media{} - for _, att := range status.MediaAttachments { - m := Media{ - Type: att.Type, - Description: tview.Escape(att.Description), - URL: att.URL, - } - media = append(media, m) - } - toot.Media = media - - if status.Card != nil { - toot.Card = Card{ - Type: status.Card.Type, - Title: tview.Escape(strings.TrimSpace(status.Card.Title)), - Description: tview.Escape(strings.TrimSpace(status.Card.Description)), - URL: status.Card.URL, - } - } else { - toot.Card = Card{} - } - - toot.HasExtra = len(status.MediaAttachments) > 0 || status.Card != nil || status.Poll != nil - toot.Replies = int(status.RepliesCount) - toot.Boosts = int(status.ReblogsCount) - toot.Favorites = int(status.FavouritesCount) - - app.UI.StatusView.ScrollToBeginning() - - var info []string - if status.Favourited == true { - info = append(info, ColorKey(app.Config, "Un", "F", "avorite")) - } else { - info = append(info, ColorKey(app.Config, "", "F", "avorite")) - } - if status.Reblogged == true { - info = append(info, ColorKey(app.Config, "Un", "B", "oost")) - } else { - info = append(info, ColorKey(app.Config, "", "B", "oost")) - } - info = append(info, ColorKey(app.Config, "", "T", "hread")) - info = append(info, ColorKey(app.Config, "", "R", "eply")) - info = append(info, ColorKey(app.Config, "", "V", "iew")) - info = append(info, ColorKey(app.Config, "", "U", "ser")) - if len(status.MediaAttachments) > 0 { - info = append(info, ColorKey(app.Config, "", "M", "edia")) - } - if len(urls)+len(status.Mentions)+len(status.Tags) > 0 { - info = append(info, ColorKey(app.Config, "", "O", "pen")) - } - info = append(info, ColorKey(app.Config, "", "A", "vatar")) - if status.Account.ID == app.Me.ID { - info = append(info, ColorKey(app.Config, "", "D", "elete")) - } - - if status.Bookmarked == false { - info = append(info, ColorKey(app.Config, "", "S", "ave")) - } else { - info = append(info, ColorKey(app.Config, "Un", "S", "ave")) - } - info = append(info, ColorKey(app.Config, "", "Y", "ank")) - - controls := strings.Join(info, " ") - - td := DisplayTootData{ - Toot: toot, - Style: app.Config.Style, - } - var output bytes.Buffer - err := app.Config.Templates.TootTemplate.ExecuteTemplate(&output, "toot.tmpl", td) - if err != nil { - panic(err) - } - - return output.String(), controls -} - -type User struct { - Username string - Account string - DisplayName string - Locked bool - CreatedAt time.Time - FollowersCount int64 - FollowingCount int64 - StatusCount int64 - Note string - URL string - Avatar string - AvatarStatic string - Header string - HeaderStatic string - Fields []Field - Bot bool - //Emojis []Emoji - //Moved *Account `json:"moved"` -} - -type Field struct { - Name string - Value string - VerifiedAt time.Time -} - -type DisplayUserData struct { - User User - Style StyleConfig -} - -func showUser(app *App, user *mastodon.Account, relation *mastodon.Relationship, showUserControl bool) (string, string) { - u := User{ - Username: tview.Escape(user.Username), - Account: tview.Escape(user.Acct), - DisplayName: tview.Escape(user.DisplayName), - Locked: user.Locked, - CreatedAt: user.CreatedAt, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - StatusCount: user.StatusesCount, - URL: user.URL, - Avatar: user.Avatar, - AvatarStatic: user.AvatarStatic, - Header: user.Header, - HeaderStatic: user.HeaderStatic, - Bot: user.Bot, - } - - var controls string - - var urls []URL - fields := []Field{} - u.Note, urls = cleanTootHTML(user.Note) - for _, f := range user.Fields { - value, fu := cleanTootHTML(f.Value) - fields = append(fields, Field{ - Name: tview.Escape(f.Name), - Value: tview.Escape(value), - VerifiedAt: f.VerifiedAt, - }) - urls = append(urls, fu...) - } - u.Fields = fields - - app.UI.LinkOverlay.SetLinks(urls, nil) - - var controlItems []string - if app.Me.ID != user.ID { - if relation.Following { - controlItems = append(controlItems, ColorKey(app.Config, "Un", "F", "ollow")) - } else { - controlItems = append(controlItems, ColorKey(app.Config, "", "F", "ollow")) - } - if relation.Blocking { - controlItems = append(controlItems, ColorKey(app.Config, "Un", "B", "lock")) - } else { - controlItems = append(controlItems, ColorKey(app.Config, "", "B", "lock")) - } - if relation.Muting { - controlItems = append(controlItems, ColorKey(app.Config, "Un", "M", "ute")) - } else { - controlItems = append(controlItems, ColorKey(app.Config, "", "M", "ute")) - } - if len(urls) > 0 { - controlItems = append(controlItems, ColorKey(app.Config, "", "O", "pen")) - } - } - if showUserControl { - controlItems = append(controlItems, ColorKey(app.Config, "", "U", "ser")) - } - controlItems = append(controlItems, ColorKey(app.Config, "", "A", "vatar")) - controlItems = append(controlItems, ColorKey(app.Config, "", "Y", "ank")) - controls = strings.Join(controlItems, " ") - - ud := DisplayUserData{ - User: u, - Style: app.Config.Style, - } - var output bytes.Buffer - err := app.Config.Templates.UserTemplate.ExecuteTemplate(&output, "user.tmpl", ud) - if err != nil { - panic(err) - } - - return output.String(), controls -} - -func drawStatusList(statuses []*mastodon.Status, longFormat, shortFormat string, relativeDate int) <-chan ListItem { - ch := make(chan ListItem) - go func() { - today := time.Now() - for _, s := range statuses { - sLocal := s.CreatedAt.Local() - dateOutput := OutputDate(sLocal, today, longFormat, shortFormat, relativeDate) - - content := fmt.Sprintf("%s %s", dateOutput, s.Account.Acct) - iconText := "" - rs := s - if s.Reblog != nil { - rs = s.Reblog - } - if rs.RepliesCount > 0 || (rs.InReplyToID != nil && rs.InReplyToID != "") { - iconText = " ⤶ " - } - ch <- ListItem{Text: content, Icons: iconText} - } - close(ch) - }() - return ch -} - -func openAvatar(app *App, user mastodon.Account) { - f, err := downloadFile(user.AvatarStatic) - if err != nil { - app.UI.CmdBar.ShowError("Couldn't open avatar") - return - } - openMediaType(app.UI.Root, app.Config.Media, []string{f}, "image") -} - -type ControlItem uint - -const ( - ControlAvatar ControlItem = 1 << iota - ControlBlock - ControlBoost - ControlCompose - ControlDelete - ControlEnter - ControlFavorite - ControlFollow - ControlList - ControlMedia - ControlMute - ControlOpen - ControlReply - ControlThread - ControlUser - ControlVote - ControlSpoiler - ControlBookmark - ControlYankStatus - ControlYankUser -) - -func inputOptions(options []ControlItem) ControlItem { - var controls ControlItem - for _, o := range options { - controls = controls | o - } - return controls -} - -func inputSimple(app *App, event *tcell.EventKey, controls ControlItem, - user mastodon.Account, status *mastodon.Status, originalStatus *mastodon.Status, relation *mastodon.Relationship, feed Feed, listInfo *ListInfo) (updated bool, - redrawControls bool, redrawToot bool, newStatus *mastodon.Status, newRelation *mastodon.Relationship) { - - newStatus = status - newRelation = relation - var err error - - if event.Key() == tcell.KeyEnter { - if controls&ControlEnter == 0 { - return - } - if controls&ControlUser != 0 { - app.UI.StatusView.AddFeed( - NewUserFeed(app, user), - ) - } - if controls&ControlList != 0 { - app.UI.StatusView.AddFeed( - NewTimelineFeed(app, TimelineList, listInfo), - ) - } - } - - if event.Key() != tcell.KeyRune { - return - } - - switch event.Rune() { - case 'a', 'A': - if controls&ControlAvatar != 0 { - openAvatar(app, user) - } - case 'b', 'B': - if controls&ControlBoost != 0 { - newStatus, err = app.API.BoostToggle(status) - if err != nil { - app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't boost toot. Error: %v\n", err)) - return - } - updated = true - redrawControls = true - } - if controls&ControlBlock != 0 { - if relation.Blocking { - newRelation, err = app.API.UnblockUser(user) - } else { - newRelation, err = app.API.BlockUser(user) - } - if err != nil { - app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't block/unblock user. Error: %v\n", err)) - return - } - updated = true - redrawToot = true - redrawControls = true - } - case 'd', 'D': - if controls&ControlDelete != 0 { - err = app.API.DeleteStatus(status) - if err != nil { - app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't delete toot. Error: %v\n", err)) - } else { - status.Card = nil - status.Sensitive = false - status.SpoilerText = "" - status.Favourited = false - status.MediaAttachments = nil - status.Reblogged = false - status.Content = "Deleted" - updated = true - redrawToot = true - } - } - case 'f', 'F': - if controls&ControlFavorite != 0 { - newStatus, err = app.API.FavoriteToogle(status) - if err != nil { - app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't toggle favorite on toot. Error: %v\n", err)) - return - } - updated = true - redrawControls = true - } - if controls&ControlFollow != 0 { - if relation.Following { - newRelation, err = app.API.UnfollowUser(user) - } else { - newRelation, err = app.API.FollowUser(user) - } - if err != nil { - app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't follow/unfollow user. Error: %v\n", err)) - return - } - updated = true - redrawToot = true - redrawControls = true - } - case 'c', 'C': - if controls&ControlCompose != 0 { - app.UI.NewToot() - } - case 'm', 'M': - if controls&ControlMedia != 0 { - app.UI.OpenMedia(status) - } - if controls&ControlMute != 0 { - if relation.Muting { - newRelation, err = app.API.UnmuteUser(user) - } else { - newRelation, err = app.API.MuteUser(user) - } - if err != nil { - app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't mute/unmute user. Error: %v\n", err)) - return - } - updated = true - redrawToot = true - redrawControls = true - } - case 'o', 'O': - if controls&ControlOpen != 0 { - app.UI.ShowLinks() - } - if controls&ControlList != 0 { - app.UI.StatusView.AddFeed( - NewTimelineFeed(app, TimelineList, listInfo), - ) - } - case 'p', 'P': - if controls&ControlVote != 0 { - app.UI.ShowVote() - } - case 'r', 'R': - if controls&ControlReply != 0 { - app.UI.Reply(status) - } - case 's', 'S': - if controls&ControlBookmark != 0 { - tmpStatus, err := app.API.BookmarkToogle(status) - newStatus = originalStatus - if newStatus.Reblog != nil { - newStatus.Reblog.Bookmarked = tmpStatus.Bookmarked - } else { - newStatus.Bookmarked = tmpStatus.Bookmarked - } - if err != nil { - app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't toggle bookmark on toot. Error: %v\n", err)) - return - } - updated = true - redrawControls = true - redrawToot = true - } - case 't', 'T': - if controls&ControlThread != 0 { - app.UI.StatusView.AddFeed( - NewThreadFeed(app, status), - ) - } - case 'u', 'U': - if controls&ControlUser != 0 { - app.UI.StatusView.AddFeed( - NewUserFeed(app, user), - ) - } - case 'y', 'Y': - if controls&ControlYankStatus != 0 { - copyToClipboard(status.URL) - } - if controls&ControlYankUser != 0 { - copyToClipboard(user.URL) - } - case 'z', 'Z': - if controls&ControlSpoiler != 0 { - feed.DrawSpoiler() - } - } - return -} - -func userFromStatus(s *mastodon.Status) *mastodon.Account { - if s == nil { - return nil - } - if s.Reblog != nil { - s = s.Reblog - } - return &s.Account -} - -func NewTimelineFeed(app *App, tl TimelineType, listInfo *ListInfo) *TimelineFeed { - t := &TimelineFeed{ - app: app, - timelineType: tl, - linkPrev: "", - linkNext: "", - listInfo: listInfo, - } - var err error - t.statuses, err = t.app.API.GetStatuses(t) - if err != nil { - t.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load timeline toots. Error: %v\n", err)) - } - return t -} - -type TimelineFeed struct { - app *App - timelineType TimelineType - statuses []*mastodon.Status - linkPrev mastodon.ID //Only bm and fav - linkNext mastodon.ID //Only bm and fav - listInfo *ListInfo //only lists - index int - showSpoiler bool -} - -func (t *TimelineFeed) FeedType() FeedType { - return TimelineFeedType -} - -func (t *TimelineFeed) GetDesc() string { - switch t.timelineType { - case TimelineHome: - return "Timeline home" - case TimelineDirect: - return "Timeline direct" - case TimelineLocal: - return "Timeline local" - case TimelineFederated: - return "Timeline federated" - case TimelineBookmarked: - return "Bookmarks" - case TimelineFavorited: - return "Favorited" - case TimelineList: - return fmt.Sprintf("List: %s", t.listInfo.name) - } - return "Timeline" -} - -func (t *TimelineFeed) GetCurrentStatus() *mastodon.Status { - index := t.app.UI.StatusView.GetCurrentItem() - if index >= len(t.statuses) { - return nil - } - return t.statuses[index] -} - -func (t *TimelineFeed) GetCurrentUser() *mastodon.Account { - return userFromStatus(t.GetCurrentStatus()) -} - -func (t *TimelineFeed) GetFeedList() <-chan ListItem { - return drawStatusList(t.statuses, t.app.Config.General.DateFormat, t.app.Config.General.DateTodayFormat, t.app.Config.General.DateRelative) -} - -func (t *TimelineFeed) LoadNewer() int { - var statuses []*mastodon.Status - var err error - if len(t.statuses) == 0 { - statuses, err = t.app.API.GetStatuses(t) - } else { - statuses, err = t.app.API.GetStatusesNewer(t) - newCount := len(statuses) - if newCount > 0 { - Notify(t.app.Config.NotificationConfig, NotificationPost, - fmt.Sprintf("%d new toots", newCount), "") - } - } - if err != nil { - t.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load new toots. Error: %v\n", err)) - return 0 - } - if len(statuses) == 0 { - return 0 - } - old := t.statuses - t.statuses = append(statuses, old...) - return len(statuses) -} - -func (t *TimelineFeed) LoadOlder() int { - var statuses []*mastodon.Status - var err error - if len(t.statuses) == 0 { - statuses, err = t.app.API.GetStatuses(t) - } else { - statuses, err = t.app.API.GetStatusesOlder(t) - } - if err != nil { - t.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load older toots. Error: %v\n", err)) - return 0 - } - if len(statuses) == 0 { - return 0 - } - t.statuses = append(t.statuses, statuses...) - return len(statuses) -} - -func (t *TimelineFeed) DrawList() { - t.app.UI.StatusView.SetList(t.GetFeedList()) -} - -func (t *TimelineFeed) DrawSpoiler() { - t.showSpoiler = true - t.DrawToot() -} - -func (t *TimelineFeed) 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.text.Clear() - t.app.UI.StatusView.SetText(text) - t.app.UI.StatusView.SetControls(controls) - t.app.UI.ShouldSync() -} - -func (t *TimelineFeed) RedrawControls() { - status := t.GetCurrentStatus() - if status == nil { - return - } - _, controls := showTootOptions(t.app, status, t.showSpoiler) - t.app.UI.StatusView.SetControls(controls) -} - -func (t *TimelineFeed) GetSavedIndex() int { - return t.index -} - -func (t *TimelineFeed) RedrawPoll(p *mastodon.Poll) { - s := t.GetCurrentStatus() - if s == nil { - return - } - s.Poll = p - t.DrawToot() -} - -func (t *TimelineFeed) Input(event *tcell.EventKey) { - status := t.GetCurrentStatus() - originalStatus := status - if status == nil { - return - } - if status.Reblog != nil { - status = status.Reblog - } - user := status.Account - - controls := []ControlItem{ - ControlAvatar, ControlThread, ControlUser, ControlSpoiler, - ControlCompose, ControlOpen, ControlReply, ControlMedia, - ControlFavorite, ControlBoost, ControlDelete, ControlBookmark, - ControlYankStatus, - } - if status.Poll != nil { - if !status.Poll.Expired && !status.Poll.Voted { - controls = append(controls, ControlVote) - } - } - options := inputOptions(controls) - - updated, rc, rt, newS, _ := inputSimple(t.app, event, options, user, status, originalStatus, nil, t, nil) - if updated { - index := t.app.UI.StatusView.GetCurrentItem() - t.statuses[index] = newS - } - if rc { - t.RedrawControls() - } - if rt { - t.DrawToot() - } -} - -func NewThreadFeed(app *App, s *mastodon.Status) *ThreadFeed { - t := &ThreadFeed{ - app: app, - } - statuses, index, err := t.app.API.GetThread(s) - if err != nil { - t.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load thread. Error: %v\n", err)) - } - t.statuses = statuses - t.status = s - t.index = index - return t -} - -type ThreadFeed struct { - app *App - statuses []*mastodon.Status - status *mastodon.Status - index int - showSpoiler bool -} - -func (t *ThreadFeed) FeedType() FeedType { - return ThreadFeedType -} - -func (t *ThreadFeed) GetDesc() string { - return "Thread" -} - -func (t *ThreadFeed) 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 *ThreadFeed) GetCurrentUser() *mastodon.Account { - return userFromStatus(t.GetCurrentStatus()) -} - -func (t *ThreadFeed) GetFeedList() <-chan ListItem { - return drawStatusList(t.statuses, t.app.Config.General.DateFormat, t.app.Config.General.DateTodayFormat, t.app.Config.General.DateRelative) -} - -func (t *ThreadFeed) LoadNewer() int { - return 0 -} - -func (t *ThreadFeed) LoadOlder() int { - return 0 -} - -func (t *ThreadFeed) DrawList() { - t.app.UI.StatusView.SetList(t.GetFeedList()) -} - -func (t *ThreadFeed) DrawSpoiler() { - t.showSpoiler = true - t.DrawToot() -} - -func (t *ThreadFeed) 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) - t.app.UI.ShouldSync() -} - -func (t *ThreadFeed) 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 *ThreadFeed) GetSavedIndex() int { - return t.index -} - -func (t *ThreadFeed) RedrawPoll(p *mastodon.Poll) { - s := t.GetCurrentStatus() - if s == nil { - return - } - s.Poll = p - t.DrawToot() -} - -func (t *ThreadFeed) Input(event *tcell.EventKey) { - status := t.GetCurrentStatus() - originalStatus := status - if status == nil { - return - } - if status.Reblog != nil { - status = status.Reblog - } - user := status.Account - - controls := []ControlItem{ - ControlAvatar, ControlUser, ControlSpoiler, - ControlCompose, ControlOpen, ControlReply, ControlMedia, - ControlFavorite, ControlBoost, ControlDelete, ControlBookmark, - ControlYankStatus, - } - if status.ID != t.status.ID { - controls = append(controls, ControlThread) - } - if status.Poll != nil { - if !status.Poll.Expired && !status.Poll.Voted { - controls = append(controls, ControlVote) - } - } - options := inputOptions(controls) - - updated, rc, rt, newS, _ := inputSimple(t.app, event, options, user, status, originalStatus, nil, t, nil) - if updated { - index := t.app.UI.StatusView.GetCurrentItem() - t.statuses[index] = newS - } - if rc { - t.RedrawControls() - } - if rt { - t.DrawToot() - } -} - -func NewUserFeed(app *App, a mastodon.Account) *UserFeed { - u := &UserFeed{ - app: app, - } - statuses, err := app.API.GetUserStatuses(a) - if err != nil { - u.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load user toots. Error: %v\n", err)) - return u - } - u.statuses = statuses - relation, err := app.API.UserRelation(a) - if err != nil { - u.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load user data. Error: %v\n", err)) - return u - } - u.relation = relation - u.user = a - return u -} - -type UserFeed struct { - app *App - statuses []*mastodon.Status - user mastodon.Account - relation *mastodon.Relationship - index int - showSpoiler bool -} - -func (u *UserFeed) FeedType() FeedType { - return UserFeedType -} - -func (u *UserFeed) GetDesc() string { - return "User " + u.user.Acct -} - -func (u *UserFeed) 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 *UserFeed) GetCurrentUser() *mastodon.Account { - return &u.user -} - -func (u *UserFeed) GetFeedList() <-chan ListItem { - ch := make(chan ListItem) - go func() { - ch <- ListItem{Text: "Profile", Icons: ""} - for s := range drawStatusList(u.statuses, u.app.Config.General.DateFormat, u.app.Config.General.DateTodayFormat, u.app.Config.General.DateRelative) { - ch <- s - } - close(ch) - }() - return ch -} - -func (u *UserFeed) 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 { - u.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load new toots. Error: %v\n", err)) - return 0 - } - if len(statuses) == 0 { - return 0 - } - old := u.statuses - u.statuses = append(statuses, old...) - return len(statuses) -} - -func (u *UserFeed) 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 { - u.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load older toots. Error: %v\n", err)) - return 0 - } - if len(statuses) == 0 { - return 0 - } - u.statuses = append(u.statuses, statuses...) - return len(statuses) -} - -func (u *UserFeed) DrawList() { - u.app.UI.StatusView.SetList(u.GetFeedList()) -} - -func (u *UserFeed) DrawSpoiler() { - u.showSpoiler = true - u.DrawToot() -} - -func (u *UserFeed) DrawToot() { - u.index = u.app.UI.StatusView.GetCurrentItem() - - var text string - var controls string - - if u.index == 0 { - text, controls = showUser(u.app, &u.user, u.relation, false) - } 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) - u.app.UI.ShouldSync() -} - -func (u *UserFeed) 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 *UserFeed) GetSavedIndex() int { - return u.index -} - -func (u *UserFeed) RedrawPoll(p *mastodon.Poll) { - s := u.GetCurrentStatus() - if s == nil { - return - } - s.Poll = p - u.DrawToot() -} - -func (u *UserFeed) Input(event *tcell.EventKey) { - index := u.GetSavedIndex() - - if index == 0 { - controls := []ControlItem{ - ControlAvatar, ControlFollow, ControlBlock, ControlMute, ControlOpen, - ControlYankUser, - } - options := inputOptions(controls) - - updated, _, _, _, newRel := inputSimple(u.app, event, options, u.user, nil, nil, u.relation, u, nil) - if updated { - u.relation = newRel - u.DrawToot() - } - return - } - - status := u.GetCurrentStatus() - originalStatus := status - if status == nil { - return - } - if status.Reblog != nil { - status = status.Reblog - } - user := status.Account - - controls := []ControlItem{ - ControlAvatar, ControlThread, ControlSpoiler, ControlCompose, - ControlOpen, ControlReply, ControlMedia, ControlFavorite, ControlBoost, - ControlDelete, ControlUser, ControlBookmark, ControlYankStatus, - } - if status.Poll != nil { - if !status.Poll.Expired && !status.Poll.Voted { - controls = append(controls, ControlVote) - } - } - options := inputOptions(controls) - - updated, rc, rt, newS, _ := inputSimple(u.app, event, options, user, status, originalStatus, nil, u, nil) - if updated { - index := u.app.UI.StatusView.GetCurrentItem() - u.statuses[index-1] = newS - } - if rc { - u.RedrawControls() - } - if rt { - u.DrawToot() - } -} - -func NewNotificationFeed(app *App, docked bool) *NotificationsFeed { - n := &NotificationsFeed{ - app: app, - docked: docked, - } - n.notifications, _ = n.app.API.GetNotifications() - return n -} - -type Notification struct { - N *mastodon.Notification - R *mastodon.Relationship -} - -type NotificationsFeed struct { - app *App - notifications []*Notification - docked bool - index int - showSpoiler bool -} - -func (n *NotificationsFeed) FeedType() FeedType { - return NotificationFeedType -} - -func (n *NotificationsFeed) GetDesc() string { - return "Notifications" -} - -func (n *NotificationsFeed) GetCurrentNotification() *Notification { - var index int - if n.docked { - index = n.app.UI.StatusView.notificationView.list.GetCurrentItem() - } else { - index = n.app.UI.StatusView.GetCurrentItem() - } - if index >= len(n.notifications) { - return nil - } - return n.notifications[index] -} - -func (n *NotificationsFeed) GetCurrentStatus() *mastodon.Status { - notification := n.GetCurrentNotification() - if notification.N == nil { - return nil - } - return notification.N.Status -} - -func (n *NotificationsFeed) GetCurrentUser() *mastodon.Account { - notification := n.GetCurrentNotification() - if notification.N == nil { - return nil - } - return ¬ification.N.Account -} - -func (n *NotificationsFeed) GetFeedList() <-chan ListItem { - ch := make(chan ListItem) - notifications := n.notifications - go func() { - today := time.Now() - for _, item := range notifications { - sLocal := item.N.CreatedAt.Local() - long := n.app.Config.General.DateFormat - short := n.app.Config.General.DateTodayFormat - relative := n.app.Config.General.DateRelative - - dateOutput := OutputDate(sLocal, today, long, short, relative) - - iconText := "" - switch item.N.Type { - case "follow", "follow_request": - iconText += " + " - case "favourite": - iconText = " ★ " - case "reblog": - iconText = " ♺ " - case "mention": - iconText = " ⤶ " - case "poll": - iconText = " = " - case "status": - iconText = " ⤶ " - } - - content := fmt.Sprintf("%s %s", dateOutput, item.N.Account.Acct) - ch <- ListItem{Text: content, Icons: iconText} - } - close(ch) - }() - return ch -} - -func (n *NotificationsFeed) LoadNewer() int { - var notifications []*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]) - for _, o := range notifications { - switch o.N.Type { - case "follow": - Notify(n.app.Config.NotificationConfig, NotificationFollower, - "New follower", fmt.Sprintf("%s follows you", o.N.Account.Username)) - case "favourite": - Notify(n.app.Config.NotificationConfig, NotificationFavorite, - "Favorited your toot", fmt.Sprintf("%s favorited your toot", o.N.Account.Username)) - case "reblog": - Notify(n.app.Config.NotificationConfig, NotificationBoost, - "Boosted your toot", fmt.Sprintf("%s boosted your toot", o.N.Account.Username)) - case "mention": - Notify(n.app.Config.NotificationConfig, NotificationMention, - "Mentioned in toot", fmt.Sprintf("%s mentioned you", o.N.Account.Username)) - case "status": - Notify(n.app.Config.NotificationConfig, NotificationMention, - "From following", fmt.Sprintf("%s posted a new toot", o.N.Account.Username)) - case "poll": - Notify(n.app.Config.NotificationConfig, NotificationPoll, - "Poll has ended", "") - default: - } - } - } - if err != nil { - n.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load new toots. Error: %v\n", err)) - return 0 - } - if len(notifications) == 0 { - return 0 - } - old := n.notifications - n.notifications = append(notifications, old...) - return len(notifications) -} - -func (n *NotificationsFeed) LoadOlder() int { - var notifications []*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 { - n.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load older toots. Error: %v\n", err)) - return 0 - } - if len(notifications) == 0 { - return 0 - } - n.notifications = append(n.notifications, notifications...) - return len(notifications) -} - -func (n *NotificationsFeed) DrawList() { - if n.docked { - n.app.UI.StatusView.notificationView.SetList(n.GetFeedList()) - } else { - n.app.UI.StatusView.SetList(n.GetFeedList()) - } -} - -func (n *NotificationsFeed) DrawSpoiler() { - n.showSpoiler = true - n.DrawToot() -} - -func (n *NotificationsFeed) DrawToot() { - if n.docked { - n.index = n.app.UI.StatusView.notificationView.list.GetCurrentItem() - } else { - 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.N.Type { - case "follow": - text = SublteText(n.app.Config.Style, FormatUsername(notification.N.Account)+" started following you\n\n") - var t string - t, controls = showUser(n.app, ¬ification.N.Account, notification.R, true) - text += t - case "favourite": - pre := SublteText(n.app.Config.Style, FormatUsername(notification.N.Account)+" favorited your toot") + "\n\n" - text, controls = showTootOptions(n.app, notification.N.Status, n.showSpoiler) - text = pre + text - case "reblog": - pre := SublteText(n.app.Config.Style, FormatUsername(notification.N.Account)+" boosted your toot") + "\n\n" - text, controls = showTootOptions(n.app, notification.N.Status, n.showSpoiler) - text = pre + text - case "mention": - pre := SublteText(n.app.Config.Style, FormatUsername(notification.N.Account)+" mentioned you") + "\n\n" - text, controls = showTootOptions(n.app, notification.N.Status, n.showSpoiler) - text = pre + text - case "status": - pre := SublteText(n.app.Config.Style, FormatUsername(notification.N.Account)+" posted a new toot") + "\n\n" - text, controls = showTootOptions(n.app, notification.N.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.N.Status, n.showSpoiler) - text = pre + text - case "follow_request": - text = SublteText(n.app.Config.Style, FormatUsername(notification.N.Account)+" wants to follow you. This is currently not implemented, so use another app to accept or reject the request.\n\n") - default: - } - - n.app.UI.StatusView.SetText(text) - n.app.UI.StatusView.SetControls(controls) - n.app.UI.ShouldSync() -} - -func (n *NotificationsFeed) RedrawControls() { - notification := n.GetCurrentNotification() - if notification == nil { - n.app.UI.StatusView.SetControls("") - return - } - switch notification.N.Type { - case "favourite", "reblog", "mention", "poll", "status": - _, controls := showTootOptions(n.app, notification.N.Status, n.showSpoiler) - n.app.UI.StatusView.SetControls(controls) - case "follow": - _, controls := showUser(n.app, ¬ification.N.Account, notification.R, true) - n.app.UI.StatusView.SetControls(controls) - } -} - -func (n *NotificationsFeed) GetSavedIndex() int { - return n.index -} - -func (n *NotificationsFeed) RedrawPoll(p *mastodon.Poll) { - s := n.GetCurrentStatus() - if s == nil { - return - } - s.Poll = p - n.DrawToot() -} - -func (n *NotificationsFeed) Input(event *tcell.EventKey) { - notification := n.GetCurrentNotification() - if notification == nil { - return - } - if notification.N.Type == "follow" { - controls := []ControlItem{ - ControlUser, ControlFollow, ControlBlock, - ControlMute, ControlAvatar, ControlOpen, - ControlYankUser, - } - options := inputOptions(controls) - _, rc, _, _, rel := inputSimple(n.app, event, options, notification.N.Account, nil, nil, notification.R, n, nil) - if rc { - var index int - if n.docked { - index = n.app.UI.StatusView.notificationView.list.GetCurrentItem() - } else { - index = n.app.UI.StatusView.GetCurrentItem() - } - n.notifications[index].R = rel - n.RedrawControls() - } - return - } - - if notification.N.Type == "follow_request" { - return - } - status := notification.N.Status - if status == nil { - return - } - originalStatus := status - if status.Reblog != nil { - status = status.Reblog - } - - controls := []ControlItem{ - ControlAvatar, ControlThread, ControlUser, ControlSpoiler, - ControlCompose, ControlOpen, ControlReply, ControlMedia, - ControlFavorite, ControlBoost, ControlDelete, ControlBookmark, - ControlYankStatus, - } - if status.Poll != nil { - if !status.Poll.Expired && !status.Poll.Voted { - controls = append(controls, ControlVote) - } - } - options := inputOptions(controls) - - updated, rc, rt, newS, _ := inputSimple(n.app, event, options, notification.N.Account, status, originalStatus, nil, n, nil) - if updated { - var index int - if n.docked { - index = n.app.UI.StatusView.notificationView.list.GetCurrentItem() - } else { - index = n.app.UI.StatusView.GetCurrentItem() - } - n.notifications[index].N.Status = newS - } - if rc { - n.RedrawControls() - } - if rt { - n.DrawToot() - } -} - -func NewTagFeed(app *App, tag string) *TagFeed { - t := &TagFeed{ - app: app, - tag: tag, - } - t.statuses, _ = t.app.API.GetTags(tag) - return t -} - -type TagFeed struct { - app *App - tag string - statuses []*mastodon.Status - index int - showSpoiler bool -} - -func (t *TagFeed) FeedType() FeedType { - return TagFeedType -} - -func (t *TagFeed) GetDesc() string { - return "Tag #" + t.tag -} - -func (t *TagFeed) 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 *TagFeed) GetCurrentUser() *mastodon.Account { - return userFromStatus(t.GetCurrentStatus()) -} - -func (t *TagFeed) GetFeedList() <-chan ListItem { - return drawStatusList(t.statuses, t.app.Config.General.DateFormat, t.app.Config.General.DateTodayFormat, t.app.Config.General.DateRelative) -} - -func (t *TagFeed) LoadNewer() int { - var statuses []*mastodon.Status - var err error - if len(t.statuses) == 0 { - statuses, err = t.app.API.GetTags(t.tag) - } else { - statuses, err = t.app.API.GetTagsNewer(t.tag, t.statuses[0]) - } - if err != nil { - t.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load new toots. Error: %v\n", err)) - return 0 - } - if len(statuses) == 0 { - return 0 - } - old := t.statuses - t.statuses = append(statuses, old...) - return len(statuses) -} - -func (t *TagFeed) LoadOlder() int { - var statuses []*mastodon.Status - var err error - if len(t.statuses) == 0 { - statuses, err = t.app.API.GetTags(t.tag) - } else { - statuses, err = t.app.API.GetTagsOlder(t.tag, t.statuses[len(t.statuses)-1]) - } - if err != nil { - t.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load older toots. Error: %v\n", err)) - return 0 - } - if len(statuses) == 0 { - return 0 - } - t.statuses = append(t.statuses, statuses...) - return len(statuses) -} - -func (t *TagFeed) DrawList() { - t.app.UI.StatusView.SetList(t.GetFeedList()) -} - -func (t *TagFeed) DrawSpoiler() { - t.showSpoiler = true - t.DrawToot() -} - -func (t *TagFeed) 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) - t.app.UI.ShouldSync() -} - -func (t *TagFeed) RedrawControls() { - status := t.GetCurrentStatus() - if status == nil { - return - } - _, controls := showTootOptions(t.app, status, t.showSpoiler) - t.app.UI.StatusView.SetControls(controls) -} - -func (t *TagFeed) GetSavedIndex() int { - return t.index -} - -func (t *TagFeed) RedrawPoll(p *mastodon.Poll) { - s := t.GetCurrentStatus() - if s == nil { - return - } - s.Poll = p - t.DrawToot() -} - -func (t *TagFeed) Input(event *tcell.EventKey) { - status := t.GetCurrentStatus() - originalStatus := status - if status == nil { - return - } - if status.Reblog != nil { - status = status.Reblog - } - user := status.Account - - controls := []ControlItem{ - ControlAvatar, ControlThread, ControlUser, ControlSpoiler, - ControlCompose, ControlOpen, ControlReply, ControlMedia, - ControlFavorite, ControlBoost, ControlDelete, ControlBookmark, - ControlYankStatus, - } - if status.Poll != nil { - if !status.Poll.Expired && !status.Poll.Voted { - controls = append(controls, ControlVote) - } - } - options := inputOptions(controls) - - updated, rc, rt, newS, _ := inputSimple(t.app, event, options, user, status, originalStatus, nil, t, nil) - if updated { - index := t.app.UI.StatusView.GetCurrentItem() - t.statuses[index] = newS - } - if rc { - t.RedrawControls() - } - if rt { - t.DrawToot() - } -} - -func NewUserListFeed(app *App, t UserListType, s string) *UserListFeed { - u := &UserListFeed{ - app: app, - listType: t, - input: s, - } - users, err := app.API.GetUserList(t, s) - if err != nil { - u.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load users. Error: %v\n", err)) - return u - } - u.users = users - return u -} - -type UserListFeed struct { - app *App - users []*UserData - index int - input string - listType UserListType -} - -func (u *UserListFeed) FeedType() FeedType { - return UserListFeedType -} - -func (u *UserListFeed) GetDesc() string { - var output string - switch u.listType { - case UserListSearch: - output = "User search: " + u.input - case UserListBoosts: - output = "Boosts" - case UserListFavorites: - output = "Favorites" - case UserListFollowers: - output = "Followers" - case UserListFollowing: - output = "Following" - case UserListBlocking: - output = "Blocking" - case UserListMuting: - output = "Muting" - } - return output -} - -func (u *UserListFeed) GetCurrentStatus() *mastodon.Status { - return nil -} - -func (u *UserListFeed) GetCurrentUser() *mastodon.Account { - ud := u.GetCurrentUserData() - if ud == nil { - return nil - } - return ud.User -} - -func (u *UserListFeed) GetCurrentUserData() *UserData { - index := u.app.UI.app.UI.StatusView.GetCurrentItem() - if len(u.users) == 0 || index > len(u.users)-1 { - return nil - } - return u.users[index-1] -} - -func (u *UserListFeed) GetFeedList() <-chan ListItem { - ch := make(chan ListItem) - users := u.users - go func() { - for _, user := range users { - var username string - if user.User.DisplayName == "" { - username = user.User.Acct - } else { - username = fmt.Sprintf("%s (%s)", user.User.DisplayName, user.User.Acct) - } - ch <- ListItem{Text: username, Icons: ""} - } - close(ch) - }() - return ch -} - -func (u *UserListFeed) LoadNewer() int { - var users []*UserData - var err error - if len(u.users) == 0 { - users, err = u.app.API.GetUserList(u.listType, u.input) - } else { - users, err = u.app.API.GetUserListNewer(u.listType, u.input, u.users[0].User) - } - if err != nil { - u.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load new users. Error: %v\n", err)) - return 0 - } - if len(users) == 0 { - return 0 - } - old := u.users - u.users = append(users, old...) - return len(users) -} - -func (u *UserListFeed) LoadOlder() int { - var users []*UserData - var err error - if len(u.users) == 0 { - users, err = u.app.API.GetUserList(u.listType, u.input) - } else { - users, err = u.app.API.GetUserListOlder(u.listType, u.input, u.users[len(u.users)-1].User) - } - if err != nil { - u.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load more users. Error: %v\n", err)) - return 0 - } - if len(users) == 0 { - return 0 - } - u.users = append(u.users, users...) - return len(users) -} - -func (u *UserListFeed) DrawList() { - u.app.UI.StatusView.SetList(u.GetFeedList()) -} - -func (u *UserListFeed) RedrawControls() { - //Does not implement -} - -func (u *UserListFeed) DrawSpoiler() { - //Does not implement -} - -func (u *UserListFeed) DrawToot() { - u.index = u.app.UI.StatusView.GetCurrentItem() - index := u.index - if index > len(u.users)-1 || len(u.users) == 0 { - return - } - user := u.users[index] - - text, controls := showUser(u.app, user.User, user.Relationship, true) - - u.app.UI.StatusView.SetText(text) - u.app.UI.StatusView.SetControls(controls) - u.app.UI.ShouldSync() -} - -func (u *UserListFeed) GetSavedIndex() int { - return u.index -} - -func (u *UserListFeed) RedrawPoll(p *mastodon.Poll) { -} - -func (u *UserListFeed) Input(event *tcell.EventKey) { - index := u.GetSavedIndex() - if index > len(u.users)-1 || len(u.users) == 0 { - return - } - user := u.users[index] - - controls := []ControlItem{ - ControlAvatar, ControlFollow, ControlBlock, ControlMute, ControlOpen, - ControlUser, ControlEnter, ControlYankUser, - } - options := inputOptions(controls) - - updated, _, _, _, newRel := inputSimple(u.app, event, options, *user.User, nil, nil, user.Relationship, u, nil) - if updated { - u.users[index].Relationship = newRel - u.DrawToot() - } -} - -func NewListFeed(app *App) *ListFeed { - l := &ListFeed{ - app: app, - } - lists, err := app.API.GetLists() - if err != nil { - l.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load lists. Error: %v\n", err)) - return l - } - l.lists = lists - return l -} - -type ListInfo struct { - name string - id mastodon.ID -} - -type ListFeed struct { - app *App - lists []*mastodon.List - index int -} - -func (l *ListFeed) FeedType() FeedType { - return ListFeedType -} - -func (l *ListFeed) GetDesc() string { - return "Lists" -} - -func (l *ListFeed) GetCurrentStatus() *mastodon.Status { - return nil -} - -func (l *ListFeed) GetCurrentUser() *mastodon.Account { - return nil -} - -func (l *ListFeed) GetFeedList() <-chan ListItem { - ch := make(chan ListItem) - go func() { - for _, list := range l.lists { - ch <- ListItem{Text: list.Title, Icons: ""} - } - close(ch) - }() - return ch -} - -func (l *ListFeed) LoadNewer() int { - return 0 -} - -func (l *ListFeed) LoadOlder() int { - return 0 -} - -func (l *ListFeed) DrawList() { - l.app.UI.StatusView.SetList(l.GetFeedList()) -} - -func (l *ListFeed) RedrawControls() { - //Does not implement -} - -func (l *ListFeed) DrawSpoiler() { - //Does not implement -} - -func (l *ListFeed) DrawToot() { - l.index = l.app.UI.StatusView.GetCurrentItem() - index := l.index - if index > len(l.lists)-1 || len(l.lists) == 0 { - return - } - list := l.lists[index] - - text := ColorKey(l.app.Config, "", "O", "pen") - text += fmt.Sprintf(" list %s", list.Title) - - l.app.UI.StatusView.SetText(text) - l.app.UI.StatusView.SetControls("") - l.app.UI.ShouldSync() -} - -func (l *ListFeed) GetSavedIndex() int { - return l.index - -} - -func (t *ListFeed) RedrawPoll(p *mastodon.Poll) { -} - -func (l *ListFeed) Input(event *tcell.EventKey) { - index := l.GetSavedIndex() - if index > len(l.lists)-1 || len(l.lists) == 0 { - return - } - list := l.lists[index] - li := ListInfo{ - name: list.Title, - id: list.ID, - } - - controls := []ControlItem{ControlEnter, ControlList} - options := inputOptions(controls) - - inputSimple(l.app, event, options, mastodon.Account{}, nil, nil, nil, nil, &li) -} diff --git a/feed/feed.go b/feed/feed.go new file mode 100644 index 0000000..0117877 --- /dev/null +++ b/feed/feed.go @@ -0,0 +1,930 @@ +package feed + +import ( + "context" + "errors" + "log" + "sync" + "time" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/api" +) + +type apiFunc func(pg *mastodon.Pagination) ([]api.Item, error) +type apiEmptyFunc func() ([]api.Item, error) +type apiIDFunc func(pg *mastodon.Pagination, id mastodon.ID) ([]api.Item, error) +type apiSearchFunc func(search string) ([]api.Item, error) +type apiSearchPGFunc func(pg *mastodon.Pagination, search string) ([]api.Item, error) +type apiThreadFunc func(status *mastodon.Status) ([]api.Item, int, error) + +type FeedType uint + +const ( + Favorites FeedType = iota + Favorited + Boosts + Followers + Following + Blocking + Muting + InvalidFeed + Notification + Tag + Thread + TimelineFederated + TimelineHome + TimelineLocal + Conversations + User + UserList + Lists + List +) + +type LoadingLock struct { + mux sync.Mutex + last time.Time +} + +type DesktopNotificationType uint + +const ( + DekstopNotificationNone DesktopNotificationType = iota + DesktopNotificationFollower + DesktopNotificationFavorite + DesktopNotificationMention + DesktopNotificationBoost + DesktopNotificationPoll + DesktopNotificationPost +) + +type Feed struct { + accountClient *api.AccountClient + feedType FeedType + items []api.Item + itemsMux sync.RWMutex + loadingNewer *LoadingLock + loadingOlder *LoadingLock + loadNewer func() + loadOlder func() + Update chan DesktopNotificationType + apiData *api.RequestData + apiDataMux sync.Mutex + stream *api.Receiver + name string + close func() +} + +func (f *Feed) Type() FeedType { + return f.feedType +} + +func (f *Feed) List() []api.Item { + f.itemsMux.RLock() + defer f.itemsMux.RUnlock() + return f.items +} + +func (f *Feed) Item(index int) (api.Item, error) { + f.itemsMux.RLock() + defer f.itemsMux.RUnlock() + if index < 0 || index >= len(f.items) { + return nil, errors.New("item out of range") + } + return f.items[index], nil +} + +func (f *Feed) Updated(nt DesktopNotificationType) { + if len(f.Update) > 0 { + return + } + f.Update <- nt +} + +func (f *Feed) LoadNewer() { + if f.loadNewer == nil { + return + } + lock := f.loadingNewer.mux.TryLock() + if !lock { + return + } + if time.Since(f.loadingNewer.last) < (500 * time.Millisecond) { + f.loadingNewer.mux.Unlock() + return + } + f.loadNewer() + f.Updated(DekstopNotificationNone) + f.loadingNewer.last = time.Now() + f.loadingNewer.mux.Unlock() +} + +func (f *Feed) LoadOlder() { + if f.loadOlder == nil { + return + } + lock := f.loadingOlder.mux.TryLock() + if !lock { + return + } + if time.Since(f.loadingOlder.last) < (500 * time.Microsecond) { + f.loadingOlder.mux.Unlock() + return + } + f.loadOlder() + f.Updated(DekstopNotificationNone) + f.loadingOlder.last = time.Now() + f.loadingOlder.mux.Unlock() +} + +func (f *Feed) HasStream() bool { + return f.stream != nil +} + +func (f *Feed) Close() { + if f.close != nil { + f.close() + } +} + +func (f *Feed) Name() string { + return f.name +} + +func (f *Feed) singleNewerSearch(fn apiSearchFunc, search string) { + items, err := fn(search) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(items, f.items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) singleThread(fn apiThreadFunc, status *mastodon.Status) { + items, _, err := fn(status) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(items, f.items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) normalNewer(fn apiFunc) { + f.itemsMux.RLock() + pg := mastodon.Pagination{} + if len(f.items) > 0 { + switch item := f.items[0].Raw().(type) { + case *mastodon.Status: + pg.MinID = item.ID + case *api.NotificationData: + pg.MinID = item.Item.ID + } + } + f.itemsMux.RUnlock() + items, err := fn(&pg) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(items, f.items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) normalOlder(fn apiFunc) { + f.itemsMux.RLock() + pg := mastodon.Pagination{} + if len(f.items) > 0 { + switch item := f.items[len(f.items)-1].Raw().(type) { + case *mastodon.Status: + pg.MaxID = item.ID + case *api.NotificationData: + pg.MaxID = item.Item.ID + } + } + f.itemsMux.RUnlock() + items, err := fn(&pg) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(f.items, items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) newerSearchPG(fn apiSearchPGFunc, search string) { + f.itemsMux.RLock() + pg := mastodon.Pagination{} + if len(f.items) > 0 { + switch item := f.items[0].Raw().(type) { + case *mastodon.Status: + pg.MinID = item.ID + } + } + f.itemsMux.RUnlock() + items, err := fn(&pg, search) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(items, f.items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) olderSearchPG(fn apiSearchPGFunc, search string) { + f.itemsMux.RLock() + pg := mastodon.Pagination{} + if len(f.items) > 0 { + switch item := f.items[len(f.items)-1].Raw().(type) { + case *mastodon.Status: + pg.MaxID = item.ID + } + } + f.itemsMux.RUnlock() + items, err := fn(&pg, search) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(f.items, items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) normalNewerUser(fn apiIDFunc, id mastodon.ID) { + f.itemsMux.RLock() + pg := mastodon.Pagination{} + if len(f.items) > 1 { + switch item := f.items[1].Raw().(type) { + case *mastodon.Status: + pg.MinID = item.ID + } + } + f.itemsMux.RUnlock() + items, err := fn(&pg, id) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + newItems := []api.Item{f.items[0]} + newItems = append(newItems, items...) + if len(f.items) > 1 { + newItems = append(newItems, f.items[1:]...) + } + f.items = newItems + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) normalOlderUser(fn apiIDFunc, id mastodon.ID) { + f.itemsMux.RLock() + pg := mastodon.Pagination{} + if len(f.items) > 1 { + switch item := f.items[len(f.items)-1].Raw().(type) { + case *mastodon.Status: + pg.MaxID = item.ID + } + } + f.itemsMux.RUnlock() + items, err := fn(&pg, id) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(f.items, items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) normalNewerID(fn apiIDFunc, id mastodon.ID) { + f.itemsMux.RLock() + pg := mastodon.Pagination{} + if len(f.items) > 0 { + switch item := f.items[0].Raw().(type) { + case *mastodon.Status: + pg.MinID = item.ID + } + } + f.itemsMux.RUnlock() + items, err := fn(&pg, id) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(items, f.items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) normalOlderID(fn apiIDFunc, id mastodon.ID) { + f.itemsMux.RLock() + pg := mastodon.Pagination{} + if len(f.items) > 0 { + switch item := f.items[len(f.items)-1].Raw().(type) { + case *mastodon.Status: + pg.MaxID = item.ID + } + } + f.itemsMux.RUnlock() + items, err := fn(&pg, id) + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(f.items, items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) normalEmpty(fn apiEmptyFunc) { + items, err := fn() + if err != nil { + return + } + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(f.items, items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) linkNewer(fn apiFunc) { + f.apiDataMux.Lock() + pg := &mastodon.Pagination{} + pg.MinID = f.apiData.MinID + maxTmp := f.apiData.MaxID + + items, err := fn(pg) + if err != nil { + f.apiDataMux.Unlock() + return + } + f.apiData.MinID = pg.MinID + if pg.MaxID == "" { + f.apiData.MaxID = maxTmp + } else { + f.apiData.MaxID = pg.MaxID + } + f.apiDataMux.Unlock() + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(items, f.items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) linkOlder(fn apiFunc) { + f.apiDataMux.Lock() + pg := &mastodon.Pagination{} + pg.MaxID = f.apiData.MaxID + if pg.MaxID == "" { + f.apiDataMux.Unlock() + return + } + + items, err := fn(pg) + if err != nil { + f.apiDataMux.Unlock() + return + } + f.apiData.MaxID = pg.MaxID + f.apiDataMux.Unlock() + + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(f.items, items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) linkNewerID(fn apiIDFunc, id mastodon.ID) { + f.apiDataMux.Lock() + pg := &mastodon.Pagination{} + pg.MinID = f.apiData.MinID + maxTmp := f.apiData.MaxID + + items, err := fn(pg, id) + if err != nil { + f.apiDataMux.Unlock() + return + } + f.apiData.MinID = pg.MinID + if pg.MaxID == "" { + f.apiData.MaxID = maxTmp + } else { + f.apiData.MaxID = pg.MaxID + } + f.apiDataMux.Unlock() + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(items, f.items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) linkOlderID(fn apiIDFunc, id mastodon.ID) { + f.apiDataMux.Lock() + pg := &mastodon.Pagination{} + pg.MaxID = f.apiData.MaxID + if pg.MaxID == "" { + f.apiDataMux.Unlock() + return + } + + items, err := fn(pg, id) + if err != nil { + f.apiDataMux.Unlock() + return + } + f.apiData.MaxID = pg.MaxID + f.apiDataMux.Unlock() + + f.itemsMux.Lock() + if len(items) > 0 { + f.items = append(f.items, items...) + f.Updated(DekstopNotificationNone) + } + f.itemsMux.Unlock() +} + +func (f *Feed) startStream(rec *api.Receiver, err error) { + if err != nil { + log.Fatalln("Couldn't open stream") + } + f.stream = rec + go func() { + for e := range f.stream.Ch { + switch t := e.(type) { + case *mastodon.UpdateEvent: + s := api.NewStatusItem(t.Status) + f.itemsMux.Lock() + f.items = append([]api.Item{s}, f.items...) + f.Updated(DesktopNotificationPost) + f.itemsMux.Unlock() + } + } + }() +} + +func (f *Feed) startStreamNotification(rec *api.Receiver, err error) { + if err != nil { + log.Fatalln("Couldn't open stream") + } + f.stream = rec + go func() { + for e := range f.stream.Ch { + switch t := e.(type) { + case *mastodon.NotificationEvent: + rel, err := f.accountClient.Client.GetAccountRelationships(context.Background(), []string{string(t.Notification.Account.ID)}) + if err != nil { + continue + } + if len(rel) == 0 { + log.Fatalln(t.Notification.Account.Acct) + continue + } + s := api.NewNotificationItem(t.Notification, + &api.User{ + Data: &t.Notification.Account, + Relation: rel[0], + }) + f.itemsMux.Lock() + f.items = append([]api.Item{s}, f.items...) + nft := DekstopNotificationNone + switch t.Notification.Type { + case "follow", "follow_request": + nft = DesktopNotificationFollower + case "favourite": + nft = DesktopNotificationFollower + case "reblog": + nft = DesktopNotificationBoost + case "mention": + nft = DesktopNotificationMention + case "status": + nft = DesktopNotificationPost + case "poll": + nft = DesktopNotificationPoll + } + f.Updated(nft) + f.itemsMux.Unlock() + } + } + }() +} + +func NewTimelineHome(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: TimelineHome, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.normalNewer(feed.accountClient.GetTimeline) } + feed.loadOlder = func() { feed.normalOlder(feed.accountClient.GetTimeline) } + feed.startStream(feed.accountClient.NewHomeStream()) + feed.close = func() { feed.accountClient.RemoveHomeReceiver(feed.stream) } + + return feed +} + +func NewTimelineFederated(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: TimelineFederated, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.normalNewer(feed.accountClient.GetTimelineFederated) } + feed.loadOlder = func() { feed.normalOlder(feed.accountClient.GetTimelineFederated) } + feed.startStream(feed.accountClient.NewFederatedStream()) + feed.close = func() { feed.accountClient.RemoveFederatedReceiver(feed.stream) } + + return feed +} + +func NewTimelineLocal(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: TimelineLocal, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.normalNewer(feed.accountClient.GetTimelineLocal) } + feed.loadOlder = func() { feed.normalOlder(feed.accountClient.GetTimelineLocal) } + feed.startStream(feed.accountClient.NewLocalStream()) + feed.close = func() { feed.accountClient.RemoveFederatedReceiver(feed.stream) } + + return feed +} + +func NewConversations(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Conversations, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.normalNewer(feed.accountClient.GetConversations) } + feed.loadOlder = func() { feed.normalOlder(feed.accountClient.GetConversations) } + feed.startStream(feed.accountClient.NewDirectStream()) + feed.close = func() { feed.accountClient.RemoveFederatedReceiver(feed.stream) } + + return feed +} + +func NewNotifications(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Notification, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.normalNewer(feed.accountClient.GetNotifications) } + feed.loadOlder = func() { feed.normalOlder(feed.accountClient.GetNotifications) } + feed.startStreamNotification(feed.accountClient.NewHomeStream()) + feed.close = func() { feed.accountClient.RemoveHomeReceiver(feed.stream) } + + return feed +} + +func NewFavorites(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Favorited, + items: make([]api.Item, 0), + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.linkNewer(feed.accountClient.GetFavorites) } + feed.loadOlder = func() { feed.linkOlder(feed.accountClient.GetFavorites) } + + return feed +} + +func NewBookmarks(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Favorites, + items: make([]api.Item, 0), + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.linkNewer(feed.accountClient.GetBookmarks) } + feed.loadOlder = func() { feed.linkOlder(feed.accountClient.GetBookmarks) } + + return feed +} + +func NewUserSearch(ac *api.AccountClient, search string) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: UserList, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + name: search, + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.singleNewerSearch(feed.accountClient.GetUsers, search) } + + return feed +} + +func NewUserProfile(ac *api.AccountClient, user *api.User) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: User, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + feed.items = append(feed.items, api.NewUserItem(user, true)) + feed.loadNewer = func() { feed.normalNewerUser(feed.accountClient.GetUser, user.Data.ID) } + feed.loadOlder = func() { feed.normalOlderUser(feed.accountClient.GetUser, user.Data.ID) } + + return feed +} + +func NewThread(ac *api.AccountClient, status *mastodon.Status) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Thread, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.singleThread(feed.accountClient.GetThread, status) } + + return feed +} + +func NewTag(ac *api.AccountClient, search string) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Tag, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + name: search, + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.newerSearchPG(feed.accountClient.GetTag, search) } + feed.loadOlder = func() { feed.olderSearchPG(feed.accountClient.GetTag, search) } + feed.startStream(feed.accountClient.NewTagStream(search)) + feed.close = func() { feed.accountClient.RemoveTagReceiver(feed.stream, search) } + + return feed +} + +func NewListList(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Lists, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.normalEmpty(feed.accountClient.GetLists) } + + return feed +} + +func NewList(ac *api.AccountClient, list *mastodon.List) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: List, + items: make([]api.Item, 0), + loadOlder: func() {}, + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + name: list.Title, + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + feed.loadNewer = func() { feed.normalNewerID(feed.accountClient.GetListStatuses, list.ID) } + feed.loadOlder = func() { feed.normalOlderID(feed.accountClient.GetListStatuses, list.ID) } + feed.startStream(feed.accountClient.NewListStream(list.ID)) + feed.close = func() { feed.accountClient.RemoveListReceiver(feed.stream, list.ID) } + + return feed +} + +func NewFavoritesStatus(ac *api.AccountClient, id mastodon.ID) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Favorites, + items: make([]api.Item, 0), + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadOlder: func() {}, + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + once := true + feed.loadNewer = func() { + if once { + feed.linkNewerID(feed.accountClient.GetFavoritesStatus, id) + } + once = false + } + + return feed +} + +func NewBoosts(ac *api.AccountClient, id mastodon.ID) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Boosts, + items: make([]api.Item, 0), + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadOlder: func() {}, + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + once := true + feed.loadNewer = func() { + if once { + feed.linkNewerID(feed.accountClient.GetBoostsStatus, id) + } + once = false + } + + return feed +} + +func NewFollowers(ac *api.AccountClient, id mastodon.ID) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Followers, + items: make([]api.Item, 0), + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + once := true + feed.loadNewer = func() { + if once { + feed.linkNewerID(feed.accountClient.GetFollowers, id) + } + once = false + } + feed.loadOlder = func() { feed.linkOlderID(feed.accountClient.GetFollowers, id) } + + return feed +} + +func NewFollowing(ac *api.AccountClient, id mastodon.ID) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Following, + items: make([]api.Item, 0), + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + once := true + feed.loadNewer = func() { + if once { + feed.linkNewerID(feed.accountClient.GetFollowing, id) + } + once = false + } + feed.loadOlder = func() { feed.linkOlderID(feed.accountClient.GetFollowing, id) } + + return feed +} + +func NewBlocking(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Blocking, + items: make([]api.Item, 0), + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + feed.loadNewer = func() { feed.linkNewer(feed.accountClient.GetBlocking) } + feed.loadOlder = func() { feed.linkOlder(feed.accountClient.GetBlocking) } + + once := true + feed.loadNewer = func() { + if once { + feed.linkNewer(feed.accountClient.GetBlocking) + } + once = false + } + feed.loadOlder = func() { feed.linkOlder(feed.accountClient.GetBlocking) } + + return feed +} + +func NewMuting(ac *api.AccountClient) *Feed { + feed := &Feed{ + accountClient: ac, + feedType: Muting, + items: make([]api.Item, 0), + apiData: &api.RequestData{}, + Update: make(chan DesktopNotificationType, 1), + loadingNewer: &LoadingLock{}, + loadingOlder: &LoadingLock{}, + } + + once := true + feed.loadNewer = func() { + if once { + feed.linkNewer(feed.accountClient.GetMuting) + } + once = false + } + feed.loadOlder = func() { feed.linkOlder(feed.accountClient.GetMuting) } + + return feed +} diff --git a/go.mod b/go.mod index aec2248..9e7697a 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,35 @@ module github.com/RasmusLindroth/tut -go 1.16 +go 1.17 require ( + github.com/RasmusLindroth/go-mastodon v0.0.5 github.com/atotto/clipboard v0.1.4 - github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 - github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1 + github.com/gdamore/tcell/v2 v2.5.1 + github.com/gen2brain/beeep v0.0.0-20220402123239-6a3042f4b71a github.com/gobwas/glob v0.2.3 - github.com/godbus/dbus/v5 v5.0.6 // indirect - github.com/gopherjs/gopherjs v0.0.0-20211111143520-d0d5ecc1a356 // indirect - github.com/icza/gox v0.0.0-20210726201659-cd40a3f8d324 - github.com/mattn/go-mastodon v0.0.5-0.20211104150201-58c389181352 - github.com/microcosm-cc/bluemonday v1.0.16 - github.com/pelletier/go-toml/v2 v2.0.0-beta.4 - github.com/rivo/tview v0.0.0-20211202162923-2a6de950f73b + github.com/icza/gox v0.0.0-20220321141217-e2d488ab2fbc + github.com/microcosm-cc/bluemonday v1.0.18 + github.com/pelletier/go-toml/v2 v2.0.0-beta.8 + github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 github.com/rivo/uniseg v0.2.0 - golang.org/x/net v0.0.0-20211203184738-4852103109b8 - golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/net v0.0.0-20210614182718-04defd469f4e + gopkg.in/ini.v1 v1.66.4 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect + github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect + golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect + golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect golang.org/x/text v0.3.7 // indirect - gopkg.in/ini.v1 v1.66.2 ) diff --git a/go.sum b/go.sum index eb4b092..2bc90ee 100644 --- a/go.sum +++ b/go.sum @@ -1,667 +1,81 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= -github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/RasmusLindroth/go-mastodon v0.0.5 h1:GHyyv2qc4X9XmUfM1IytRjaU5bWqMoPD+wM+Wvkc/4U= +github.com/RasmusLindroth/go-mastodon v0.0.5/go.mod h1:4L0oyiNwq1tUoiByczzhSikxR9RiANzELtZgexxKpPM= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 h1:QqwPZCwh/k1uYqq6uXSb9TRDhTkfQbO80v8zhnIe5zM= github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= -github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1 h1:Xh9mvwEmhbdXlRSsgn+N0zj/NqnKvpeqL08oKDHln2s= -github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1/go.mod h1:ElSskYZe3oM8kThaHGJ+kiN2yyUMVXMZ7WxF9QqLDS8= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/gdamore/tcell/v2 v2.5.1 h1:zc3LPdpK184lBW7syF2a5C6MV827KmErk9jGVnmsl/I= +github.com/gdamore/tcell/v2 v2.5.1/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= +github.com/gen2brain/beeep v0.0.0-20220402123239-6a3042f4b71a h1:fwNLHrP5Rbg/mGSXCjtPdpbqv2GucVTA/KMi8wEm6mE= +github.com/gen2brain/beeep v0.0.0-20220402123239-6a3042f4b71a/go.mod h1:/WeFVhhxMOGypVKS0w8DUJxUBbHypnWkUVnW7p5c9Pw= github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= -github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20211111143520-d0d5ecc1a356 h1:d3wWSjdOuGrMHa8+Tvw3z9EGPzATpzVq1BmGK3+IyeU= -github.com/gopherjs/gopherjs v0.0.0-20211111143520-d0d5ecc1a356/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI= -github.com/gopherjs/gopherwasm v1.1.0 h1:fA2uLoctU5+T3OhOn2vYP0DVT6pxc7xhTlBB1paATqQ= -github.com/gopherjs/gopherwasm v1.1.0/go.mod h1:SkZ8z7CWBz5VXbhJel8TxCmAcsQqzgWGR/8nMhyhZSI= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/icza/gox v0.0.0-20210726201659-cd40a3f8d324 h1:vgRDKn3I9l793fk4V5omw4ADVOXS1F8F6HBvi+ZXNdM= -github.com/icza/gox v0.0.0-20210726201659-cd40a3f8d324/go.mod h1:VbcN86fRkkUMPX2ufM85Um8zFndLZswoIW1eYtpAcVk= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/icza/gox v0.0.0-20220321141217-e2d488ab2fbc h1:/vPVDa098f2SM/Ef3NtrWiyo4UBWL+QkD4hodlNpha8= +github.com/icza/gox v0.0.0-20220321141217-e2d488ab2fbc/go.mod h1:VbcN86fRkkUMPX2ufM85Um8zFndLZswoIW1eYtpAcVk= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-mastodon v0.0.5-0.20211104150201-58c389181352 h1:zxF8a6o96VQe9ZV5gMfdhw/2Mo744rCIM0SprndUyAw= -github.com/mattn/go-mastodon v0.0.5-0.20211104150201-58c389181352/go.mod h1:D8ScK24P+nSB6g5xdsi/m40TIWispU4yyhJw9rGgHx4= -github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= -github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc= -github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= -github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/microcosm-cc/bluemonday v1.0.18 h1:6HcxvXDAi3ARt3slx6nTesbvorIc3QeTzBNRvWktHBo= +github.com/microcosm-cc/bluemonday v1.0.18/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= -github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.0.0-beta.4 h1:GCs8ebsDtEH3RiO78+BvhHqj65d/I6tjESitJZc07Rc= -github.com/pelletier/go-toml/v2 v2.0.0-beta.4/go.mod h1:ke6xncR3W76Ba8xnVxkrZG0js6Rd2BsQEAYrfgJ6eQA= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pelletier/go-toml/v2 v2.0.0-beta.8 h1:dy81yyLYJDwMTifq24Oi/IslOslRrDSb3jwDggjz3Z0= +github.com/pelletier/go-toml/v2 v2.0.0-beta.8/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rivo/tview v0.0.0-20211202162923-2a6de950f73b h1:EMgbQ+bOHWkl0Ptano8M0yrzVZkxans+Vfv7ox/EtO8= -github.com/rivo/tview v0.0.0-20211202162923-2a6de950f73b/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk= +github.com/rivo/tview v0.0.0-20211129142845-821b2667c414 h1:8pLxYvjWizid9rNUDyWv9D4gti+/w+TK7P10eXnh+xA= +github.com/rivo/tview v0.0.0-20211129142845-821b2667c414/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk= +github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 h1:xe+mmCnDN82KhC010l3NfYlA8ZbOuzbXAzSYBa6wbMc= +github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= -github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942 h1:t0lM6y/M5IiUZyvbBTcngso8SZEZICH7is9B6g/obVU= -github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= -github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211101193420-4a448f8816b3/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211203184738-4852103109b8 h1:PFkPt/jI9Del3hmFplBtRp8tDhSRpFu7CyRs7VmEC0M= -golang.org/x/net v0.0.0-20211203184738-4852103109b8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 h1:TyHqChC80pFkXWraUUf6RuB5IqFdQieMLwwCJokV2pc= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= +golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI= -gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/ini.v1 v1.66.4 h1:SsAcf+mM7mRZo2nJNGt8mZCjG8ZRaNGMURJw7BsIST4= +gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/helpoverlay.go b/helpoverlay.go deleted file mode 100644 index 5d446d3..0000000 --- a/helpoverlay.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "bytes" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func NewHelpOverlay(app *App) *HelpOverlay { - h := &HelpOverlay{ - app: app, - Flex: tview.NewFlex(), - TextMain: tview.NewTextView(), - TextBottom: tview.NewTextView(), - } - - h.TextMain.SetBackgroundColor(app.Config.Style.Background) - h.TextMain.SetDynamicColors(true) - h.TextBottom.SetBackgroundColor(app.Config.Style.Background) - h.TextBottom.SetDynamicColors(true) - h.TextBottom.SetText(ColorKey(app.Config, "", "Q", "uit")) - h.Flex.SetDrawFunc(app.Config.ClearContent) - - hd := HelpData{ - Style: app.Config.Style, - } - var output bytes.Buffer - err := app.Config.Templates.HelpTemplate.ExecuteTemplate(&output, "help.tmpl", hd) - if err != nil { - panic(err) - } - h.TextMain.SetText(output.String()) - - return h -} - -type HelpData struct { - Style StyleConfig -} - -type HelpOverlay struct { - app *App - Flex *tview.Flex - TextMain *tview.TextView - TextBottom *tview.TextView -} - -func (h *HelpOverlay) InputHandler(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'q', 'Q': - h.app.UI.StatusView.giveBackFocus() - return nil - } - } else { - switch event.Key() { - case tcell.KeyEsc: - h.app.UI.StatusView.giveBackFocus() - return nil - } - } - return event -} diff --git a/linkoverlay.go b/linkoverlay.go deleted file mode 100644 index 324d1f6..0000000 --- a/linkoverlay.go +++ /dev/null @@ -1,211 +0,0 @@ -package main - -import ( - "fmt" - "strconv" - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/mattn/go-mastodon" - "github.com/rivo/tview" -) - -func NewLinkOverlay(app *App) *LinkOverlay { - l := &LinkOverlay{ - app: app, - Flex: tview.NewFlex(), - TextBottom: tview.NewTextView(), - List: tview.NewList(), - } - - 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) - l.List.SetSelectedTextColor(app.Config.Style.ListSelectedText) - l.List.ShowSecondaryText(false) - l.List.SetHighlightFullLine(true) - l.Flex.SetDrawFunc(app.Config.ClearContent) - var items []string - items = append(items, ColorKey(app.Config, "", "O", "pen")) - items = append(items, ColorKey(app.Config, "", "Y", "ank")) - for _, cust := range app.Config.OpenCustom.OpenCustoms { - items = append(items, ColorKey(app.Config, "", fmt.Sprintf("%d", cust.Index), cust.Name)) - } - l.TextBottom.SetText(strings.Join(items, " ")) - return l -} - -type LinkOverlay struct { - app *App - Flex *tview.Flex - TextBottom *tview.TextView - List *tview.List - urls []URL - mentions []mastodon.Mention - tags []mastodon.Tag -} - -func (l *LinkOverlay) SetLinks(urls []URL, status *mastodon.Status) { - realUrls := []URL{} - l.urls = []URL{} - l.mentions = []mastodon.Mention{} - l.tags = []mastodon.Tag{} - - if urls != nil { - if status != nil { - for _, url := range urls { - isNotMention := true - for _, mention := range status.Mentions { - if mention.URL == url.URL { - isNotMention = false - } - } - if isNotMention { - realUrls = append(realUrls, url) - } - } - - } else { - realUrls = urls - } - l.urls = realUrls - } - - if status != nil { - l.mentions = status.Mentions - l.tags = status.Tags - } - - l.List.Clear() - for _, url := range realUrls { - l.List.AddItem(url.Text, "", 0, nil) - } - for _, mention := range l.mentions { - l.List.AddItem(mention.Acct, "", 0, nil) - } - for _, tag := range l.tags { - l.List.AddItem("#"+tag.Name, "", 0, nil) - } -} - -func (l *LinkOverlay) Prev() { - index := l.List.GetCurrentItem() - if index-1 >= 0 { - l.List.SetCurrentItem(index - 1) - } -} - -func (l *LinkOverlay) Next() { - index := l.List.GetCurrentItem() - if index+1 < l.List.GetItemCount() { - l.List.SetCurrentItem(index + 1) - } -} - -func (l *LinkOverlay) Open() { - index := l.List.GetCurrentItem() - total := len(l.urls) + len(l.mentions) + len(l.tags) - if total == 0 || index >= total { - return - } - if index < len(l.urls) { - openURL(l.app.UI.Root, l.app.Config.Media, l.app.Config.OpenPattern, l.urls[index].URL) - return - } - mIndex := index - len(l.urls) - if mIndex < len(l.mentions) { - u, err := l.app.API.GetUserByID(l.mentions[mIndex].ID) - if err != nil { - l.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't load user. Error: %v\n", err)) - return - } - l.app.UI.StatusView.AddFeed( - NewUserFeed(l.app, *u), - ) - l.app.UI.SetFocus(LeftPaneFocus) - return - } - tIndex := index - len(l.mentions) - len(l.urls) - if tIndex < len(l.tags) { - l.app.UI.StatusView.AddFeed( - NewTagFeed(l.app, l.tags[tIndex].Name), - ) - l.app.UI.SetFocus(LeftPaneFocus) - } -} - -func (l *LinkOverlay) CopyToClipboard() { - text := l.GetURL() - if text != "" { - e := copyToClipboard(text) - if !e { - l.app.UI.CmdBar.ShowError("Couldn't copy to clipboard.") - } - } -} - -func (l *LinkOverlay) GetURL() string { - index := l.List.GetCurrentItem() - total := len(l.urls) + len(l.mentions) + len(l.tags) - if total == 0 || index >= total { - return "" - } - if index < len(l.urls) { - return l.urls[index].URL - } - mIndex := index - len(l.urls) - if mIndex < len(l.mentions) { - return l.mentions[mIndex].URL - } - tIndex := index - len(l.mentions) - len(l.urls) - if tIndex < len(l.tags) { - return l.tags[tIndex].URL - } - return "" -} - -func (l *LinkOverlay) OpenCustom(index int) { - url := l.GetURL() - customs := l.app.Config.OpenCustom.OpenCustoms - for _, c := range customs { - if c.Index != index { - continue - } - openCustom(l.app.UI.Root, c.Program, c.Args, c.Terminal, url) - return - } -} - -func (l *LinkOverlay) InputHandler(event *tcell.EventKey) { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'j', 'J': - l.Next() - case 'k', 'K': - l.Prev() - case 'o', 'O': - l.Open() - case 'y', 'Y': - l.CopyToClipboard() - case '1', '2', '3', '4', '5': - s := string(event.Rune()) - i, _ := strconv.Atoi(s) - l.OpenCustom(i) - case 'q', 'Q': - l.app.UI.StatusView.giveBackFocus() - } - } else { - switch event.Key() { - case tcell.KeyEnter: - l.Open() - case tcell.KeyUp: - l.Prev() - case tcell.KeyDown: - l.Next() - case tcell.KeyEsc: - l.app.UI.StatusView.giveBackFocus() - } - } -} diff --git a/main.go b/main.go index 23c976c..fa87ac5 100644 --- a/main.go +++ b/main.go @@ -1,338 +1,44 @@ package main import ( - "fmt" - "log" "os" - "strings" - "github.com/gdamore/tcell/v2" + "github.com/RasmusLindroth/tut/auth" + "github.com/RasmusLindroth/tut/config" + "github.com/RasmusLindroth/tut/ui" + "github.com/rivo/tview" ) -const version string = "0.0.46" +const version = "1.0.0" func main() { - newUser := false - selectedUser := "" - if len(os.Args) > 1 { - switch os.Args[1] { - case "example-config": - CreateDefaultConfig("./config.example.ini") - os.Exit(0) - case "--new-user", "-n": - newUser = true - case "--user", "-u": - if len(os.Args) > 2 { - name := os.Args[2] - selectedUser = strings.TrimSpace(name) - } else { - log.Fatalln("--user/-u must be followed by a user name. Like -u tut") - } - case "--help", "-h": - fmt.Print("tut - a TUI for Mastodon with vim inspired keys.\n\n") - fmt.Print("Usage:\n\n") - fmt.Print("\tTo run the program you just have to write tut\n\n") - - fmt.Print("Commands:\n\n") - fmt.Print("\texample-config - creates the default configuration file in the current directory and names it ./config.example.ini\n\n") - - fmt.Print("Flags:\n\n") - fmt.Print("\t--help -h - prints this message\n") - fmt.Print("\t--version -v - prints the version\n") - fmt.Print("\t--new-user -n - add one more user to tut\n") - fmt.Print("\t--user -u - login directly to user named \n") - fmt.Print("\t\tDon't use a = between --user and the \n") - fmt.Print("\t\tIf two users are named the same. Use full name like tut@fosstodon.org\n\n") - - fmt.Print("Configuration:\n\n") - fmt.Printf("\tThe config is located in XDG_CONFIG_HOME/tut/config.ini which usally equals to ~/.config/tut/config.ini.\n") - fmt.Printf("\tThe program will generate the file the first time you run tut. The file has comments which exmplains what each configuration option does.\n\n") - - fmt.Print("Contact info for issues or questions:\n\n") - fmt.Printf("\t@rasmus@mastodon.acc.sunet.se\n\trasmus@lindroth.xyz\n") - fmt.Printf("\thttps://github.com/RasmusLindroth/tut\n") - os.Exit(0) - case "--version", "-v": - fmt.Printf("tut version %s\n\n", version) - fmt.Printf("https://github.com/RasmusLindroth/tut\n") - os.Exit(0) - } - } - - err := CreateConfigDir() - if err != nil { - log.Fatalln( - fmt.Sprintf("Couldn't create or access the configuration dir. Error: %v", err), - ) - } - path, exists, err := CheckConfig("config.ini") - if err != nil { - log.Fatalln( - fmt.Sprintf("Couldn't access config.ini. Error: %v", err), - ) - } - if !exists { - err = CreateDefaultConfig(path) - if err != nil { - log.Fatalf("Couldn't create default config. Error: %v", err) - } - } - config, err := ParseConfig(path) - if err != nil { - log.Fatalf("Couldn't open or parse the config. Error: %v", err) - } - - app := &App{ - API: &API{}, - HaveAccount: false, - Config: &config, - Accounts: &AccountData{}, - } - - app.UI = NewUI(app) - app.UI.Init() - - path, exists, err = CheckConfig("accounts.toml") - if err != nil { - log.Fatalln( - fmt.Sprintf("Couldn't access accounts.toml. Error: %v", err), - ) - } - - if exists { - app.Accounts, err = GetAccounts(path) - if err != nil { - log.Fatalln( - fmt.Sprintf("Couldn't access accounts.toml. Error: %v", err), - ) - } - if len(app.Accounts.Accounts) == 1 && !newUser { - app.Login(0) - } - } - - if len(app.Accounts.Accounts) > 1 && !newUser { - if selectedUser != "" { - useHost := false - found := false - if strings.Contains(selectedUser, "@") { - useHost = true - } - for i, acc := range app.Accounts.Accounts { - accName := acc.Name - if useHost { - host := strings.TrimPrefix(acc.Server, "https://") - host = strings.TrimPrefix(host, "http://") - accName += "@" + host - } - if accName == selectedUser { - app.Login(i) - app.UI.LoggedIn() - found = true - } - } - if !found { - log.Fatalf("Couldn't find a user named %s. Try again", selectedUser) - } - } else { - app.UI.SetFocus(UserSelectFocus) - } - } else if !app.HaveAccount || newUser { - app.UI.SetFocus(AuthOverlayFocus) - } else { - app.UI.LoggedIn() - } - - app.FileList = []string{} - - app.UI.Root.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if !app.HaveAccount { - if app.UI.Focus == UserSelectFocus { - app.UI.UserSelectOverlay.InputHandler(event) - return nil - } else { - return event - } - } - - if app.UI.Focus == LinkOverlayFocus { - app.UI.LinkOverlay.InputHandler(event) - return nil - } - - if app.UI.Focus == VisibilityOverlayFocus { - app.UI.VisibilityOverlay.InputHandler(event) - return nil - } - - if app.UI.Focus == HelpOverlayFocus { - ev := app.UI.HelpOverlay.InputHandler(event) - return ev - } - - if app.UI.Focus == VoteOverlayFocus { - app.UI.VoteOverlay.InputHandler(event) - return nil - } - - if app.UI.Focus == CmdBarFocus { - switch event.Key() { - case tcell.KeyEnter: - app.UI.CmdBar.DoneFunc(tcell.KeyEnter) - case tcell.KeyEsc: - app.UI.CmdBar.ClearInput() - app.UI.SetFocus(LeftPaneFocus) - return nil - } - return event - } - - if app.UI.Focus == MessageFocus { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'p', 'P': - app.UI.MessageBox.Post() - return nil - case 'e', 'E': - app.UI.MessageBox.EditText() - return nil - case 'c', 'C': - app.UI.MessageBox.EditSpoiler() - return nil - case 't', 'T': - app.UI.MessageBox.ToggleSpoiler() - return nil - case 'i', 'I': - app.UI.MessageBox.IncludeQuote() - return nil - case 'm', 'M': - app.UI.SetFocus(MessageAttachmentFocus) - return nil - case 'v', 'V': - app.UI.SetFocus(VisibilityOverlayFocus) - return nil - case 'q', 'Q': - app.UI.StatusView.giveBackFocus() - return nil - } - } else { - switch event.Key() { - case tcell.KeyEsc: - app.UI.StatusView.giveBackFocus() - return nil - } - } - return event - } - - if app.UI.Focus == MessageAttachmentFocus && app.UI.MediaOverlay.Focus == MediaFocusOverview { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'j', 'J': - app.UI.MediaOverlay.Next() - case 'k', 'K': - app.UI.MediaOverlay.Prev() - case 'd', 'D': - app.UI.MediaOverlay.Delete() - case 'e', 'E': - app.UI.MediaOverlay.EditDesc() - case 'a', 'A': - app.UI.MediaOverlay.SetFocus(MediaFocusAdd) - return nil - case 'q', 'Q': - app.UI.SetFocus(MessageFocus) - return nil - } - } else { - switch event.Key() { - case tcell.KeyUp: - app.UI.MediaOverlay.Prev() - case tcell.KeyDown: - app.UI.MediaOverlay.Next() - case tcell.KeyEsc: - app.UI.SetFocus(MessageFocus) - return nil - } - } - return event - } - - if app.UI.Focus == MessageAttachmentFocus && app.UI.MediaOverlay.Focus == MediaFocusAdd { - if event.Key() == tcell.KeyRune { - app.UI.MediaOverlay.InputField.AddRune(event.Rune()) - return nil - } - switch event.Key() { - case tcell.KeyTAB: - app.UI.MediaOverlay.InputField.AutocompleteTab() - return nil - case tcell.KeyDown: - app.UI.MediaOverlay.InputField.AutocompleteNext() - return nil - case tcell.KeyBacktab, tcell.KeyUp: - app.UI.MediaOverlay.InputField.AutocompletePrev() - return nil - case tcell.KeyEnter: - app.UI.MediaOverlay.InputField.CheckDone() - return nil - case tcell.KeyEsc: - app.UI.MediaOverlay.SetFocus(MediaFocusOverview) - } - return event - } - - if app.UI.Focus == LeftPaneFocus || app.UI.Focus == RightPaneFocus || app.UI.Focus == NotificationPaneFocus { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case ':': - app.UI.CmdBar.ClearInput() - app.UI.CmdBar.Input.SetText(":") - app.UI.SetFocus(CmdBarFocus) - return nil - } - } - return app.UI.StatusView.Input(event) - } - return event - }) - - app.UI.MediaOverlay.InputField.View.SetChangedFunc( - app.UI.MediaOverlay.InputField.HandleChanges, - ) - - app.UI.CmdBar.Input.SetAutocompleteFunc(func(currentText string) (entries []string) { - words := strings.Split(":blocking,:boosts,:bookmarks,:compose,:favorites,:favorited,:help,:h,:lists,:muting,:profile,:saved,:tag,:timeline,:tl,:user,:quit,:q", ",") - if currentText == "" { - return - } - - if len(currentText) > 2 && currentText[:3] == ":tl" { - words = strings.Split(":tl home,:tl notifications,:tl local,:tl federated,:tl direct,:tl favorited", ",") - } - if len(currentText) > 8 && currentText[:9] == ":timeline" { - words = strings.Split(":timeline home,:timeline notifications,:timeline local,:timeline federated,:timeline direct,:timeline favorited", ",") - } - - for _, word := range words { - if strings.HasPrefix(strings.ToLower(word), strings.ToLower(currentText)) { - entries = append(entries, word) - } - } - if len(entries) < 1 { - entries = nil - } - return - }) - - app.UI.AuthOverlay.Input.SetDoneFunc(func(key tcell.Key) { - app.UI.AuthOverlay.GotInput() - }) - - if err := app.UI.Root.SetRoot(app.UI.Pages, true).Run(); err != nil { + newUser, selectedUser := ui.CliView(version) + accs := auth.StartAuth(newUser) + + app := tview.NewApplication() + t := &ui.Tut{ + App: app, + Config: config.Load(), + } + tview.Styles = tview.Theme{ + PrimitiveBackgroundColor: t.Config.Style.Background, // background + ContrastBackgroundColor: t.Config.Style.Text, //background for button, checkbox, form, modal + MoreContrastBackgroundColor: t.Config.Style.Text, //background for dropdown + BorderColor: t.Config.Style.Background, //border + TitleColor: t.Config.Style.Text, //titles + GraphicsColor: t.Config.Style.Text, //borders + PrimaryTextColor: t.Config.Style.StatusBarViewBackground, //backround color selected + SecondaryTextColor: t.Config.Style.Text, //text + TertiaryTextColor: t.Config.Style.Text, //list secondary + InverseTextColor: t.Config.Style.Text, //label activated + ContrastSecondaryTextColor: t.Config.Style.Text, //foreground on input and prefix on dropdown + } + main := ui.NewTutView(t, accs, selectedUser) + app.SetInputCapture(main.Input) + if err := app.SetRoot(main.View, true).Run(); err != nil { panic(err) } - - for _, f := range app.FileList { + for _, f := range main.FileList { os.Remove(f) } } diff --git a/media.go b/media.go deleted file mode 100644 index addf465..0000000 --- a/media.go +++ /dev/null @@ -1,272 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/rivo/tview" -) - -type MediaFocus int - -const ( - MediaFocusOverview MediaFocus = iota - MediaFocusAdd -) - -func NewMediaOverlay(app *App) *MediaView { - m := &MediaView{ - app: app, - Flex: tview.NewFlex(), - TextTop: tview.NewTextView(), - TextBottom: tview.NewTextView(), - FileList: tview.NewList(), - InputField: &MediaInput{app: app, View: tview.NewInputField()}, - Focus: MediaFocusOverview, - } - m.Flex.SetBackgroundColor(app.Config.Style.Background) - - m.FileList.SetBackgroundColor(app.Config.Style.Background) - m.FileList.SetMainTextColor(app.Config.Style.Text) - m.FileList.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground) - m.FileList.SetSelectedTextColor(app.Config.Style.ListSelectedText) - m.FileList.ShowSecondaryText(false) - m.FileList.SetHighlightFullLine(true) - - m.TextTop.SetBackgroundColor(app.Config.Style.Background) - m.TextTop.SetTextColor(app.Config.Style.Subtle) - - 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) - m.InputField.View.SetFieldTextColor(app.Config.Style.Text) - - m.Flex.SetDrawFunc(app.Config.ClearContent) - - m.Draw() - return m -} - -type MediaView struct { - app *App - Flex *tview.Flex - TextTop *tview.TextView - TextBottom *tview.TextView - FileList *tview.List - InputField *MediaInput - Focus MediaFocus - Files []UploadFile -} - -type UploadFile struct { - Path string - Description string -} - -func (m *MediaView) Reset() { - m.Files = nil - m.FileList.Clear() - m.Focus = MediaFocusOverview - m.Draw() -} - -func (m *MediaView) AddFile(f string) { - file := UploadFile{Path: f} - m.Files = append(m.Files, file) - m.FileList.AddItem(filepath.Base(f), "", 0, nil) - index := m.FileList.GetItemCount() - m.FileList.SetCurrentItem(index - 1) - m.Draw() -} - -func (m *MediaView) Draw() { - topText := "Current file description: " - - index := m.FileList.GetCurrentItem() - if len(m.Files) != 0 && index < len(m.Files) && m.Files[index].Description != "" { - topText += tview.Escape(m.Files[index].Description) - } - - m.TextTop.SetText(topText) - - var items []string - items = append(items, ColorKey(m.app.Config, "", "A", "dd file")) - items = append(items, ColorKey(m.app.Config, "", "D", "elete file")) - items = append(items, ColorKey(m.app.Config, "", "E", "dit desc")) - items = append(items, ColorKey(m.app.Config, "", "Esc", " Done")) - m.TextBottom.SetText(strings.Join(items, " ")) - m.app.UI.ShouldSync() -} - -func (m *MediaView) SetFocus(f MediaFocus) { - switch f { - case MediaFocusOverview: - m.InputField.View.SetText("") - m.app.UI.Root.SetFocus(m.FileList) - case MediaFocusAdd: - m.app.UI.Root.SetFocus(m.InputField.View) - pwd, err := os.Getwd() - if err != nil { - home, err := os.UserHomeDir() - if err != nil { - pwd = "" - } else { - pwd = home - } - } - if !strings.HasSuffix(pwd, "/") { - pwd += "/" - } - m.InputField.View.SetText(pwd) - } - m.Focus = f -} - -func (m *MediaView) Prev() { - index := m.FileList.GetCurrentItem() - if index-1 >= 0 { - m.FileList.SetCurrentItem(index - 1) - } - m.Draw() -} - -func (m *MediaView) Next() { - index := m.FileList.GetCurrentItem() - if index+1 < m.FileList.GetItemCount() { - m.FileList.SetCurrentItem(index + 1) - } - m.Draw() -} - -func (m *MediaView) Delete() { - index := m.FileList.GetCurrentItem() - if len(m.Files) == 0 || index > len(m.Files) { - return - } - m.FileList.RemoveItem(index) - m.Files = append(m.Files[:index], m.Files[index+1:]...) - m.Draw() -} - -func (m *MediaView) EditDesc() { - index := m.FileList.GetCurrentItem() - if len(m.Files) == 0 || index > len(m.Files) { - return - } - file := m.Files[index] - desc, err := openEditor(m.app.UI.Root, file.Description) - if err != nil { - m.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't edit description. Error: %v\n", err)) - m.Draw() - return - } - file.Description = desc - m.Files[index] = file - m.Draw() -} - -type MediaInput struct { - app *App - View *tview.InputField - text string - autocompleteIndex int - autocompleteList []string - isAutocompleteChange bool -} - -func (m *MediaInput) AddRune(r rune) { - newText := m.View.GetText() + string(r) - m.text = newText - m.View.SetText(m.text) - m.saveAutocompleteState() -} - -func (m *MediaInput) HandleChanges(text string) { - if m.isAutocompleteChange { - m.isAutocompleteChange = false - return - } - m.saveAutocompleteState() -} - -func (m *MediaInput) saveAutocompleteState() { - text := m.View.GetText() - m.text = text - m.autocompleteList = FindFiles(text) - m.autocompleteIndex = 0 -} - -func (m *MediaInput) AutocompletePrev() { - if len(m.autocompleteList) == 0 { - return - } - index := m.autocompleteIndex - 1 - if index < 0 { - index = len(m.autocompleteList) - 1 - } - m.autocompleteIndex = index - m.showAutocomplete() -} - -func (m *MediaInput) AutocompleteTab() { - if len(m.autocompleteList) == 0 { - return - } - same := "" - for i := 0; i < len(m.autocompleteList[0]); i++ { - match := true - c := m.autocompleteList[0][i] - for _, item := range m.autocompleteList { - if i >= len(item) || c != item[i] { - match = false - break - } - } - if !match { - break - } - same += string(c) - } - if same != m.text { - m.text = same - m.View.SetText(same) - m.saveAutocompleteState() - } else { - m.AutocompleteNext() - } -} - -func (m *MediaInput) AutocompleteNext() { - if len(m.autocompleteList) == 0 { - return - } - index := m.autocompleteIndex + 1 - if index >= len(m.autocompleteList) { - index = 0 - } - m.autocompleteIndex = index - m.showAutocomplete() -} - -func (m *MediaInput) CheckDone() { - path := m.View.GetText() - if IsDir(path) { - m.saveAutocompleteState() - return - } - m.app.UI.MediaOverlay.AddFile(path) - m.app.UI.MediaOverlay.SetFocus(MediaFocusOverview) -} - -func (m *MediaInput) showAutocomplete() { - m.isAutocompleteChange = true - m.View.SetText(m.autocompleteList[m.autocompleteIndex]) - if len(m.autocompleteList) < 3 { - m.saveAutocompleteState() - } -} diff --git a/messagebox.go b/messagebox.go deleted file mode 100644 index 7f7581c..0000000 --- a/messagebox.go +++ /dev/null @@ -1,296 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "strings" - "time" - - "github.com/mattn/go-mastodon" - "github.com/rivo/tview" - "github.com/rivo/uniseg" -) - -type msgToot struct { - Text string - Status *mastodon.Status - MediaIDs []mastodon.ID - Sensitive bool - SpoilerText string - ScheduledAt *time.Time - QuoteIncluded bool -} - -func VisibilityToText(s string) string { - switch s { - case mastodon.VisibilityPublic: - return "Public" - case mastodon.VisibilityUnlisted: - return "Unlisted" - case mastodon.VisibilityFollowersOnly: - return "Followers" - case mastodon.VisibilityDirectMessage: - return "Direct" - default: - return "Public" - } -} - -func NewMessageBox(app *App) *MessageBox { - m := &MessageBox{ - app: app, - Flex: tview.NewFlex(), - View: tview.NewTextView(), - Index: 0, - Controls: tview.NewTextView(), - } - - m.View.SetBackgroundColor(app.Config.Style.Background) - m.View.SetTextColor(app.Config.Style.Text) - m.View.SetDynamicColors(true) - m.Controls.SetDynamicColors(true) - m.Controls.SetBackgroundColor(app.Config.Style.Background) - m.Controls.SetTextColor(app.Config.Style.Text) - m.Flex.SetDrawFunc(app.Config.ClearContent) - - return m -} - -type MessageBox struct { - app *App - Flex *tview.Flex - View *tview.TextView - Controls *tview.TextView - Index int - maxIndex int - currentToot msgToot -} - -func (m *MessageBox) NewToot() { - m.composeToot(nil) -} - -func (m *MessageBox) Reply(status *mastodon.Status) { - m.composeToot(status) -} - -func (m *MessageBox) ToggleSpoiler() { - m.currentToot.Sensitive = !m.currentToot.Sensitive - m.Draw() -} - -func (m *MessageBox) composeToot(status *mastodon.Status) { - m.Index = 0 - mt := msgToot{} - if status != nil { - if status.Reblog != nil { - status = status.Reblog - } - mt.Status = status - } - visibility := mastodon.VisibilityPublic - if status != nil { - visibility = status.Visibility - if status.Sensitive { - mt.Sensitive = true - mt.SpoilerText = status.SpoilerText - } - } - m.app.UI.VisibilityOverlay.SetVisibilty(visibility) - - m.currentToot = mt -} - -func (m *MessageBox) Up() { - if m.Index-1 > -1 { - m.Index-- - } - m.View.ScrollTo(m.Index, 0) -} - -func (m *MessageBox) Down() { - m.Index++ - if m.Index > m.maxIndex { - m.Index = m.maxIndex - } - m.View.ScrollTo(m.Index, 0) -} - -func (m *MessageBox) Post() { - toot := m.currentToot - send := mastodon.Toot{ - Status: strings.TrimSpace(toot.Text), - } - if toot.Status != nil { - send.InReplyToID = toot.Status.ID - } - if toot.Sensitive { - send.Sensitive = true - send.SpoilerText = toot.SpoilerText - } - - attachments := m.app.UI.MediaOverlay.Files - for _, ap := range attachments { - f, err := os.Open(ap.Path) - if err != nil { - m.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't upload media. Error: %v\n", err)) - return - } - media := &mastodon.Media{ - File: f, - } - if ap.Description != "" { - media.Description = ap.Description - } - a, err := m.app.API.Client.UploadMediaFromMedia(context.Background(), media) - if err != nil { - m.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't upload media. Error: %v\n", err)) - return - } - f.Close() - send.MediaIDs = append(send.MediaIDs, a.ID) - } - - send.Visibility = m.app.UI.VisibilityOverlay.GetVisibility() - - _, err := m.app.API.Client.PostStatus(context.Background(), &send) - if err != nil { - m.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't post toot. Error: %v\n", err)) - return - } - m.app.UI.StatusView.giveBackFocus() -} - -func (m *MessageBox) TootLength() int { - charCount := uniseg.GraphemeClusterCount(m.currentToot.Text) - spoilerCount := uniseg.GraphemeClusterCount(m.currentToot.SpoilerText) - totalCount := charCount - if m.currentToot.Sensitive { - totalCount += spoilerCount - } - charsLeft := m.app.Config.General.CharLimit - totalCount - return charsLeft -} - -func (m *MessageBox) Draw() { - var items []string - items = append(items, ColorKey(m.app.Config, "", "P", "ost")) - items = append(items, ColorKey(m.app.Config, "", "E", "dit")) - items = append(items, ColorKey(m.app.Config, "", "V", "isibility")) - items = append(items, ColorKey(m.app.Config, "", "T", "oggle CW")) - items = append(items, ColorKey(m.app.Config, "", "C", "ontent warning text")) - items = append(items, ColorKey(m.app.Config, "", "M", "edia attachment")) - items = append(items, ColorKey(m.app.Config, "", "I", "nclude quote")) - status := strings.Join(items, " ") - m.Controls.SetText(status) - - var outputHead string - var output string - - normal := ColorMark(m.app.Config.Style.Text) - subtleColor := ColorMark(m.app.Config.Style.Subtle) - warningColor := ColorMark(m.app.Config.Style.WarningText) - - charsLeft := m.TootLength() - - outputHead += subtleColor + VisibilityToText(m.app.UI.VisibilityOverlay.GetVisibility()) + ", " - if charsLeft > 0 { - outputHead += fmt.Sprintf("%d chars left", charsLeft) + "\n\n" + normal - } else { - outputHead += warningColor + fmt.Sprintf("%d chars left", charsLeft) + "\n\n" + normal - } - if m.currentToot.Status != nil { - var acct string - if m.currentToot.Status.Account.DisplayName != "" { - acct = fmt.Sprintf("%s (%s)\n", m.currentToot.Status.Account.DisplayName, m.currentToot.Status.Account.Acct) - } else { - acct = fmt.Sprintf("%s\n", m.currentToot.Status.Account.Acct) - } - outputHead += subtleColor + "Replying to " + tview.Escape(acct) + "\n" + normal - } - if m.currentToot.SpoilerText != "" && !m.currentToot.Sensitive { - outputHead += warningColor + "You have entered spoiler text, but haven't set an content warning. Do it by pressing " + tview.Escape("[T]") + "\n\n" + normal - } - - if m.currentToot.Sensitive && m.currentToot.SpoilerText == "" { - outputHead += warningColor + "You have added an content warning, but haven't set any text above the hidden text. Do it by pressing " + tview.Escape("[C]") + "\n\n" + normal - } - - if m.currentToot.Sensitive && m.currentToot.SpoilerText != "" { - outputHead += subtleColor + "Content warning\n\n" + normal - outputHead += tview.Escape(m.currentToot.SpoilerText) - outputHead += "\n\n" + subtleColor + "---hidden content below---\n\n" + normal - } - output = outputHead + normal + tview.Escape(m.currentToot.Text) - - m.View.SetText(output) - m.View.ScrollToEnd() - m.maxIndex, _ = m.View.GetScrollOffset() - m.View.ScrollTo(m.Index, 0) - m.app.UI.Root.Sync() -} - -func (m *MessageBox) EditText() { - t := m.currentToot.Text - s := m.currentToot.Status - m.currentToot.QuoteIncluded = false - - if t == "" && s != nil { - var users []string - if s.Account.Acct != m.app.Me.Acct { - users = append(users, "@"+s.Account.Acct) - } - for _, men := range s.Mentions { - if men.Acct == m.app.Me.Acct { - continue - } - users = append(users, "@"+men.Acct) - } - t = strings.Join(users, " ") - m.currentToot.Text = t - - if m.app.Config.General.QuoteReply { - m.IncludeQuote() - } - } - text, err := openEditor(m.app.UI.Root, m.currentToot.Text) - if err != nil { - m.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't edit toot. Error: %v\n", err)) - m.Draw() - return - } - m.currentToot.Text = text - m.Draw() -} - -func (m *MessageBox) IncludeQuote() { - if m.currentToot.QuoteIncluded { - return - } - t := m.currentToot.Text - s := m.currentToot.Status - if s == nil { - return - } - tootText, _ := cleanTootHTML(s.Content) - - t += "\n" - for _, line := range strings.Split(tootText, "\n") { - t += "> " + line + "\n" - } - t += "\n" - m.currentToot.Text = t - m.currentToot.QuoteIncluded = true -} - -func (m *MessageBox) EditSpoiler() { - text, err := openEditor(m.app.UI.Root, m.currentToot.SpoilerText) - if err != nil { - m.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't edit spoiler. Error: %v\n", err)) - m.Draw() - return - } - m.currentToot.SpoilerText = text - m.Draw() -} diff --git a/notifications.go b/notifications.go deleted file mode 100644 index 880ad7c..0000000 --- a/notifications.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import "github.com/rivo/tview" - -type NotificationView struct { - app *App - list *tview.List - iconList *tview.List - feed Feed - loadingNewer bool - loadingOlder bool -} - -func NewNotificationView(app *App) *NotificationView { - nv := &NotificationView{ - app: app, - loadingNewer: false, - loadingOlder: false, - } - - nv.list = tview.NewList() - nv.list.SetMainTextColor(app.Config.Style.Text) - nv.list.SetBackgroundColor(app.Config.Style.Background) - nv.list.SetSelectedTextColor(app.Config.Style.StatusBarViewText) - nv.list.SetSelectedBackgroundColor(app.Config.Style.StatusBarViewBackground) - nv.list.ShowSecondaryText(false) - nv.list.SetHighlightFullLine(true) - - nv.iconList = tview.NewList() - nv.iconList.SetMainTextColor(app.Config.Style.Text) - nv.iconList.SetBackgroundColor(app.Config.Style.Background) - nv.iconList.SetSelectedTextColor(app.Config.Style.StatusBarViewText) - nv.iconList.SetSelectedBackgroundColor(app.Config.Style.StatusBarViewBackground) - nv.iconList.ShowSecondaryText(false) - nv.iconList.SetHighlightFullLine(true) - - nv.feed = NewNotificationFeed(app, true) - - return nv -} - -func (n *NotificationView) SetList(items <-chan ListItem) { - n.list.Clear() - n.iconList.Clear() - for s := range items { - n.list.AddItem(s.Text, "", 0, nil) - n.iconList.AddItem(s.Icons, "", 0, nil) - } -} - -func (n *NotificationView) loadNewer() { - if n.loadingNewer { - return - } - n.loadingNewer = true - go func() { - new := n.feed.LoadNewer() - if new == 0 { - n.loadingNewer = false - return - } - n.app.UI.Root.QueueUpdateDraw(func() { - index := n.list.GetCurrentItem() - n.feed.DrawList() - newIndex := index + new - - n.list.SetCurrentItem(newIndex) - n.iconList.SetCurrentItem(newIndex) - n.loadingNewer = false - }) - }() -} - -func (n *NotificationView) loadOlder() { - if n.loadingOlder { - return - } - n.loadingOlder = true - go func() { - new := n.feed.LoadOlder() - if new == 0 { - n.loadingOlder = false - return - } - n.app.UI.Root.QueueUpdateDraw(func() { - index := n.list.GetCurrentItem() - n.feed.DrawList() - n.list.SetCurrentItem(index) - n.iconList.SetCurrentItem(index) - n.loadingOlder = false - }) - }() -} diff --git a/paneview.go b/paneview.go deleted file mode 100644 index f11f445..0000000 --- a/paneview.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -type PaneView interface { - GetLeftView() tview.Primitive - GetRightView() tview.Primitive - Input(event *tcell.EventKey) *tcell.EventKey -} diff --git a/status.go b/status.go deleted file mode 100644 index 887e44c..0000000 --- a/status.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import "github.com/rivo/tview" - -func NewStatusBar(app *App) *StatusBar { - s := &StatusBar{ - Text: tview.NewTextView(), - } - - s.Text.SetBackgroundColor(app.Config.Style.StatusBarBackground) - s.Text.SetTextColor(app.Config.Style.StatusBarText) - - return s -} - -type StatusBar struct { - Text *tview.TextView -} - -func (s *StatusBar) SetText(t string) { - s.Text.SetText(t) -} diff --git a/statusview.go b/statusview.go deleted file mode 100644 index 49627f6..0000000 --- a/statusview.go +++ /dev/null @@ -1,705 +0,0 @@ -package main - -import ( - "fmt" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/mattn/go-mastodon" - "github.com/rivo/tview" -) - -func NewStatusView(app *App, tl TimelineType) *StatusView { - t := &StatusView{ - app: app, - list: tview.NewList(), - iconList: tview.NewList(), - text: tview.NewTextView(), - controls: tview.NewTextView(), - focus: LeftPaneFocus, - lastList: LeftPaneFocus, - loadingNewer: false, - loadingOlder: false, - feedIndex: 0, - } - if t.app.Config.General.NotificationFeed { - t.notificationView = NewNotificationView(app) - } - - if app.Config.General.MaxWidth > 0 { - mw := app.Config.General.MaxWidth - t.text.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { - rWidth := width - if rWidth > mw { - rWidth = mw - } - return x, y, rWidth, height - }) - } - - t.flex = tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(t.text, 0, 9, false). - AddItem(t.controls, 1, 0, false) - - t.list.SetMainTextColor(app.Config.Style.Text) - 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.iconList.SetMainTextColor(app.Config.Style.Text) - t.iconList.SetBackgroundColor(app.Config.Style.Background) - t.iconList.SetSelectedTextColor(app.Config.Style.ListSelectedText) - t.iconList.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground) - t.iconList.ShowSecondaryText(false) - t.iconList.SetHighlightFullLine(true) - - 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) - - if app.Config.General.AutoLoadNewer { - go func() { - d := time.Second * time.Duration(app.Config.General.AutoLoadSeconds) - ticker := time.NewTicker(d) - - for range ticker.C { - t.loadNewer() - } - }() - if app.Config.General.NotificationFeed { - go func() { - d := time.Second * time.Duration(app.Config.General.AutoLoadSeconds) - ticker := time.NewTicker(d) - for range ticker.C { - t.notificationView.loadNewer() - } - }() - } - } - return t -} - -type ListItem struct { - Text string - Icons string -} - -type StatusView struct { - app *App - list *tview.List - iconList *tview.List - flex *tview.Flex - text *tview.TextView - controls *tview.TextView - feeds []Feed - feedIndex int - focus FocusAt - lastList FocusAt - loadingNewer bool - loadingOlder bool - notificationView *NotificationView -} - -func (t *StatusView) AddFeed(f Feed) { - t.feeds = append(t.feeds, f) - t.feedIndex = len(t.feeds) - 1 - f.DrawList() - t.list.SetCurrentItem(f.GetSavedIndex()) - t.iconList.SetCurrentItem(f.GetSavedIndex()) - f.DrawToot() - t.drawDesc() - - if t.focus == NotificationPaneFocus { - t.app.UI.SetFocus(LeftPaneFocus) - t.focus = LeftPaneFocus - t.lastList = NotificationPaneFocus - } else { - t.lastList = LeftPaneFocus - } -} - -func (t *StatusView) CycleDraw() { - feed := t.feeds[t.feedIndex] - feed.DrawList() - t.list.SetCurrentItem(feed.GetSavedIndex()) - t.iconList.SetCurrentItem(feed.GetSavedIndex()) - - if t.lastList == NotificationPaneFocus { - t.app.UI.SetFocus(NotificationPaneFocus) - t.focus = NotificationPaneFocus - t.lastList = NotificationPaneFocus - t.notificationView.feed.DrawToot() - } else { - t.app.UI.SetFocus(LeftPaneFocus) - t.focus = LeftPaneFocus - t.lastList = LeftPaneFocus - feed.DrawToot() - } - t.drawDesc() - if feed.GetSavedIndex() < 4 { - t.loadNewer() - } -} - -func (t *StatusView) CyclePreviousFeed() { - if t.feedIndex != 0 { - t.feedIndex = t.feedIndex - 1 - } - t.CycleDraw() -} - -func (t *StatusView) CycleNextFeed() { - if t.feedIndex+1 < len(t.feeds) { - t.feedIndex = t.feedIndex + 1 - } - t.CycleDraw() -} - -func (t *StatusView) RemoveCurrentFeed() { - t.feeds[t.feedIndex] = nil - t.feeds = t.feeds[:t.feedIndex+copy(t.feeds[t.feedIndex:], t.feeds[t.feedIndex+1:])] - - if t.feedIndex == len(t.feeds) { - t.feedIndex = len(t.feeds) - 1 - } - - feed := t.feeds[t.feedIndex] - feed.DrawList() - t.list.SetCurrentItem(feed.GetSavedIndex()) - t.iconList.SetCurrentItem(feed.GetSavedIndex()) - - if t.lastList == NotificationPaneFocus { - t.app.UI.SetFocus(NotificationPaneFocus) - t.focus = NotificationPaneFocus - t.lastList = NotificationPaneFocus - t.notificationView.feed.DrawToot() - } else { - t.app.UI.SetFocus(LeftPaneFocus) - t.focus = LeftPaneFocus - t.lastList = LeftPaneFocus - feed.DrawToot() - } - t.drawDesc() - -} - -func (t *StatusView) GetLeftView() tview.Primitive { - if len(t.feeds) > 0 { - feed := t.feeds[t.feedIndex] - feed.DrawList() - feed.DrawToot() - } - iw := 3 - if !t.app.Config.General.ShowIcons { - iw = 0 - } - v := tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(t.list, 0, 1, false). - AddItem(t.iconList, iw, 0, false) - return v -} - -func (t *StatusView) GetNotificationView() tview.Primitive { - iw := 3 - if !t.app.Config.General.ShowIcons { - iw = 0 - } - if t.notificationView != nil { - t.notificationView.feed.DrawList() - v := tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(t.notificationView.list, 0, 1, false). - AddItem(t.notificationView.iconList, iw, 0, false) - return v - } - return nil -} - -func (t *StatusView) GetRightView() tview.Primitive { - return t.flex -} - -func (t *StatusView) GetTextWidth() int { - _, _, width, _ := t.text.GetInnerRect() - return width -} - -func (t *StatusView) RedrawPoll(p *mastodon.Poll) { - if len(t.feeds) == 0 { - return - } - t.feeds[t.feedIndex].RedrawPoll(p) -} - -func (t *StatusView) GetCurrentItem() int { - return t.list.GetCurrentItem() -} - -func (t *StatusView) GetCurrentStatus() *mastodon.Status { - if len(t.feeds) == 0 { - return nil - } - return t.feeds[t.feedIndex].GetCurrentStatus() -} - -func (t *StatusView) GetCurrentUser() *mastodon.Account { - if len(t.feeds) == 0 { - return nil - } - return t.feeds[t.feedIndex].GetCurrentUser() -} - -func (t *StatusView) ScrollToBeginning() { - t.text.ScrollToBeginning() -} - -func (t *StatusView) inputBoth(event *tcell.EventKey) { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'g': - t.home() - case 'G': - t.end() - case '?': - t.app.UI.SetFocus(HelpOverlayFocus) - } - } else { - switch event.Key() { - case tcell.KeyCtrlC: - t.app.UI.Root.Stop() - case tcell.KeyHome: - t.home() - case tcell.KeyEnd: - t.end() - } - } - allowedLeft := t.lastList == LeftPaneFocus || t.focus == LeftPaneFocus - allowedNotification := t.lastList == NotificationPaneFocus || t.focus == NotificationPaneFocus - if t.focus == RightPaneFocus { - switch event.Rune() { - case 'g', 'G', 'j', 'k', 'h', 'l', 'q', 'Q': - allowedLeft = false - } - switch event.Key() { - case tcell.KeyEsc: - allowedLeft = false - } - } - if len(t.feeds) > 0 && allowedLeft { - feed := t.feeds[t.feedIndex] - feed.Input(event) - } else if allowedNotification { - t.notificationView.feed.Input(event) - } -} - -func (t *StatusView) inputBack(q bool) { - if t.app.UI.Focus == LeftPaneFocus && len(t.feeds) > 1 { - t.RemoveCurrentFeed() - } else if t.app.UI.Focus == LeftPaneFocus && q { - t.app.UI.Root.Stop() - } else if t.app.UI.Focus == NotificationPaneFocus { - t.app.UI.SetFocus(LeftPaneFocus) - t.focus = LeftPaneFocus - t.lastList = LeftPaneFocus - t.feeds[t.feedIndex].DrawToot() - } -} - -func (t *StatusView) inputLeft(event *tcell.EventKey) { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'v', 'V': - t.app.UI.SetFocus(RightPaneFocus) - t.focus = RightPaneFocus - t.app.UI.StatusBar.Text.SetBackgroundColor( - t.app.Config.Style.StatusBarViewBackground, - ) - t.app.UI.StatusBar.Text.SetTextColor( - t.app.Config.Style.StatusBarViewText, - ) - case 'k', 'K': - t.prev() - case 'j', 'J': - t.next() - case 'n', 'N': - if t.app.Config.General.NotificationFeed { - t.app.UI.SetFocus(NotificationPaneFocus) - t.focus = NotificationPaneFocus - t.lastList = NotificationPaneFocus - t.notificationView.feed.DrawToot() - } - case 'q', 'Q': - t.inputBack(true) - case 'h', 'H': - t.CyclePreviousFeed() - case 'l', 'L': - t.CycleNextFeed() - } - } else { - switch event.Key() { - case tcell.KeyUp: - t.prev() - case tcell.KeyDown: - t.next() - case tcell.KeyLeft: - t.CyclePreviousFeed() - case tcell.KeyRight: - t.CycleNextFeed() - case tcell.KeyPgUp, tcell.KeyCtrlB: - t.pgup() - case tcell.KeyPgDn, tcell.KeyCtrlF: - t.pgdown() - case tcell.KeyEsc: - t.inputBack(false) - } - } -} - -func (t *StatusView) inputRightQuit() { - if t.lastList == LeftPaneFocus { - t.app.UI.SetFocus(LeftPaneFocus) - t.focus = LeftPaneFocus - } else if t.lastList == NotificationPaneFocus { - t.app.UI.SetFocus(NotificationPaneFocus) - t.focus = NotificationPaneFocus - } - t.app.UI.StatusBar.Text.SetBackgroundColor( - t.app.Config.Style.StatusBarBackground, - ) - t.app.UI.StatusBar.Text.SetTextColor( - t.app.Config.Style.StatusBarText, - ) -} - -func (t *StatusView) inputRight(event *tcell.EventKey) { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'q', 'Q': - t.inputRightQuit() - } - } else { - switch event.Key() { - case tcell.KeyEsc: - t.inputRightQuit() - } - } -} - -func (t *StatusView) Input(event *tcell.EventKey) *tcell.EventKey { - t.inputBoth(event) - if len(t.feeds) == 0 { - return event - } - - switch t.focus { - case LeftPaneFocus: - t.inputLeft(event) - return nil - case NotificationPaneFocus: - t.inputLeft(event) - return nil - default: - t.inputRight(event) - } - - return event -} - -func (t *StatusView) SetList(items <-chan ListItem) { - t.list.Clear() - t.iconList.Clear() - for s := range items { - t.list.AddItem(s.Text, "", 0, nil) - t.iconList.AddItem(s.Icons, "", 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) drawDesc() { - if len(t.feeds) == 0 { - t.app.UI.SetTopText("") - return - } - i := t.feedIndex + 1 - l := len(t.feeds) - f := t.feeds[t.feedIndex] - t.app.UI.SetTopText( - fmt.Sprintf("%s (%d/%d)", f.GetDesc(), i, l), - ) -} - -func (t *StatusView) prev() { - var current int - var list *tview.List - var iList *tview.List - var feed Feed - if t.app.UI.Focus == LeftPaneFocus { - current = t.GetCurrentItem() - list = t.list - iList = t.iconList - feed = t.feeds[t.feedIndex] - } else { - current = t.notificationView.list.GetCurrentItem() - list = t.notificationView.list - iList = t.notificationView.iconList - feed = t.notificationView.feed - } - - if current-1 >= 0 { - current-- - } - list.SetCurrentItem(current) - iList.SetCurrentItem(current) - feed.DrawToot() - - if current < 4 { - switch t.app.UI.Focus { - case LeftPaneFocus: - t.loadNewer() - case NotificationPaneFocus: - t.notificationView.loadNewer() - } - } -} - -func (t *StatusView) next() { - var list *tview.List - var iList *tview.List - var feed Feed - if t.app.UI.Focus == LeftPaneFocus { - list = t.list - iList = t.iconList - feed = t.feeds[t.feedIndex] - } else { - list = t.notificationView.list - iList = t.notificationView.iconList - feed = t.notificationView.feed - } - - list.SetCurrentItem(list.GetCurrentItem() + 1) - iList.SetCurrentItem(iList.GetCurrentItem() + 1) - feed.DrawToot() - - count := list.GetItemCount() - current := list.GetCurrentItem() - if (count - current + 1) < 5 { - switch t.app.UI.Focus { - case LeftPaneFocus: - t.loadOlder() - case NotificationPaneFocus: - t.notificationView.loadOlder() - } - } -} - -func (t *StatusView) pgdown() { - var list *tview.List - var iList *tview.List - var feed Feed - if t.app.UI.Focus == LeftPaneFocus { - list = t.list - iList = t.iconList - feed = t.feeds[t.feedIndex] - } else { - list = t.notificationView.list - iList = t.notificationView.iconList - feed = t.notificationView.feed - } - - _, _, _, height := list.GetInnerRect() - i := list.GetCurrentItem() + height - 1 - list.SetCurrentItem(i) - iList.SetCurrentItem(i) - feed.DrawToot() - - count := list.GetItemCount() - current := list.GetCurrentItem() - if (count - current + 1) < 5 { - switch t.app.UI.Focus { - case LeftPaneFocus: - t.loadOlder() - case NotificationPaneFocus: - t.notificationView.loadOlder() - } - } -} - -func (t *StatusView) pgup() { - var list *tview.List - var iList *tview.List - var feed Feed - if t.app.UI.Focus == LeftPaneFocus { - list = t.list - iList = t.iconList - feed = t.feeds[t.feedIndex] - } else { - list = t.notificationView.list - iList = t.notificationView.iconList - feed = t.notificationView.feed - } - - _, _, _, height := list.GetInnerRect() - i := list.GetCurrentItem() - height + 1 - if i < 0 { - i = 0 - } - list.SetCurrentItem(i) - iList.SetCurrentItem(i) - feed.DrawToot() - - current := list.GetCurrentItem() - if current < 4 { - switch t.app.UI.Focus { - case LeftPaneFocus: - t.loadNewer() - case NotificationPaneFocus: - t.notificationView.loadNewer() - } - } -} - -func (t *StatusView) home() { - if t.focus == RightPaneFocus { - t.text.ScrollToBeginning() - return - } - - var list *tview.List - var iList *tview.List - var feed Feed - if t.app.UI.Focus == LeftPaneFocus { - list = t.list - iList = t.iconList - feed = t.feeds[t.feedIndex] - } else { - list = t.notificationView.list - iList = t.notificationView.iconList - feed = t.notificationView.feed - } - - list.SetCurrentItem(0) - iList.SetCurrentItem(0) - feed.DrawToot() - - switch t.app.UI.Focus { - case LeftPaneFocus: - t.loadNewer() - case NotificationPaneFocus: - t.notificationView.loadNewer() - } -} - -func (t *StatusView) end() { - if t.focus == RightPaneFocus { - t.text.ScrollToEnd() - return - } - - var list *tview.List - var iList *tview.List - var feed Feed - if t.app.UI.Focus == LeftPaneFocus { - list = t.list - iList = t.iconList - feed = t.feeds[t.feedIndex] - } else { - list = t.notificationView.list - iList = t.notificationView.iconList - feed = t.notificationView.feed - } - - list.SetCurrentItem(-1) - iList.SetCurrentItem(-1) - feed.DrawToot() - - switch t.app.UI.Focus { - case LeftPaneFocus: - t.loadOlder() - case NotificationPaneFocus: - t.notificationView.loadOlder() - } -} - -func (t *StatusView) loadNewer() { - feedIndex := t.feedIndex - if t.loadingNewer || feedIndex < 0 { - return - } - t.loadingNewer = true - go func() { - new := t.feeds[feedIndex].LoadNewer() - if new == 0 { - t.loadingNewer = false - 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() == UserFeedType { - newIndex = 0 - } - t.list.SetCurrentItem(newIndex) - t.iconList.SetCurrentItem(newIndex) - t.loadingNewer = false - }) - }() -} - -func (t *StatusView) loadOlder() { - feedIndex := t.feedIndex - if t.loadingOlder || feedIndex < 0 { - return - } - t.loadingOlder = true - go func() { - new := t.feeds[feedIndex].LoadOlder() - if new == 0 { - t.loadingOlder = false - return - } - t.app.UI.Root.QueueUpdateDraw(func() { - index := t.list.GetCurrentItem() - t.feeds[feedIndex].DrawList() - t.list.SetCurrentItem(index) - t.iconList.SetCurrentItem(index) - t.loadingOlder = false - }) - }() -} - -func (t *StatusView) giveBackFocus() { - if t.focus == RightPaneFocus { - t.app.UI.SetFocus(RightPaneFocus) - t.focus = RightPaneFocus - t.app.UI.StatusBar.Text.SetBackgroundColor( - t.app.Config.Style.StatusBarViewBackground, - ) - t.app.UI.StatusBar.Text.SetTextColor( - t.app.Config.Style.StatusBarViewText, - ) - return - } else if t.lastList == LeftPaneFocus { - t.app.UI.SetFocus(LeftPaneFocus) - t.focus = LeftPaneFocus - } else if t.lastList == NotificationPaneFocus { - t.app.UI.SetFocus(NotificationPaneFocus) - t.focus = NotificationPaneFocus - } - t.app.UI.StatusBar.Text.SetBackgroundColor( - t.app.Config.Style.StatusBarBackground, - ) - t.app.UI.StatusBar.Text.SetTextColor( - t.app.Config.Style.StatusBarText, - ) -} diff --git a/top.go b/top.go deleted file mode 100644 index 15d30dd..0000000 --- a/top.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import "github.com/rivo/tview" - -func NewTop(app *App) *Top { - t := &Top{ - Text: tview.NewTextView(), - } - - t.Text.SetBackgroundColor(app.Config.Style.TopBarBackground) - t.Text.SetTextColor(app.Config.Style.TopBarText) - - return t -} - -type Top struct { - Text *tview.TextView -} diff --git a/ui.go b/ui.go deleted file mode 100644 index 7e298b5..0000000 --- a/ui.go +++ /dev/null @@ -1,548 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - - "github.com/gdamore/tcell/v2" - "github.com/mattn/go-mastodon" - "github.com/rivo/tview" -) - -type FocusAt uint - -const ( - LeftPaneFocus FocusAt = iota - RightPaneFocus - NotificationPaneFocus - CmdBarFocus - MessageFocus - MessageAttachmentFocus - LinkOverlayFocus - VisibilityOverlayFocus - VoteOverlayFocus - AuthOverlayFocus - UserSelectFocus - HelpOverlayFocus -) - -func NewUI(app *App) *UI { - ui := &UI{ - app: app, - Root: tview.NewApplication(), - } - - return ui -} - -func (ui *UI) Init() { - tview.Styles = tview.Theme{ - PrimitiveBackgroundColor: ui.app.Config.Style.StatusBarViewText, // main text color, selected text - ContrastBackgroundColor: ui.app.Config.Style.Background, - MoreContrastBackgroundColor: ui.app.Config.Style.StatusBarBackground, //background color - BorderColor: ui.app.Config.Style.Subtle, - TitleColor: ui.app.Config.Style.Text, - GraphicsColor: ui.app.Config.Style.Text, - PrimaryTextColor: ui.app.Config.Style.StatusBarViewBackground, //backround color selected - SecondaryTextColor: ui.app.Config.Style.Text, - TertiaryTextColor: ui.app.Config.Style.Text, - InverseTextColor: ui.app.Config.Style.Text, - ContrastSecondaryTextColor: ui.app.Config.Style.Text, - } - ui.Top = NewTop(ui.app) - ui.Pages = tview.NewPages() - ui.Timeline = ui.app.Config.General.StartTimeline - ui.CmdBar = NewCmdBar(ui.app) - ui.StatusBar = NewStatusBar(ui.app) - ui.MessageBox = NewMessageBox(ui.app) - ui.LinkOverlay = NewLinkOverlay(ui.app) - ui.VisibilityOverlay = NewVisibilityOverlay(ui.app) - ui.VoteOverlay = NewVoteOverlay(ui.app) - ui.AuthOverlay = NewAuthOverlay(ui.app) - ui.UserSelectOverlay = NewUserSelectOverlay(ui.app) - ui.MediaOverlay = NewMediaOverlay(ui.app) - ui.HelpOverlay = NewHelpOverlay(ui.app) - - ui.Pages.SetBackgroundColor(ui.app.Config.Style.Background) - - verticalLine := tview.NewBox() - verticalLine.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { - var s tcell.Style - s = s.Background(ui.app.Config.Style.Background).Foreground(ui.app.Config.Style.Subtle) - for cy := y; cy < y+height; cy++ { - screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, s) - } - return 0, 0, 0, 0 - }) - ui.SetTopText("") - 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(tview.NewBox().SetBackgroundColor(ui.app.Config.Style.Background), 0, 2, false). - AddItem(verticalLine, 1, 0, false). - AddItem(tview.NewBox().SetBackgroundColor(ui.app.Config.Style.Background), 1, 0, false). - AddItem(tview.NewTextView().SetBackgroundColor(ui.app.Config.Style.Background), - 0, 4, false), - 0, 1, false). - AddItem(ui.StatusBar.Text, 1, 1, false). - AddItem(ui.CmdBar.Input, 1, 0, false), 0, 1, false), true, true) - - ui.Pages.AddPage("toot", tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(ui.MessageBox.Flex.SetDirection(tview.FlexRow). - AddItem(ui.MessageBox.View, 0, 9, true). - AddItem(ui.MessageBox.Controls, 2, 1, false), 0, 8, false). - AddItem(nil, 0, 1, false), 0, 8, true). - AddItem(nil, 0, 1, false), true, false) - - ui.Pages.AddPage("links", tview.NewFlex().AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(ui.LinkOverlay.Flex.SetDirection(tview.FlexRow). - AddItem(ui.LinkOverlay.List, 0, 10, true). - AddItem(ui.LinkOverlay.TextBottom, 1, 1, true), 0, 8, false). - AddItem(nil, 0, 1, false), 0, 8, true). - AddItem(nil, 0, 1, false), true, false) - ui.Pages.AddPage("visibility", tview.NewFlex().AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(ui.VisibilityOverlay.Flex.SetDirection(tview.FlexRow). - AddItem(ui.VisibilityOverlay.List, 0, 10, true). - AddItem(ui.VisibilityOverlay.TextBottom, 1, 1, true), 0, 8, false). - AddItem(nil, 0, 1, false), 0, 8, true). - AddItem(nil, 0, 1, false), true, false) - ui.Pages.AddPage("vote", tview.NewFlex().AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(ui.VoteOverlay.Flex.SetDirection(tview.FlexRow). - AddItem(ui.VoteOverlay.TextTop, 3, 1, true). - AddItem(ui.VoteOverlay.List, 0, 10, true). - AddItem(ui.VoteOverlay.TextBottom, 1, 1, true), 0, 8, false). - AddItem(nil, 0, 1, false), 0, 8, true). - AddItem(nil, 0, 1, false), true, false) - ui.Pages.AddPage("login", - tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(ui.AuthOverlay.Flex.SetDirection(tview.FlexRow). - AddItem(ui.AuthOverlay.Text, 4, 1, false). - AddItem(ui.AuthOverlay.Input, 0, 9, true), 0, 9, true). - AddItem(nil, 0, 1, false), 0, 6, true). - AddItem(nil, 0, 1, false), - true, false) - ui.Pages.AddPage("userselect", - tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(ui.UserSelectOverlay.Flex.SetDirection(tview.FlexRow). - AddItem(ui.UserSelectOverlay.Text, 2, 1, false). - AddItem(ui.UserSelectOverlay.List, 0, 9, true), 0, 9, true). - AddItem(nil, 0, 1, false), 0, 6, true). - AddItem(nil, 0, 1, false), - true, false) - ui.Pages.AddPage("media", tview.NewFlex().AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(ui.MediaOverlay.Flex.SetDirection(tview.FlexRow). - AddItem(ui.MediaOverlay.TextTop, 2, 1, true). - AddItem(ui.MediaOverlay.FileList, 0, 10, true). - AddItem(ui.MediaOverlay.TextBottom, 1, 1, true). - AddItem(ui.MediaOverlay.InputField.View, 2, 1, false), 0, 8, false). - AddItem(nil, 0, 1, false), 0, 8, true). - AddItem(nil, 0, 1, false), true, false) - ui.Pages.AddPage("help", tview.NewFlex().AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(ui.HelpOverlay.Flex.SetDirection(tview.FlexRow). - AddItem(ui.HelpOverlay.TextMain, 0, 10, true). - AddItem(ui.HelpOverlay.TextBottom, 1, 1, true), 0, 8, false). - AddItem(nil, 0, 1, false), 0, 8, true). - AddItem(nil, 0, 1, false), true, false) - - ui.Root.SetBeforeDrawFunc(func(screen tcell.Screen) bool { - screen.Clear() - return false - }) -} - -type UI struct { - app *App - Root *tview.Application - Focus FocusAt - Top *Top - MessageBox *MessageBox - CmdBar *CmdBar - StatusBar *StatusBar - Pages *tview.Pages - LinkOverlay *LinkOverlay - VisibilityOverlay *VisibilityOverlay - VoteOverlay *VoteOverlay - AuthOverlay *AuthOverlay - UserSelectOverlay *UserSelectOverlay - MediaOverlay *MediaView - Timeline TimelineType - StatusView *StatusView - HelpOverlay *HelpOverlay -} - -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) { - ui.Focus = f - switch f { - case RightPaneFocus: - ui.Pages.SwitchToPage("main") - ui.FocusAt(ui.StatusView.text, "-- VIEW --") - case CmdBarFocus: - ui.FocusAt(ui.CmdBar.Input, "-- CMD --") - case MessageFocus: - ui.MessageBox.Draw() - ui.Pages.ShowPage("toot") - ui.Pages.HidePage("media") - ui.Pages.HidePage("visibility") - ui.Root.SetFocus(ui.MessageBox.View) - ui.FocusAt(ui.MessageBox.View, "-- TOOT --") - case MessageAttachmentFocus: - ui.Pages.ShowPage("media") - case LinkOverlayFocus: - ui.Pages.ShowPage("links") - ui.Root.SetFocus(ui.LinkOverlay.List) - ui.FocusAt(ui.LinkOverlay.List, "-- LINK --") - case HelpOverlayFocus: - ui.Pages.ShowPage("help") - if ui.app.Config.General.ShowHelp { - ui.CmdBar.ClearInput() - } - ui.Root.SetFocus(ui.HelpOverlay.TextMain) - ui.FocusAt(ui.HelpOverlay.TextMain, "-- HELP --") - case VoteOverlayFocus: - ui.Pages.ShowPage("vote") - ui.Root.SetFocus(ui.VoteOverlay.List) - ui.FocusAt(ui.VoteOverlay.List, "-- VOTE --") - case VisibilityOverlayFocus: - ui.VisibilityOverlay.Show() - ui.Pages.ShowPage("visibility") - ui.Root.SetFocus(ui.VisibilityOverlay.List) - ui.FocusAt(ui.VisibilityOverlay.List, "-- VISIBILITY --") - case AuthOverlayFocus: - ui.Pages.ShowPage("login") - ui.FocusAt(ui.AuthOverlay.Input, "-- LOGIN --") - case UserSelectFocus: - ui.UserSelectOverlay.Draw() - ui.Pages.ShowPage("userselect") - ui.FocusAt(ui.UserSelectOverlay.List, "-- SELECT USER --") - case NotificationPaneFocus: - ui.Pages.SwitchToPage("main") - ui.FocusAt(nil, "-- NOTIFICATIONS --") - - ui.StatusView.notificationView.list.SetSelectedBackgroundColor( - ui.app.Config.Style.ListSelectedBackground, - ) - ui.StatusView.notificationView.list.SetSelectedTextColor( - ui.app.Config.Style.ListSelectedText, - ) - - ui.StatusView.list.SetSelectedBackgroundColor( - ui.app.Config.Style.StatusBarViewBackground, - ) - ui.StatusView.list.SetSelectedTextColor( - ui.app.Config.Style.StatusBarViewText, - ) - ui.StatusView.notificationView.iconList.SetSelectedBackgroundColor( - ui.app.Config.Style.ListSelectedBackground, - ) - ui.StatusView.notificationView.iconList.SetSelectedTextColor( - ui.app.Config.Style.ListSelectedText, - ) - - ui.StatusView.iconList.SetSelectedBackgroundColor( - ui.app.Config.Style.StatusBarViewBackground, - ) - ui.StatusView.iconList.SetSelectedTextColor( - ui.app.Config.Style.StatusBarViewText, - ) - default: - ui.app.UI.StatusBar.Text.SetBackgroundColor( - ui.app.Config.Style.StatusBarBackground, - ) - ui.app.UI.StatusBar.Text.SetTextColor( - ui.app.Config.Style.StatusBarText, - ) - ui.StatusView.list.SetSelectedBackgroundColor( - ui.app.Config.Style.ListSelectedBackground, - ) - ui.StatusView.list.SetSelectedTextColor( - ui.app.Config.Style.ListSelectedText, - ) - ui.StatusView.iconList.SetSelectedBackgroundColor( - ui.app.Config.Style.ListSelectedBackground, - ) - ui.StatusView.iconList.SetSelectedTextColor( - ui.app.Config.Style.ListSelectedText, - ) - - if ui.app.Config.General.NotificationFeed { - ui.StatusView.notificationView.list.SetSelectedBackgroundColor( - ui.app.Config.Style.StatusBarViewBackground, - ) - ui.StatusView.notificationView.list.SetSelectedTextColor( - ui.app.Config.Style.StatusBarViewText, - ) - ui.StatusView.notificationView.iconList.SetSelectedBackgroundColor( - ui.app.Config.Style.StatusBarViewBackground, - ) - ui.StatusView.notificationView.iconList.SetSelectedTextColor( - ui.app.Config.Style.StatusBarViewText, - ) - } - ui.Pages.SwitchToPage("main") - ui.FocusAt(nil, "-- LIST --") - } -} - -func (ui *UI) NewToot() { - ui.Root.SetFocus(ui.MessageBox.View) - ui.MediaOverlay.Reset() - ui.MessageBox.NewToot() - ui.MessageBox.Draw() - ui.SetFocus(MessageFocus) -} - -func (ui *UI) Reply(status *mastodon.Status) { - if status.Reblog != nil { - status = status.Reblog - } - ui.MediaOverlay.Reset() - ui.MessageBox.Reply(status) - ui.MessageBox.Draw() - ui.SetFocus(MessageFocus) -} - -func (ui *UI) ShowLinks() { - ui.SetFocus(LinkOverlayFocus) -} - -func (ui *UI) ShowVote() { - ui.SetFocus(VoteOverlayFocus) -} - -func (ui *UI) OpenMedia(status *mastodon.Status) { - if status.Reblog != nil { - status = status.Reblog - } - - if len(status.MediaAttachments) == 0 { - return - } - - mediaGroup := make(map[string][]mastodon.Attachment) - for _, m := range status.MediaAttachments { - mediaGroup[m.Type] = append(mediaGroup[m.Type], m) - } - - for key := range mediaGroup { - var files []string - for _, m := range mediaGroup[key] { - //'image', 'video', 'gifv', 'audio' or 'unknown' - f, err := downloadFile(m.URL) - if err != nil { - continue - } - files = append(files, f) - } - openMediaType(ui.Root, ui.app.Config.Media, files, key) - ui.app.FileList = append(ui.app.FileList, files...) - ui.ShouldSync() - } -} - -func (ui *UI) SetTopText(s string) { - if s == "" { - ui.Top.Text.SetText("tut") - } else { - ui.Top.Text.SetText(fmt.Sprintf("tut - %s - %s", s, ui.app.FullUsername)) - } -} - -func (ui *UI) LoggedIn() { - if ui.app.Config.General.ShowHelp { - ui.CmdBar.ShowMsg("Press ? or :help to learn how tut functions") - } - ui.StatusView = NewStatusView(ui.app, ui.Timeline) - - verticalLine := tview.NewBox() - verticalLine.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { - var s tcell.Style - s = s.Background(ui.app.Config.Style.Background).Foreground(ui.app.Config.Style.Subtle) - for cy := y; cy < y+height; cy++ { - screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, s) - } - return 0, 0, 0, 0 - }) - horizontalLine := tview.NewBox() - horizontalLine.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { - var s tcell.Style - s = s.Background(ui.app.Config.Style.Background).Foreground(ui.app.Config.Style.Subtle) - for cx := x; cx < x+width; cx++ { - screen.SetContent(cx, y, tview.BoxDrawingsLightHorizontal, nil, s) - } - return 0, 0, 0, 0 - }) - - ui.Pages.RemovePage("main") - mainText := tview.NewTextView() - mainText.SetBackgroundColor(ui.app.Config.Style.Background) - mainText.SetTextColor(ui.app.Config.Style.Subtle) - mainText.SetText("") - mainText.SetTextAlign(tview.AlignCenter) - - notificationText := tview.NewTextView() - notificationText.SetBackgroundColor(ui.app.Config.Style.Background) - notificationText.SetTextColor(ui.app.Config.Style.Subtle) - notificationText.SetText("[N]otifications") - notificationText.SetTextAlign(tview.AlignCenter) - - var listViewRow *tview.Flex - var listViewColumn *tview.Flex - lp := ui.app.Config.General.ListProportion - cp := ui.app.Config.General.ContentProportion - nf := 1 - if ui.app.Config.General.HideNotificationText { - nf = 0 - } - - if ui.app.Config.General.NotificationFeed { - listViewRow = tview.NewFlex().AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(ui.StatusView.GetLeftView(), 0, 1, false). - AddItem(notificationText, 1, 0, false). - AddItem(ui.StatusView.GetNotificationView(), 0, 1, false), 0, 1, false) - - listViewColumn = tview.NewFlex().AddItem(tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(mainText, nf, 0, false). - AddItem(ui.StatusView.GetLeftView(), 0, 1, false), 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(notificationText, nf, 0, false). - AddItem(ui.StatusView.GetNotificationView(), 0, 1, false), 0, 1, false), 0, 1, false) - } else { - listViewRow = tview.NewFlex().AddItem(ui.StatusView.GetLeftView(), 0, 1, false) - listViewColumn = tview.NewFlex().AddItem(ui.StatusView.GetLeftView(), 0, 1, false) - } - - var listViewChoice *tview.Flex - if ui.app.Config.General.ListSplit == ListRow { - listViewChoice = listViewRow - } else { - listViewChoice = listViewColumn - } - - switch ui.app.Config.General.ListPlacement { - case ListPlacementLeft: - 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(listViewChoice, 0, lp, false). - AddItem(verticalLine, 1, 0, false). - AddItem(tview.NewBox().SetBackgroundColor(ui.app.Config.Style.Background), 1, 0, false). - AddItem(ui.StatusView.GetRightView(), 0, cp, false), - 0, 1, false). - AddItem(ui.StatusBar.Text, 1, 1, false). - AddItem(ui.CmdBar.Input, 1, 0, false), 0, 1, false), true, true) - case ListPlacementRight: - 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(tview.NewBox().SetBackgroundColor(ui.app.Config.Style.Background), 1, 0, false). - AddItem(ui.StatusView.GetRightView(), 0, cp, false). - AddItem(verticalLine, 1, 0, false). - AddItem(listViewChoice, 0, 1, false), 0, lp, false). - AddItem(ui.StatusBar.Text, 1, 1, false). - AddItem(ui.CmdBar.Input, 1, 0, false), 0, 1, false), true, true) - case ListPlacementTop: - ui.Pages.AddPage("main", - tview.NewFlex(). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(ui.Top.Text, 1, 0, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(listViewChoice, 0, lp, false). - AddItem(horizontalLine, 1, 0, false). - AddItem(ui.StatusView.GetRightView(), 0, cp, false), - 0, 1, false). - AddItem(ui.StatusBar.Text, 1, 1, false). - AddItem(ui.CmdBar.Input, 1, 0, false), 0, 1, false), true, true) - case ListPlacementBottom: - ui.Pages.AddPage("main", - tview.NewFlex(). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(ui.Top.Text, 1, 0, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(ui.StatusView.GetRightView(), 0, cp, false). - AddItem(horizontalLine, 1, 0, false). - AddItem(listViewChoice, 0, lp, false), - 0, 1, false). - AddItem(ui.StatusBar.Text, 1, 1, false). - AddItem(ui.CmdBar.Input, 1, 0, false), 0, 1, false), true, true) - } - ui.Pages.SendToBack("main") - - ui.SetFocus(LeftPaneFocus) - - me, err := ui.app.API.Client.GetAccountCurrentUser(context.Background()) - if err != nil { - log.Fatalln(err) - } - ui.app.Me = me - ui.StatusView.AddFeed( - NewTimelineFeed(ui.app, ui.Timeline, nil), - ) -} - -func (conf *Config) ClearContent(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { - for cx := x; cx < width+x; cx++ { - for cy := y; cy < height+y; cy++ { - screen.SetContent(cx, cy, ' ', nil, tcell.StyleDefault.Background(conf.Style.Background)) - } - } - y2 := y + height - for cx := x + 1; cx < width+x; cx++ { - screen.SetContent(cx, y, tview.BoxDrawingsLightHorizontal, nil, tcell.StyleDefault.Foreground(conf.Style.Subtle).Background(conf.Style.Background)) - screen.SetContent(cx, y2, tview.BoxDrawingsLightHorizontal, nil, tcell.StyleDefault.Foreground(conf.Style.Subtle).Background(conf.Style.Background)) - } - x2 := x + width - for cy := y + 1; cy < height+y; cy++ { - screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, tcell.StyleDefault.Foreground(conf.Style.Subtle).Background(conf.Style.Background)) - screen.SetContent(x2, cy, tview.BoxDrawingsLightVertical, nil, tcell.StyleDefault.Foreground(conf.Style.Subtle).Background(conf.Style.Background)) - } - screen.SetContent(x, y, tview.BoxDrawingsLightDownAndRight, nil, tcell.StyleDefault.Foreground(conf.Style.Subtle).Background(conf.Style.Background)) - screen.SetContent(x, y+height, tview.BoxDrawingsLightUpAndRight, nil, tcell.StyleDefault.Foreground(conf.Style.Subtle).Background(conf.Style.Background)) - screen.SetContent(x+width, y, tview.BoxDrawingsLightDownAndLeft, nil, tcell.StyleDefault.Foreground(conf.Style.Subtle).Background(conf.Style.Background)) - screen.SetContent(x+width, y+height, tview.BoxDrawingsLightUpAndLeft, nil, tcell.StyleDefault.Foreground(conf.Style.Subtle).Background(conf.Style.Background)) - return x + 1, y + 1, width - 1, height - 1 -} - -func (ui *UI) ShouldSync() { - if !ui.app.Config.General.RedrawUI { - return - } - ui.app.UI.Root.Sync() -} diff --git a/ui/bottom.go b/ui/bottom.go new file mode 100644 index 0000000..691f09a --- /dev/null +++ b/ui/bottom.go @@ -0,0 +1,22 @@ +package ui + +import "github.com/rivo/tview" + +type Bottom struct { + View tview.Primitive + StatusBar *StatusBar + Cmd *CmdBar +} + +func NewBottom(tv *TutView) *Bottom { + b := &Bottom{ + StatusBar: NewStatusBar(tv), + Cmd: NewCmdBar(tv), + } + view := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(b.StatusBar.View, 1, 0, false). + AddItem(b.Cmd.View, 1, 0, false) + + b.View = view + return b +} diff --git a/ui/cliview.go b/ui/cliview.go new file mode 100644 index 0000000..718fdc8 --- /dev/null +++ b/ui/cliview.go @@ -0,0 +1,58 @@ +package ui + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/RasmusLindroth/tut/config" +) + +func CliView(version string) (newUser bool, selectedUser string) { + if len(os.Args) > 1 { + switch os.Args[1] { + case "example-config": + config.CreateDefaultConfig("./config.example.ini") + os.Exit(0) + case "--new-user", "-n": + newUser = true + case "--user", "-u": + if len(os.Args) > 2 { + name := os.Args[2] + selectedUser = strings.TrimSpace(name) + } else { + log.Fatalln("--user/-u must be followed by a user name. Like -u tut") + } + case "--help", "-h": + fmt.Print("tut - a TUI for Mastodon with vim inspired keys.\n\n") + fmt.Print("Usage:\n\n") + fmt.Print("\tTo run the program you just have to write tut\n\n") + + fmt.Print("Commands:\n\n") + fmt.Print("\texample-config - creates the default configuration file in the current directory and names it ./config.example.ini\n\n") + + fmt.Print("Flags:\n\n") + fmt.Print("\t--help -h - prints this message\n") + fmt.Print("\t--version -v - prints the version\n") + fmt.Print("\t--new-user -n - add one more user to tut\n") + fmt.Print("\t--user -u - login directly to user named \n") + fmt.Print("\t\tDon't use a = between --user and the \n") + fmt.Print("\t\tIf two users are named the same. Use full name like tut@fosstodon.org\n\n") + + fmt.Print("Configuration:\n\n") + fmt.Printf("\tThe config is located in XDG_CONFIG_HOME/tut/config.ini which usally equals to ~/.config/tut/config.ini.\n") + fmt.Printf("\tThe program will generate the file the first time you run tut. The file has comments which exmplains what each configuration option does.\n\n") + + fmt.Print("Contact info for issues or questions:\n\n") + fmt.Printf("\t@rasmus@mastodon.acc.sunet.se\n\trasmus@lindroth.xyz\n") + fmt.Printf("\thttps://github.com/RasmusLindroth/tut\n") + os.Exit(0) + case "--version", "-v": + fmt.Printf("tut version %s\n\n", version) + fmt.Printf("https://github.com/RasmusLindroth/tut\n") + os.Exit(0) + } + } + return newUser, selectedUser +} diff --git a/ui/cmdbar.go b/ui/cmdbar.go new file mode 100644 index 0000000..862f0c3 --- /dev/null +++ b/ui/cmdbar.go @@ -0,0 +1,262 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/util" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type CmdBar struct { + tutView *TutView + View *tview.InputField +} + +func NewCmdBar(tv *TutView) *CmdBar { + c := &CmdBar{ + tutView: tv, + View: NewInputField(tv.tut.Config), + } + c.View.SetAutocompleteFunc(c.Autocomplete) + c.View.SetDoneFunc(c.DoneFunc) + + if tv.tut.Config.General.ShowHelp { + c.ShowMsg("Press ? or :help to learn how tut functions") + } + + return c +} + +func (c *CmdBar) GetInput() string { + return strings.TrimSpace(c.View.GetText()) +} + +func (c *CmdBar) ShowError(s string) { + c.View.SetFieldTextColor(c.tutView.tut.Config.Style.WarningText) + c.View.SetText(s) +} + +func (c *CmdBar) ShowMsg(s string) { + c.View.SetFieldTextColor(c.tutView.tut.Config.Style.StatusBarText) + c.View.SetText(s) +} + +func (c *CmdBar) ClearInput() { + c.View.SetFieldTextColor(c.tutView.tut.Config.Style.StatusBarText) + c.View.SetText("") +} + +func (c *CmdBar) Back() { + c.ClearInput() + c.View.Autocomplete() + c.tutView.PrevFocus() +} + +func (c *CmdBar) DoneFunc(key tcell.Key) { + if key == tcell.KeyTAB { + return + } + input := c.GetInput() + parts := strings.Split(input, " ") + item, itemErr := c.tutView.GetCurrentItem() + if len(parts) == 0 { + return + } + switch parts[0] { + case ":q": + fallthrough + case ":quit": + c.tutView.tut.App.Stop() + case ":compose": + c.tutView.InitPost(nil) + c.Back() + case ":blocking": + c.tutView.Timeline.AddFeed( + NewBlocking(c.tutView), + ) + c.Back() + case ":bookmarks", ":saved": + c.tutView.Timeline.AddFeed( + NewBookmarksFeed(c.tutView), + ) + c.Back() + case ":favorited": + c.tutView.Timeline.AddFeed( + NewFavoritedFeed(c.tutView), + ) + c.Back() + case ":boosts": + if itemErr != nil { + c.Back() + return + } + if item.Type() != api.StatusType { + c.Back() + return + } + s := item.Raw().(*mastodon.Status) + s = util.StatusOrReblog(s) + c.tutView.Timeline.AddFeed( + NewBoosts(c.tutView, s.ID), + ) + c.Back() + case ":favorites": + if itemErr != nil { + c.Back() + return + } + if item.Type() != api.StatusType { + c.Back() + return + } + s := item.Raw().(*mastodon.Status) + s = util.StatusOrReblog(s) + c.tutView.Timeline.AddFeed( + NewFavoritesStatus(c.tutView, s.ID), + ) + c.Back() + case ":following": + if itemErr != nil { + c.Back() + return + } + if item.Type() != api.UserType && item.Type() != api.ProfileType { + c.Back() + return + } + s := item.Raw().(*api.User) + c.tutView.Timeline.AddFeed( + NewFollowing(c.tutView, s.Data.ID), + ) + c.Back() + case ":followers": + if itemErr != nil { + c.Back() + return + } + if item.Type() != api.UserType && item.Type() != api.ProfileType { + c.Back() + return + } + s := item.Raw().(*api.User) + c.tutView.Timeline.AddFeed( + NewFollowers(c.tutView, s.Data.ID), + ) + c.Back() + case ":muting": + c.tutView.Timeline.AddFeed( + NewMuting(c.tutView), + ) + c.Back() + case ":profile": + item, err := c.tutView.tut.Client.GetUserByID(c.tutView.tut.Client.Me.ID) + if err != nil { + c.ShowError(fmt.Sprintf("Couldn't load user. Error: %v\n", err)) + c.Back() + } + c.tutView.Timeline.AddFeed( + NewUserFeed(c.tutView, item), + ) + c.Back() + case ":timeline", ":tl": + if len(parts) < 2 { + break + } + switch parts[1] { + case "local", "l": + c.tutView.Timeline.AddFeed( + NewLocalFeed(c.tutView), + ) + c.Back() + case "federated", "f": + c.tutView.Timeline.AddFeed( + NewFederatedFeed(c.tutView), + ) + c.Back() + case "direct", "d": + c.tutView.Timeline.AddFeed( + NewConversationsFeed(c.tutView), + ) + c.Back() + case "home", "h": + c.tutView.Timeline.AddFeed( + NewHomeFeed(c.tutView), + ) + c.Back() + case "notifications", "n": + c.tutView.Timeline.AddFeed( + NewNotificationFeed(c.tutView), + ) + c.Back() + case "favorited", "fav": + c.tutView.Timeline.AddFeed( + NewFavoritedFeed(c.tutView), + ) + c.Back() + } + c.ClearInput() + case ":tag": + if len(parts) < 2 { + break + } + tag := strings.TrimSpace(strings.TrimPrefix(parts[1], "#")) + if len(tag) == 0 { + break + } + c.tutView.Timeline.AddFeed( + NewTagFeed(c.tutView, tag), + ) + c.Back() + case ":user": + if len(parts) < 2 { + break + } + user := strings.TrimSpace(parts[1]) + if len(user) == 0 { + break + } + c.tutView.Timeline.AddFeed( + NewUserSearchFeed(c.tutView, user), + ) + c.Back() + case ":lists": + c.tutView.Timeline.AddFeed( + NewListsFeed(c.tutView), + ) + c.Back() + case ":help", ":h": + c.tutView.PageFocus = c.tutView.PrevPageFocus + c.tutView.SetPage(HelpFocus) + c.ClearInput() + c.View.Autocomplete() + } +} + +func (c *CmdBar) Autocomplete(curr string) []string { + var entries []string + words := strings.Split(":blocking,:boosts,:bookmarks,:compose,:favorites,:favorited,:followers,:following,:help,:h,:lists,:muting,:profile,:saved,:tag,:timeline,:tl,:user,:quit,:q", ",") + if curr == "" { + return entries + } + + if len(curr) > 2 && curr[:3] == ":tl" { + words = strings.Split(":tl home,:tl notifications,:tl local,:tl federated,:tl direct,:tl favorited", ",") + } + if len(curr) > 8 && curr[:9] == ":timeline" { + words = strings.Split(":timeline home,:timeline notifications,:timeline local,:timeline federated,:timeline direct,:timeline favorited", ",") + } + + for _, word := range words { + if strings.HasPrefix(strings.ToLower(word), strings.ToLower(curr)) { + entries = append(entries, word) + } + } + if len(entries) < 1 { + entries = nil + } + return entries +} diff --git a/ui/composeview.go b/ui/composeview.go new file mode 100644 index 0000000..6cbba3b --- /dev/null +++ b/ui/composeview.go @@ -0,0 +1,577 @@ +package ui + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/config" + "github.com/RasmusLindroth/tut/util" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/rivo/uniseg" +) + +type msgToot struct { + Text string + Status *mastodon.Status + MediaIDs []mastodon.ID + Sensitive bool + SpoilerText string + ScheduledAt *time.Time + QuoteIncluded bool + Visibility string +} + +type ComposeView struct { + tutView *TutView + shared *Shared + View *tview.Flex + content *tview.TextView + input *MediaInput + info *tview.TextView + controls *tview.TextView + visibility *tview.DropDown + media *MediaList + msg *msgToot +} + +var visibilities = []string{mastodon.VisibilityPublic, mastodon.VisibilityUnlisted, mastodon.VisibilityFollowersOnly, mastodon.VisibilityDirectMessage} + +func NewComposeView(tv *TutView) *ComposeView { + cv := &ComposeView{ + tutView: tv, + shared: tv.Shared, + content: NewTextView(tv.tut.Config), + input: NewMediaInput(tv), + controls: NewTextView(tv.tut.Config), + info: NewTextView(tv.tut.Config), + visibility: NewDropDown(tv.tut.Config), + media: NewMediaList(tv), + } + cv.content.SetDynamicColors(true) + cv.controls.SetDynamicColors(true) + cv.View = newComposeUI(cv) + return cv +} + +func newComposeUI(cv *ComposeView) *tview.Flex { + return tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(cv.tutView.Shared.Top.View, 1, 0, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(cv.content, 0, 2, false), 0, 2, false). + AddItem(tview.NewBox(), 2, 0, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(cv.visibility, 1, 0, false). + AddItem(cv.info, 4, 0, false). + AddItem(cv.media.View, 0, 1, false), 0, 1, false), 0, 1, false). + AddItem(cv.input.View, 1, 0, false). + AddItem(cv.controls, 1, 0, false). + AddItem(cv.tutView.Shared.Bottom.View, 2, 0, false) +} + +type ComposeControls uint + +const ( + ComposeNormal ComposeControls = iota + ComposeMedia +) + +func (cv *ComposeView) msgLength() int { + m := cv.msg + charCount := uniseg.GraphemeClusterCount(m.Text) + spoilerCount := uniseg.GraphemeClusterCount(m.SpoilerText) + totalCount := charCount + if m.Sensitive { + totalCount += spoilerCount + } + charsLeft := cv.tutView.tut.Config.General.CharLimit - totalCount + return charsLeft +} + +func (cv *ComposeView) SetControls(ctrl ComposeControls) { + var items []string + switch ctrl { + case ComposeNormal: + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "P", "ost")) + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "E", "dit")) + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "V", "isibility")) + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "T", "oggle CW")) + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "C", "ontent warning text")) + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "M", "edia attachment")) + if cv.msg.Status != nil { + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "I", "nclude quote")) + } + case ComposeMedia: + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "A", "dd")) + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "D", "elete")) + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "E", "dit desc")) + items = append(items, config.ColorKey(cv.tutView.tut.Config, "", "Esc", " Done")) + } + res := strings.Join(items, " ") + cv.controls.SetText(res) +} + +func (cv *ComposeView) SetStatus(status *mastodon.Status) { + msg := &msgToot{} + if status != nil { + if status.Reblog != nil { + status = status.Reblog + } + msg.Status = status + if status.Sensitive { + msg.Sensitive = true + msg.SpoilerText = status.SpoilerText + } + msg.Visibility = status.Visibility + } + cv.msg = msg + cv.msg.Text = cv.getAccs() + if cv.tutView.tut.Config.General.QuoteReply { + cv.IncludeQuote() + } + cv.visibility.SetLabel("Visibility: ") + index := 0 + for i, v := range visibilities { + if msg.Visibility == v { + index = i + break + } + } + cv.visibility.SetOptions(visibilities, cv.visibilitySelected) + cv.visibility.SetCurrentOption(index) + cv.visibility.SetInputCapture(cv.visibilityInput) + cv.updateContent() + cv.SetControls(ComposeNormal) +} + +func (cv *ComposeView) getAccs() string { + if cv.msg.Status == nil { + return "" + } + s := cv.msg.Status + var users []string + if s.Account.Acct != cv.tutView.tut.Client.Me.Acct { + users = append(users, "@"+s.Account.Acct) + } + for _, men := range s.Mentions { + if men.Acct == cv.tutView.tut.Client.Me.Acct { + continue + } + users = append(users, "@"+men.Acct) + } + t := strings.Join(users, " ") + return t +} + +func (cv *ComposeView) EditText() { + text, err := OpenEditor(cv.tutView, cv.msg.Text) + if err != nil { + cv.tutView.ShowError( + fmt.Sprintf("Couldn't open editor. Error: %v", err), + ) + return + } + cv.msg.Text = text + cv.updateContent() +} + +func (cv *ComposeView) EditSpoiler() { + text, err := OpenEditor(cv.tutView, cv.msg.SpoilerText) + if err != nil { + cv.tutView.ShowError( + fmt.Sprintf("Couldn't open editor. Error: %v", err), + ) + return + } + cv.msg.SpoilerText = text + cv.updateContent() +} + +func (cv *ComposeView) ToggleCW() { + cv.msg.Sensitive = !cv.msg.Sensitive + cv.updateContent() +} + +func (cv *ComposeView) updateContent() { + cv.info.SetText(fmt.Sprintf("Chars left: %d\nSpoiler: %t\n", cv.msgLength(), cv.msg.Sensitive)) + normal := config.ColorMark(cv.tutView.tut.Config.Style.Text) + subtleColor := config.ColorMark(cv.tutView.tut.Config.Style.Subtle) + warningColor := config.ColorMark(cv.tutView.tut.Config.Style.WarningText) + + var outputHead string + var output string + + if cv.msg.Status != nil { + var acct string + if cv.msg.Status.Account.DisplayName != "" { + acct = fmt.Sprintf("%s (%s)\n", cv.msg.Status.Account.DisplayName, cv.msg.Status.Account.Acct) + } else { + acct = fmt.Sprintf("%s\n", cv.msg.Status.Account.Acct) + } + outputHead += subtleColor + "Replying to " + tview.Escape(acct) + "\n" + normal + } + if cv.msg.SpoilerText != "" && !cv.msg.Sensitive { + outputHead += warningColor + "You have entered spoiler text, but haven't set an content warning. Do it by pressing " + tview.Escape("[T]") + "\n\n" + normal + } + + if cv.msg.Sensitive && cv.msg.SpoilerText == "" { + outputHead += warningColor + "You have added an content warning, but haven't set any text above the hidden text. Do it by pressing " + tview.Escape("[C]") + "\n\n" + normal + } + + if cv.msg.Sensitive && cv.msg.SpoilerText != "" { + outputHead += subtleColor + "Content warning\n\n" + normal + outputHead += tview.Escape(cv.msg.SpoilerText) + outputHead += "\n\n" + subtleColor + "---hidden content below---\n\n" + normal + } + output = outputHead + normal + tview.Escape(cv.msg.Text) + + cv.content.SetText(output) +} + +func (cv *ComposeView) IncludeQuote() { + if cv.msg.QuoteIncluded { + return + } + t := cv.msg.Text + s := cv.msg.Status + if s == nil { + return + } + tootText, _ := util.CleanHTML(s.Content) + + t += "\n\n" + for _, line := range strings.Split(tootText, "\n") { + t += "> " + line + "\n" + } + t += "\n" + cv.msg.Text = t + cv.msg.QuoteIncluded = true + cv.updateContent() +} + +func (cv *ComposeView) visibilityInput(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'j', 'J': + return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) + case 'k', 'K': + return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) + case 'q', 'Q': + cv.exitVisibility() + return nil + } + } else { + switch event.Key() { + case tcell.KeyEsc: + cv.exitVisibility() + return nil + } + } + return event +} + +func (cv *ComposeView) exitVisibility() { + cv.tutView.tut.App.SetInputCapture(cv.tutView.Input) + cv.tutView.tut.App.SetFocus(cv.content) +} + +func (cv *ComposeView) visibilitySelected(s string, index int) { + _, cv.msg.Visibility = cv.visibility.GetCurrentOption() + cv.exitVisibility() +} + +func (cv *ComposeView) FocusVisibility() { + cv.tutView.tut.App.SetInputCapture(cv.visibilityInput) + cv.tutView.tut.App.SetFocus(cv.visibility) + ev := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) + cv.tutView.tut.App.QueueEvent(ev) +} + +func (cv *ComposeView) Post() { + toot := cv.msg + send := mastodon.Toot{ + Status: strings.TrimSpace(toot.Text), + } + if toot.Status != nil { + send.InReplyToID = toot.Status.ID + } + if toot.Sensitive { + send.Sensitive = true + send.SpoilerText = toot.SpoilerText + } + + attachments := cv.media.Files + for _, ap := range attachments { + f, err := os.Open(ap.Path) + if err != nil { + cv.tutView.ShowError( + fmt.Sprintf("Couldn't upload media. Error: %v\n", err), + ) + f.Close() + return + } + media := &mastodon.Media{ + File: f, + } + if ap.Description != "" { + media.Description = ap.Description + } + a, err := cv.tutView.tut.Client.Client.UploadMediaFromMedia(context.Background(), media) + if err != nil { + cv.tutView.ShowError( + fmt.Sprintf("Couldn't upload media. Error: %v\n", err), + ) + f.Close() + return + } + f.Close() + send.MediaIDs = append(send.MediaIDs, a.ID) + } + send.Visibility = cv.msg.Visibility + + _, err := cv.tutView.tut.Client.Client.PostStatus(context.Background(), &send) + if err != nil { + cv.tutView.ShowError( + fmt.Sprintf("Couldn't post toot. Error: %v\n", err), + ) + return + } + cv.tutView.SetPage(MainFocus) +} + +type MediaList struct { + tutView *TutView + View *tview.Flex + heading *tview.TextView + text *tview.TextView + list *tview.List + Files []UploadFile +} + +func NewMediaList(tv *TutView) *MediaList { + ml := &MediaList{ + tutView: tv, + heading: NewTextView(tv.tut.Config), + text: NewTextView(tv.tut.Config), + list: NewList(tv.tut.Config), + } + ml.heading.SetText(fmt.Sprintf("Media files: %d", ml.list.GetItemCount())) + ml.heading.SetBorderPadding(1, 1, 0, 0) + ml.View = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(ml.heading, 1, 0, false). + AddItem(ml.text, 1, 0, false). + AddItem(ml.list, 0, 1, false) + return ml +} + +type UploadFile struct { + Path string + Description string +} + +func (m *MediaList) Reset() { + m.Files = nil + m.list.Clear() + m.Draw() +} + +func (m *MediaList) AddFile(f string) { + file := UploadFile{Path: f} + m.Files = append(m.Files, file) + m.list.AddItem(filepath.Base(f), "", 0, nil) + index := m.list.GetItemCount() + m.list.SetCurrentItem(index - 1) + m.Draw() +} + +func (m *MediaList) Draw() { + topText := "File desc: " + + index := m.list.GetCurrentItem() + if len(m.Files) != 0 && index < len(m.Files) && m.Files[index].Description != "" { + topText += tview.Escape(m.Files[index].Description) + } + m.text.SetText(topText) +} + +func (m *MediaList) SetFocus(reset bool) { + if reset { + m.tutView.ComposeView.input.View.SetText("") + return + } + pwd, err := os.Getwd() + if err != nil { + home, err := os.UserHomeDir() + if err != nil { + pwd = "" + } else { + pwd = home + } + } + if !strings.HasSuffix(pwd, "/") { + pwd += "/" + } + m.tutView.ComposeView.input.View.SetText(pwd) +} + +func (m *MediaList) Prev() { + index := m.list.GetCurrentItem() + if index-1 >= 0 { + m.list.SetCurrentItem(index - 1) + } + m.Draw() +} + +func (m *MediaList) Next() { + index := m.list.GetCurrentItem() + if index+1 < m.list.GetItemCount() { + m.list.SetCurrentItem(index + 1) + } + m.Draw() +} + +func (m *MediaList) Delete() { + index := m.list.GetCurrentItem() + if len(m.Files) == 0 || index > len(m.Files) { + return + } + m.list.RemoveItem(index) + m.Files = append(m.Files[:index], m.Files[index+1:]...) + m.Draw() +} + +func (m *MediaList) EditDesc() { + index := m.list.GetCurrentItem() + if len(m.Files) == 0 || index > len(m.Files) { + return + } + file := m.Files[index] + desc, err := OpenEditor(m.tutView, file.Description) + if err != nil { + m.tutView.ShowError( + fmt.Sprintf("Couldn't edit description. Error: %v\n", err), + ) + return + } + file.Description = desc + m.Files[index] = file + m.Draw() +} + +type MediaInput struct { + tutView *TutView + View *tview.InputField + text string + autocompleteIndex int + autocompleteList []string + isAutocompleteChange bool +} + +func NewMediaInput(tv *TutView) *MediaInput { + m := &MediaInput{ + tutView: tv, + View: NewInputField(tv.tut.Config), + } + m.View.SetChangedFunc(m.HandleChanges) + return m +} + +func (m *MediaInput) AddRune(r rune) { + newText := m.View.GetText() + string(r) + m.text = newText + m.View.SetText(m.text) + m.saveAutocompleteState() +} + +func (m *MediaInput) HandleChanges(text string) { + if m.isAutocompleteChange { + m.isAutocompleteChange = false + return + } + m.saveAutocompleteState() +} + +func (m *MediaInput) saveAutocompleteState() { + text := m.View.GetText() + m.text = text + m.autocompleteList = util.FindFiles(text) + m.autocompleteIndex = 0 +} + +func (m *MediaInput) AutocompletePrev() { + if len(m.autocompleteList) == 0 { + return + } + index := m.autocompleteIndex - 1 + if index < 0 { + index = len(m.autocompleteList) - 1 + } + m.autocompleteIndex = index + m.showAutocomplete() +} + +func (m *MediaInput) AutocompleteTab() { + if len(m.autocompleteList) == 0 { + return + } + same := "" + for i := 0; i < len(m.autocompleteList[0]); i++ { + match := true + c := m.autocompleteList[0][i] + for _, item := range m.autocompleteList { + if i >= len(item) || c != item[i] { + match = false + break + } + } + if !match { + break + } + same += string(c) + } + if same != m.text { + m.text = same + m.View.SetText(same) + m.saveAutocompleteState() + } else { + m.AutocompleteNext() + } +} + +func (m *MediaInput) AutocompleteNext() { + if len(m.autocompleteList) == 0 { + return + } + index := m.autocompleteIndex + 1 + if index >= len(m.autocompleteList) { + index = 0 + } + m.autocompleteIndex = index + m.showAutocomplete() +} + +func (m *MediaInput) CheckDone() { + path := m.View.GetText() + if util.IsDir(path) { + m.saveAutocompleteState() + return + } + + m.tutView.ComposeView.media.AddFile(path) + m.tutView.ComposeView.media.SetFocus(true) + m.tutView.SetPage(MediaFocus) +} + +func (m *MediaInput) showAutocomplete() { + m.isAutocompleteChange = true + m.View.SetText(m.autocompleteList[m.autocompleteIndex]) + if len(m.autocompleteList) < 3 { + m.saveAutocompleteState() + } +} diff --git a/ui/feed.go b/ui/feed.go new file mode 100644 index 0000000..ce728ec --- /dev/null +++ b/ui/feed.go @@ -0,0 +1,531 @@ +package ui + +import ( + "fmt" + "strconv" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/config" + "github.com/RasmusLindroth/tut/feed" + "github.com/gdamore/tcell/v2" + "github.com/gen2brain/beeep" + "github.com/rivo/tview" +) + +type FeedList struct { + Text *tview.List + Symbol *tview.List +} + +func (fl *FeedList) InFocus(style config.Style) { + inFocus(fl.Text, style) + inFocus(fl.Symbol, style) +} + +func inFocus(l *tview.List, style config.Style) { + l.SetBackgroundColor(style.Background) + l.SetMainTextColor(style.Text) + l.SetSelectedBackgroundColor(style.ListSelectedBackground) + l.SetSelectedTextColor(style.ListSelectedText) +} + +func (fl *FeedList) OutFocus(style config.Style) { + outFocus(fl.Text, style) + outFocus(fl.Symbol, style) +} + +func outFocus(l *tview.List, style config.Style) { + l.SetBackgroundColor(style.Background) + l.SetMainTextColor(style.Text) + l.SetSelectedBackgroundColor(style.StatusBarViewBackground) + l.SetSelectedTextColor(style.StatusBarViewText) +} + +type Feed struct { + tutView *TutView + Data *feed.Feed + ListIndex int + List *FeedList + Content *FeedContent +} + +func (f *Feed) ListInFocus() { + f.List.InFocus(f.tutView.tut.Config.Style) +} + +func (f *Feed) ListOutFocus() { + f.List.OutFocus(f.tutView.tut.Config.Style) +} + +func (f *Feed) LoadOlder() { + f.Data.LoadOlder() +} + +func (f *Feed) LoadNewer() { + if f.Data.HasStream() { + return + } + f.Data.LoadNewer() +} + +func (f *Feed) DrawContent() { + id := f.List.GetCurrentID() + for _, item := range f.Data.List() { + if id != item.ID() { + continue + } + DrawItem(f.tutView.tut, item, f.Content.Main, f.Content.Controls) + f.tutView.LinkView.SetLinks(item) + f.tutView.ShouldSync() + } +} + +func (f *Feed) update() { + for nft := range f.Data.Update { + switch nft { + case feed.DesktopNotificationFollower: + if f.tutView.tut.Config.NotificationConfig.NotificationFollower { + beeep.Notify("New follower", "", "") + } + case feed.DesktopNotificationFavorite: + if f.tutView.tut.Config.NotificationConfig.NotificationFavorite { + beeep.Notify("Favorited your toot", "", "") + } + case feed.DesktopNotificationMention: + if f.tutView.tut.Config.NotificationConfig.NotificationMention { + beeep.Notify("Mentioned you", "", "") + } + case feed.DesktopNotificationBoost: + if f.tutView.tut.Config.NotificationConfig.NotificationBoost { + beeep.Notify("Boosted your toot", "", "") + } + case feed.DesktopNotificationPoll: + if f.tutView.tut.Config.NotificationConfig.NotificationPoll { + beeep.Notify("Poll has ended", "", "") + } + case feed.DesktopNotificationPost: + if f.tutView.tut.Config.NotificationConfig.NotificationPost { + beeep.Notify("New post", "", "") + } + } + f.tutView.tut.App.QueueUpdateDraw(func() { + lLen := f.List.GetItemCount() + curr := f.List.GetCurrentID() + f.List.Clear() + for _, item := range f.Data.List() { + main, symbol := DrawListItem(f.tutView.tut.Config, item) + f.List.AddItem(main, symbol, item.ID()) + } + f.List.SetByID(curr) + if lLen == 0 { + f.DrawContent() + } + }) + } +} + +func NewHomeFeed(tv *TutView) *Feed { + f := feed.NewTimelineHome(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewFederatedFeed(tv *TutView) *Feed { + f := feed.NewTimelineFederated(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewLocalFeed(tv *TutView) *Feed { + f := feed.NewTimelineFederated(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewNotificationFeed(tv *TutView) *Feed { + f := feed.NewNotifications(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewThreadFeed(tv *TutView, item api.Item) *Feed { + f := feed.NewThread(tv.tut.Client, item.Raw().(*mastodon.Status)) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + for i, s := range f.List() { + main, symbol := DrawListItem(tv.tut.Config, s) + fd.List.AddItem(main, symbol, s.ID()) + if s.Raw().(*mastodon.Status).ID == item.Raw().(*mastodon.Status).ID { + fd.List.SetCurrentItem(i) + } + } + fd.DrawContent() + + return fd +} + +func NewConversationsFeed(tv *TutView) *Feed { + f := feed.NewConversations(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewUserFeed(tv *TutView, item api.Item) *Feed { + if item.Type() != api.UserType && item.Type() != api.ProfileType { + panic("Can't open user. Wrong type.\n") + } + u := item.Raw().(*api.User) + f := feed.NewUserProfile(tv.tut.Client, u) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewUserSearchFeed(tv *TutView, search string) *Feed { + f := feed.NewUserSearch(tv.tut.Client, search) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + for _, s := range f.List() { + main, symbol := DrawListItem(tv.tut.Config, s) + fd.List.AddItem(main, symbol, s.ID()) + } + fd.DrawContent() + + return fd +} + +func NewTagFeed(tv *TutView, search string) *Feed { + f := feed.NewTag(tv.tut.Client, search) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} +func NewListsFeed(tv *TutView) *Feed { + f := feed.NewListList(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewListFeed(tv *TutView, l *mastodon.List) *Feed { + f := feed.NewList(tv.tut.Client, l) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewFavoritedFeed(tv *TutView) *Feed { + f := feed.NewFavorites(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + + go fd.update() + return fd +} + +func NewBookmarksFeed(tv *TutView) *Feed { + f := feed.NewBookmarks(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewFavoritesStatus(tv *TutView, id mastodon.ID) *Feed { + f := feed.NewFavoritesStatus(tv.tut.Client, id) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewBoosts(tv *TutView, id mastodon.ID) *Feed { + f := feed.NewBoosts(tv.tut.Client, id) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewFollowers(tv *TutView, id mastodon.ID) *Feed { + f := feed.NewFollowers(tv.tut.Client, id) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewFollowing(tv *TutView, id mastodon.ID) *Feed { + f := feed.NewFollowing(tv.tut.Client, id) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewBlocking(tv *TutView) *Feed { + f := feed.NewBlocking(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewMuting(tv *TutView) *Feed { + f := feed.NewMuting(tv.tut.Client) + f.LoadNewer() + fd := &Feed{ + tutView: tv, + Data: f, + ListIndex: 0, + List: NewFeedList(tv.tut), + Content: NewFeedContent(tv.tut), + } + go fd.update() + + return fd +} + +func NewFeedList(t *Tut) *FeedList { + fl := &FeedList{ + Text: NewList(t.Config), + Symbol: NewList(t.Config), + } + return fl +} + +func (fl *FeedList) AddItem(text string, symbols string, id uint) { + fl.Text.AddItem(text, fmt.Sprintf("%d", id), 0, nil) + fl.Symbol.AddItem(symbols, fmt.Sprintf("%d", id), 0, nil) +} + +func (fl *FeedList) Next() (loadOlder bool) { + ni := fl.Text.GetCurrentItem() + 1 + if ni >= fl.Text.GetItemCount() { + ni = fl.Text.GetItemCount() - 1 + if ni < 0 { + ni = 0 + } + } + fl.Text.SetCurrentItem(ni) + fl.Symbol.SetCurrentItem(ni) + return fl.Text.GetItemCount()-(ni+1) < 5 +} + +func (fl *FeedList) Prev() (loadNewer bool) { + ni := fl.Text.GetCurrentItem() - 1 + if ni < 0 { + ni = 0 + } + fl.Text.SetCurrentItem(ni) + fl.Symbol.SetCurrentItem(ni) + return ni < 4 +} + +func (fl *FeedList) Clear() { + fl.Text.Clear() + fl.Symbol.Clear() +} + +func (fl *FeedList) GetItemCount() int { + return fl.Text.GetItemCount() +} + +func (fl *FeedList) SetCurrentItem(index int) { + fl.Text.SetCurrentItem(index) + fl.Symbol.SetCurrentItem(index) +} + +func (fl *FeedList) GetCurrentID() uint { + if fl.GetItemCount() == 0 { + return 0 + } + i := fl.Text.GetCurrentItem() + _, sec := fl.Text.GetItemText(i) + id, err := strconv.ParseUint(sec, 10, 32) + if err != nil { + return 0 + } + return uint(id) +} + +func (fl *FeedList) SetByID(id uint) { + if fl.Text.GetItemCount() == 0 { + return + } + s := fmt.Sprintf("%d", id) + items := fl.Text.FindItems("", s, false, false) + for _, i := range items { + _, sec := fl.Text.GetItemText(i) + if sec == s { + fl.Text.SetCurrentItem(i) + fl.Symbol.SetCurrentItem(i) + break + } + } +} + +type FeedContent struct { + Main *tview.TextView + Controls *tview.TextView +} + +func NewFeedContent(t *Tut) *FeedContent { + m := NewTextView(t.Config) + + if t.Config.General.MaxWidth > 0 { + mw := t.Config.General.MaxWidth + m.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { + rWidth := width + if rWidth > mw { + rWidth = mw + } + return x, y, rWidth, height + }) + } + c := NewTextView(t.Config) + c.SetDynamicColors(true) + fc := &FeedContent{ + Main: m, + Controls: c, + } + return fc +} diff --git a/ui/helpers.go b/ui/helpers.go new file mode 100644 index 0000000..33281c7 --- /dev/null +++ b/ui/helpers.go @@ -0,0 +1,24 @@ +package ui + +import "github.com/rivo/tview" + +func listNext(l *tview.List) (loadOlder bool) { + ni := l.GetCurrentItem() + 1 + if ni >= l.GetItemCount() { + ni = l.GetItemCount() - 1 + if ni < 0 { + ni = 0 + } + } + l.SetCurrentItem(ni) + return l.GetItemCount()-(ni+1) < 5 +} + +func listPrev(l *tview.List) (loadNewer bool) { + ni := l.GetCurrentItem() - 1 + if ni < 0 { + ni = 0 + } + l.SetCurrentItem(ni) + return ni < 4 +} diff --git a/ui/helpview.go b/ui/helpview.go new file mode 100644 index 0000000..e28cedb --- /dev/null +++ b/ui/helpview.go @@ -0,0 +1,49 @@ +package ui + +import ( + "bytes" + + "github.com/RasmusLindroth/tut/config" + "github.com/rivo/tview" +) + +type HelpView struct { + tutView *TutView + shared *Shared + View *tview.Flex + content *tview.TextView + controls *tview.TextView +} + +type HelpData struct { + Style config.Style +} + +func NewHelpView(tv *TutView) *HelpView { + content := NewTextView(tv.tut.Config) + controls := NewTextView(tv.tut.Config) + hv := &HelpView{ + tutView: tv, + shared: tv.Shared, + content: content, + controls: controls, + } + hd := HelpData{Style: tv.tut.Config.Style} + var output bytes.Buffer + err := tv.tut.Config.Templates.Help.ExecuteTemplate(&output, "help.tmpl", hd) + if err != nil { + panic(err) + } + hv.content.SetText(output.String()) + hv.controls.SetText(config.ColorKey(tv.tut.Config, "", "Esc/Q", "uit")) + hv.View = newHelpViewUI(hv) + return hv +} + +func newHelpViewUI(hv *HelpView) *tview.Flex { + return tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(hv.shared.Top.View, 1, 0, false). + AddItem(hv.content, 0, 1, false). + AddItem(hv.controls, 1, 0, false). + AddItem(hv.shared.Bottom.View, 2, 0, false) +} diff --git a/ui/input.go b/ui/input.go new file mode 100644 index 0000000..c6e8212 --- /dev/null +++ b/ui/input.go @@ -0,0 +1,675 @@ +package ui + +import ( + "fmt" + "strconv" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/util" + "github.com/gdamore/tcell/v2" +) + +func (tv *TutView) Input(event *tcell.EventKey) *tcell.EventKey { + if tv.PageFocus != LoginFocus { + switch event.Rune() { + case ':': + tv.SetPage(CmdFocus) + case '?': + tv.SetPage(HelpFocus) + } + } + switch tv.PageFocus { + case LoginFocus: + return tv.InputLoginView(event) + case MainFocus: + return tv.InputMainView(event) + case ViewFocus: + return tv.InputViewItem(event) + case ComposeFocus: + return tv.InputComposeView(event) + case LinkFocus: + return tv.InputLinkView(event) + case CmdFocus: + return tv.InputCmdView(event) + case MediaFocus: + return tv.InputMedia(event) + case MediaAddFocus: + return tv.InputMediaAdd(event) + case VoteFocus: + return tv.InputVote(event) + case HelpFocus: + return tv.InputHelp(event) + default: + return event + } +} + +func (tv *TutView) InputLoginView(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'j', 'J': + tv.LoginView.Next() + return nil + case 'k', 'K': + tv.LoginView.Prev() + return nil + } + } else { + switch event.Key() { + case tcell.KeyEnter: + tv.LoginView.Selected() + return nil + case tcell.KeyUp: + tv.LoginView.Prev() + return nil + case tcell.KeyDown: + tv.LoginView.Next() + return nil + } + } + return event +} + +func (tv *TutView) InputMainView(event *tcell.EventKey) *tcell.EventKey { + switch tv.SubFocus { + case ListFocus: + return tv.InputMainViewFeed(event) + case ContentFocus: + return tv.InputMainViewContent(event) + default: + return event + } +} + +func (tv *TutView) InputMainViewFeed(event *tcell.EventKey) *tcell.EventKey { + mainFocus := tv.TimelineFocus == FeedFocus + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'g': + tv.Timeline.HomeItemFeed(mainFocus) + return nil + case 'G': + tv.Timeline.EndItemFeed(mainFocus) + return nil + case 'h', 'H': + if mainFocus { + tv.Timeline.PrevFeed() + } + return nil + case 'l', 'L': + if mainFocus { + tv.Timeline.NextFeed() + } + return nil + case 'j', 'J': + tv.Timeline.NextItemFeed(mainFocus) + return nil + case 'k', 'K': + tv.Timeline.PrevItemFeed(mainFocus) + return nil + case 'n', 'N': + tv.FocusNotification() + return nil + case 'q', 'Q': + if mainFocus { + tv.Timeline.RemoveCurrent(true) + } else { + tv.FocusFeed() + } + return nil + } + } else { + switch event.Key() { + case tcell.KeyLeft: + if mainFocus { + tv.Timeline.PrevFeed() + return nil + } + return nil + case tcell.KeyRight: + if mainFocus { + tv.Timeline.NextFeed() + return nil + } + return nil + case tcell.KeyUp: + tv.Timeline.PrevItemFeed(mainFocus) + return nil + case tcell.KeyDown: + tv.Timeline.NextItemFeed(mainFocus) + return nil + case tcell.KeyHome: + tv.Timeline.HomeItemFeed(mainFocus) + return nil + case tcell.KeyEnd: + tv.Timeline.EndItemFeed(mainFocus) + return nil + case tcell.KeyEsc: + if mainFocus { + tv.Timeline.RemoveCurrent(false) + } else { + tv.FocusFeed() + } + return nil + } + } + return tv.InputItem(event) +} + +func (tv *TutView) InputMainViewContent(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'j', 'J': + tv.Timeline.ScrollDown() + return nil + case 'k', 'K': + tv.Timeline.ScrollUp() + return nil + default: + return event + } + } + return tv.InputItem(event) +} + +func (tv *TutView) InputHelp(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'q': + tv.PrevFocus() + return nil + } + switch event.Key() { + case tcell.KeyEsc: + tv.PrevFocus() + return nil + } + return event +} + +func (tv *TutView) InputViewItem(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'q': + tv.FocusMainNoHistory() + return nil + } + switch event.Key() { + case tcell.KeyEsc: + tv.FocusMainNoHistory() + return nil + } + return event +} + +func (tv *TutView) InputItem(event *tcell.EventKey) *tcell.EventKey { + item, err := tv.GetCurrentItem() + if err != nil { + return event + } + switch event.Rune() { + case 'c', 'C': + tv.InitPost(nil) + return nil + } + switch item.Type() { + case api.StatusType: + return tv.InputStatus(event, item, item.Raw().(*mastodon.Status)) + case api.UserType, api.ProfileType: + return tv.InputUser(event, item.Raw().(*api.User)) + case api.NotificationType: + nd := item.Raw().(*api.NotificationData) + switch nd.Item.Type { + case "follow": + return tv.InputUser(event, nd.User.Raw().(*api.User)) + case "favourite": + return tv.InputStatus(event, nd.Status, nd.Status.Raw().(*mastodon.Status)) + case "reblog": + return tv.InputStatus(event, nd.Status, nd.Status.Raw().(*mastodon.Status)) + case "mention": + return tv.InputStatus(event, nd.Status, nd.Status.Raw().(*mastodon.Status)) + case "status": + return tv.InputStatus(event, nd.Status, nd.Status.Raw().(*mastodon.Status)) + case "poll": + return tv.InputStatus(event, nd.Status, nd.Status.Raw().(*mastodon.Status)) + case "follow_request": + return tv.InputUser(event, nd.User.Raw().(*api.User)) + } + case api.ListsType: + ld := item.Raw().(*mastodon.List) + return tv.InputList(event, ld) + } + return event +} + +func (tv *TutView) InputStatus(event *tcell.EventKey, item api.Item, status *mastodon.Status) *tcell.EventKey { + sr := util.StatusOrReblog(status) + + hasMedia := len(sr.MediaAttachments) > 0 + hasPoll := sr.Poll != nil + hasSpoiler := sr.Sensitive + isMine := sr.Account.ID == tv.tut.Client.Me.ID + + boosted := sr.Reblogged + favorited := sr.Favourited + bookmarked := sr.Bookmarked + + switch event.Rune() { + case 'a', 'A': + openAvatar(tv, sr.Account) + return nil + case 'b', 'B': + txt := "boost" + if boosted { + txt = "unboost" + } + tv.ModalView.Run( + fmt.Sprintf("Do you want to %s this toot?", txt), func() { + ns, err := tv.tut.Client.BoostToggle(status) + if err != nil { + tv.ShowError( + fmt.Sprintf("Couldn't boost toot. Error: %v\n", err), + ) + return + } + *status = *ns + tv.RedrawControls() + }) + return nil + case 'd', 'D': + if !isMine { + return nil + } + tv.ModalView.Run("Do you want to delete this toot?", func() { + err := tv.tut.Client.DeleteStatus(sr) + if err != nil { + tv.ShowError( + fmt.Sprintf("Couldn't delete toot. Error: %v\n", err), + ) + return + } + status.Card = nil + status.Sensitive = false + status.SpoilerText = "" + status.Favourited = false + status.MediaAttachments = nil + status.Reblogged = false + status.Content = "Deleted" + tv.RedrawContent() + }) + return nil + case 'f', 'F': + txt := "favorite" + if favorited { + txt = "unfavorite" + } + tv.ModalView.Run(fmt.Sprintf("Do you want to %s this toot?", txt), + func() { + ns, err := tv.tut.Client.FavoriteToogle(status) + if err != nil { + tv.ShowError( + fmt.Sprintf("Couldn't favorite toot. Error: %v\n", err), + ) + return + } + *status = *ns + tv.RedrawControls() + }) + return nil + case 'm', 'M': + if hasMedia { + openMedia(tv, sr) + } + return nil + case 'o', 'O': + tv.SetPage(LinkFocus) + return nil + case 'p', 'P': + if !hasPoll { + return nil + } + tv.VoteView.SetPoll(sr.Poll) + tv.SetPage(VoteFocus) + return nil + case 'r', 'R': + tv.InitPost(status) + return nil + case 's', 'S': + txt := "save" + if bookmarked { + txt = "unsave" + } + tv.ModalView.Run(fmt.Sprintf("Do you want to %s this toot?", txt), + func() { + ns, err := tv.tut.Client.BookmarkToogle(status) + if err != nil { + tv.ShowError( + fmt.Sprintf("Couldn't bookmark toot. Error: %v\n", err), + ) + return + } + *status = *ns + tv.RedrawControls() + }) + return nil + case 't', 'T': + tv.Timeline.AddFeed(NewThreadFeed(tv, item)) + return nil + case 'u', 'U': + user, err := tv.tut.Client.GetUserByID(status.Account.ID) + if err != nil { + return nil + } + tv.Timeline.AddFeed(NewUserFeed(tv, user)) + return nil + case 'v', 'V': + tv.SetPage(ViewFocus) + return nil + case 'y', 'Y': + copyToClipboard(status.URL) + return nil + case 'z', 'Z': + if !hasSpoiler { + return nil + } + if !item.ShowSpoiler() { + item.ToggleSpoiler() + tv.RedrawContent() + } + return nil + } + + return event +} + +func (tv *TutView) InputUser(event *tcell.EventKey, user *api.User) *tcell.EventKey { + blocking := user.Relation.Blocking + muting := user.Relation.Muting + following := user.Relation.Following + switch event.Rune() { + case 'a', 'A': + openAvatar(tv, *user.Data) + return nil + case 'b', 'B': + txt := "block" + if blocking { + txt = "unblock" + } + tv.ModalView.Run(fmt.Sprintf("Do you want to %s this user?", txt), + func() { + rel, err := tv.tut.Client.BlockToggle(user) + if err != nil { + tv.ShowError( + fmt.Sprintf("Couldn't block user. Error: %v\n", err), + ) + return + } + user.Relation = rel + tv.RedrawControls() + }) + return nil + case 'f', 'F': + txt := "follow" + if following { + txt = "unfollow" + } + tv.ModalView.Run(fmt.Sprintf("Do you want to %s this user?", txt), + func() { + rel, err := tv.tut.Client.FollowToggle(user) + if err != nil { + tv.ShowError( + fmt.Sprintf("Couldn't follow user. Error: %v\n", err), + ) + return + } + user.Relation = rel + tv.RedrawControls() + }) + return nil + case 'm', 'M': + txt := "mute" + if muting { + txt = "unmute" + } + tv.ModalView.Run(fmt.Sprintf("Do you want to %s this user?", txt), + func() { + rel, err := tv.tut.Client.MuteToggle(user) + if err != nil { + tv.ShowError( + fmt.Sprintf("Couldn't follow user. Error: %v\n", err), + ) + return + } + user.Relation = rel + tv.RedrawControls() + }) + return nil + case 'o', 'O': + tv.SetPage(LinkFocus) + return nil + case 'u', 'U': + tv.Timeline.AddFeed(NewUserFeed(tv, api.NewUserItem(user, true))) + return nil + case 'v', 'V': + tv.SetPage(ViewFocus) + return nil + case 'y', 'Y': + copyToClipboard(user.Data.URL) + return nil + } + switch event.Key() { + case tcell.KeyEnter: + tv.Timeline.AddFeed(NewUserFeed(tv, api.NewUserItem(user, true))) + return nil + } + return event +} + +func (tv *TutView) InputList(event *tcell.EventKey, list *mastodon.List) *tcell.EventKey { + switch event.Rune() { + case 'o', 'O': + tv.Timeline.AddFeed(NewListFeed(tv, list)) + return nil + } + switch event.Key() { + case tcell.KeyEnter: + tv.Timeline.AddFeed(NewListFeed(tv, list)) + return nil + } + return event +} + +func (tv *TutView) InputLinkView(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'j', 'J': + tv.LinkView.Next() + return nil + case 'k', 'K': + tv.LinkView.Prev() + return nil + case 'o', 'O': + tv.LinkView.Open() + return nil + case 'y', 'Y': + tv.LinkView.Yank() + return nil + case '1', '2', '3', '4', '5': + s := string(event.Rune()) + i, _ := strconv.Atoi(s) + tv.LinkView.OpenCustom(i) + return nil + case 'q', 'Q': + tv.SetPage(MainFocus) + return nil + } + } else { + switch event.Key() { + case tcell.KeyEnter: + tv.LinkView.Open() + return nil + case tcell.KeyUp: + tv.LinkView.Prev() + return nil + case tcell.KeyDown: + tv.LinkView.Next() + return nil + case tcell.KeyEsc: + tv.SetPage(MainFocus) + return nil + } + } + return event +} + +func (tv *TutView) InputComposeView(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune { + switch event.Rune() { + case 'c', 'C': + tv.ComposeView.EditSpoiler() + return nil + case 'e', 'E': + tv.ComposeView.EditText() + return nil + case 'i', 'I': + tv.ComposeView.IncludeQuote() + return nil + case 'm', 'M': + tv.SetPage(MediaFocus) + return nil + case 'p', 'P': + tv.ComposeView.Post() + return nil + case 't', 'T': + tv.ComposeView.ToggleCW() + return nil + case 'v', 'V': + tv.ComposeView.FocusVisibility() + return nil + case 'q', 'Q': + tv.ModalView.Run( + "Do you want exit the compose view?", func() { + tv.FocusMainNoHistory() + }) + return nil + } + } else { + switch event.Key() { + case tcell.KeyEsc: + tv.ModalView.Run( + "Do you want exit the compose view?", func() { + tv.FocusMainNoHistory() + }) + return nil + } + } + return event +} + +func (tv *TutView) InputMedia(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'j', 'J': + tv.ComposeView.media.Next() + return nil + case 'k', 'K': + tv.ComposeView.media.Prev() + return nil + case 'd', 'D': + tv.ComposeView.media.Delete() + return nil + case 'e', 'E': + tv.ComposeView.media.EditDesc() + return nil + case 'a', 'A': + tv.SetPage(MediaAddFocus) + tv.ComposeView.media.SetFocus(false) + return nil + case 'q', 'Q': + tv.SetPage(MediaFocus) + return nil + } + switch event.Key() { + case tcell.KeyDown: + tv.ComposeView.media.Next() + return nil + case tcell.KeyUp: + tv.ComposeView.media.Prev() + return nil + case tcell.KeyEsc: + tv.SetPage(ComposeFocus) + return nil + } + return event +} + +func (tv *TutView) InputMediaAdd(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune { + tv.ComposeView.input.AddRune(event.Rune()) + return nil + } + switch event.Key() { + case tcell.KeyTAB: + tv.ComposeView.input.AutocompleteTab() + return nil + case tcell.KeyDown: + tv.ComposeView.input.AutocompleteNext() + return nil + case tcell.KeyBacktab, tcell.KeyUp: + tv.ComposeView.input.AutocompletePrev() + return nil + case tcell.KeyEnter: + tv.ComposeView.input.CheckDone() + return nil + case tcell.KeyEsc: + tv.SetPage(MediaFocus) + tv.ComposeView.media.SetFocus(true) + return nil + } + return event +} + +func (tv *TutView) InputVote(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'j', 'J': + tv.VoteView.Next() + return nil + case 'k', 'K': + tv.VoteView.Prev() + return nil + case 'v', 'V': + tv.VoteView.Vote() + return nil + case ' ': + tv.VoteView.ToggleSelect() + return nil + case 'q', 'Q': + tv.FocusMainNoHistory() + return nil + } + switch event.Key() { + case tcell.KeyDown, tcell.KeyTAB: + tv.VoteView.Next() + return nil + case tcell.KeyUp, tcell.KeyBacktab: + tv.VoteView.Prev() + return nil + case tcell.KeyEnter: + tv.VoteView.ToggleSelect() + return nil + case tcell.KeyEsc: + tv.FocusMainNoHistory() + return nil + } + return event +} + +func (tv *TutView) InputCmdView(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + tv.Shared.Bottom.Cmd.DoneFunc(tcell.KeyEnter) + case tcell.KeyEsc: + tv.Shared.Bottom.Cmd.Back() + tv.Shared.Bottom.Cmd.View.Autocomplete() + return nil + } + return event +} diff --git a/ui/item.go b/ui/item.go new file mode 100644 index 0000000..d54d994 --- /dev/null +++ b/ui/item.go @@ -0,0 +1,128 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/config" + "github.com/icza/gox/timex" + "github.com/rivo/tview" +) + +func DrawListItem(cfg *config.Config, item api.Item) (string, string) { + switch item.Type() { + case api.StatusType: + s := item.Raw().(*mastodon.Status) + symbol := "" + status := s + if s.Reblog != nil { + status = s + } + if status.RepliesCount > 0 { + symbol = " ⤶ " + } + d := OutputDate(cfg, s.CreatedAt.Local()) + return fmt.Sprintf("%s %s", d, strings.TrimSpace(s.Account.Acct)), symbol + case api.UserType: + a := item.Raw().(*api.User) + return strings.TrimSpace(a.Data.Acct), "" + case api.ProfileType: + return "Profile", "" + case api.NotificationType: + a := item.Raw().(*api.NotificationData) + symbol := "" + switch a.Item.Type { + case "follow", "follow_request": + symbol += " + " + case "favourite": + symbol = " ★ " + case "reblog": + symbol = " ♺ " + case "mention": + symbol = " ⤶ " + case "poll": + symbol = " = " + case "status": + symbol = " ⤶ " + } + d := OutputDate(cfg, a.Item.CreatedAt.Local()) + return fmt.Sprintf("%s %s", d, strings.TrimSpace(a.Item.Account.Acct)), symbol + case api.ListsType: + a := item.Raw().(*mastodon.List) + return tview.Escape(a.Title), "" + default: + return "", "" + } +} + +func DrawItem(tut *Tut, item api.Item, main *tview.TextView, controls *tview.TextView) { + switch item.Type() { + case api.StatusType: + drawStatus(tut, item, item.Raw().(*mastodon.Status), main, controls, "") + case api.UserType, api.ProfileType: + drawUser(tut, item.Raw().(*api.User), main, controls, "") + case api.NotificationType: + drawNotification(tut, item, item.Raw().(*api.NotificationData), main, controls) + case api.ListsType: + drawList(tut, item.Raw().(*mastodon.List), main, controls) + } +} + +func DrawItemControls(tut *Tut, item api.Item, controls *tview.TextView) { + switch item.Type() { + case api.StatusType: + drawStatus(tut, item, item.Raw().(*mastodon.Status), nil, controls, "") + case api.UserType, api.ProfileType: + drawUser(tut, item.Raw().(*api.User), nil, controls, "") + case api.NotificationType: + drawNotification(tut, item, item.Raw().(*api.NotificationData), nil, controls) + } +} + +func OutputDate(cfg *config.Config, status time.Time) string { + today := time.Now() + ty, tm, td := today.Date() + sy, sm, sd := status.Date() + + format := cfg.General.DateFormat + sameDay := false + displayRelative := false + + if ty == sy && tm == sm && td == sd { + format = cfg.General.DateTodayFormat + sameDay = true + } + + todayFloor := FloorDate(today) + statusFloor := FloorDate(status) + + if cfg.General.DateRelative > -1 && !sameDay { + days := int(todayFloor.Sub(statusFloor).Hours() / 24) + if cfg.General.DateRelative == 0 || days <= cfg.General.DateRelative { + displayRelative = true + } + } + var dateOutput string + if displayRelative { + y, m, d, _, _, _ := timex.Diff(statusFloor, todayFloor) + if y > 0 { + dateOutput = fmt.Sprintf("%s%dy", dateOutput, y) + } + if dateOutput != "" || m > 0 { + dateOutput = fmt.Sprintf("%s%dm", dateOutput, m) + } + if dateOutput != "" || d > 0 { + dateOutput = fmt.Sprintf("%s%dd", dateOutput, d) + } + } else { + dateOutput = status.Format(format) + } + return dateOutput +} + +func FloorDate(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} diff --git a/ui/item_list.go b/ui/item_list.go new file mode 100644 index 0000000..15f983e --- /dev/null +++ b/ui/item_list.go @@ -0,0 +1,20 @@ +package ui + +import ( + "fmt" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/config" + "github.com/rivo/tview" +) + +type List struct { +} + +func drawList(tut *Tut, data *mastodon.List, main *tview.TextView, controls *tview.TextView) { + + controlItem := config.ColorKey(tut.Config, "", "O", "pen") + + main.SetText(fmt.Sprintf("Press O or to open list %s", tview.Escape(data.Title))) + controls.SetText(controlItem) +} diff --git a/ui/item_notification.go b/ui/item_notification.go new file mode 100644 index 0000000..1298edf --- /dev/null +++ b/ui/item_notification.go @@ -0,0 +1,42 @@ +package ui + +import ( + "fmt" + + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/util" + "github.com/rivo/tview" +) + +func drawNotification(tut *Tut, item api.Item, notification *api.NotificationData, main *tview.TextView, controls *tview.TextView) { + switch notification.Item.Type { + case "follow": + drawUser(tut, notification.User.Raw().(*api.User), main, controls, + fmt.Sprintf("%s started following you", util.FormatUsername(notification.Item.Account)), + ) + case "favourite": + drawStatus(tut, notification.Status, notification.Item.Status, main, controls, + fmt.Sprintf("%s favorited your toot", util.FormatUsername(notification.Item.Account)), + ) + case "reblog": + drawStatus(tut, notification.Status, notification.Item.Status, main, controls, + fmt.Sprintf("%s boosted your toot", util.FormatUsername(notification.Item.Account)), + ) + case "mention": + drawStatus(tut, notification.Status, notification.Item.Status, main, controls, + fmt.Sprintf("%s mentioned you", util.FormatUsername(notification.Item.Account)), + ) + case "status": + drawStatus(tut, notification.Status, notification.Item.Status, main, controls, + fmt.Sprintf("%s posted a new toot", util.FormatUsername(notification.Item.Account)), + ) + case "poll": + drawStatus(tut, notification.Status, notification.Item.Status, main, controls, + "A poll of yours or one you participated in has ended", + ) + case "follow_request": + drawUser(tut, notification.User.Raw().(*api.User), main, controls, + fmt.Sprintf("%s wants to follow you. This is currently not implemented, so use another app to accept or reject the request.", util.FormatUsername(notification.Item.Account)), + ) + } +} diff --git a/ui/item_status.go b/ui/item_status.go new file mode 100644 index 0000000..3245db2 --- /dev/null +++ b/ui/item_status.go @@ -0,0 +1,226 @@ +package ui + +import ( + "bytes" + "fmt" + "strings" + "time" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/config" + "github.com/RasmusLindroth/tut/util" + "github.com/rivo/tview" +) + +type Toot struct { + Visibility string + Boosted bool + BoostedDisplayName string + BoostedAcct string + Bookmarked bool + AccountDisplayName string + Account string + Spoiler bool + SpoilerText string + ShowSpoiler bool + ContentText string + Width int + HasExtra bool + Poll Poll + Media []Media + Card Card + Replies int + Boosts int + Favorites int + Controls string +} + +type Poll struct { + ID string + ExpiresAt time.Time + Expired bool + Multiple bool + VotesCount int64 + Options []PollOption + Voted bool +} + +type PollOption struct { + Title string + VotesCount int64 + Percent string +} + +type Media struct { + Type string + Description string + URL string +} + +type Card struct { + Type string + Title string + Description string + URL string +} + +type DisplayTootData struct { + Toot Toot + Style config.Style +} + +func drawStatus(tut *Tut, item api.Item, status *mastodon.Status, main *tview.TextView, controls *tview.TextView, additional string) { + showSensitive := item.ShowSpoiler() + + var strippedContent string + var strippedSpoiler string + + so := status + if status.Reblog != nil { + status = status.Reblog + } + + strippedContent, _ = util.CleanHTML(status.Content) + strippedContent = tview.Escape(strippedContent) + + width := 0 + if main != nil { + _, _, width, _ = main.GetInnerRect() + } + toot := Toot{ + Width: width, + ContentText: strippedContent, + Boosted: so.Reblog != nil, + BoostedDisplayName: tview.Escape(so.Account.DisplayName), + BoostedAcct: tview.Escape(so.Account.Acct), + ShowSpoiler: showSensitive, + } + + toot.AccountDisplayName = tview.Escape(status.Account.DisplayName) + toot.Account = tview.Escape(status.Account.Acct) + toot.Bookmarked = status.Bookmarked + toot.Visibility = status.Visibility + toot.Spoiler = status.Sensitive + + if status.Poll != nil { + p := *status.Poll + toot.Poll = Poll{ + ID: string(p.ID), + ExpiresAt: p.ExpiresAt, + Expired: p.Expired, + Multiple: p.Multiple, + VotesCount: p.VotesCount, + Voted: p.Voted, + Options: []PollOption{}, + } + for _, item := range p.Options { + percent := 0.0 + if p.VotesCount > 0 { + percent = float64(item.VotesCount) / float64(p.VotesCount) * 100 + } + + o := PollOption{ + Title: tview.Escape(item.Title), + VotesCount: item.VotesCount, + Percent: fmt.Sprintf("%.2f", percent), + } + toot.Poll.Options = append(toot.Poll.Options, o) + } + + } else { + toot.Poll = Poll{} + } + + if status.Sensitive { + strippedSpoiler, _ = util.CleanHTML(status.SpoilerText) + strippedSpoiler = tview.Escape(strippedSpoiler) + } + + toot.SpoilerText = strippedSpoiler + + media := []Media{} + for _, att := range status.MediaAttachments { + m := Media{ + Type: att.Type, + Description: tview.Escape(att.Description), + URL: att.URL, + } + media = append(media, m) + } + toot.Media = media + + if status.Card != nil { + toot.Card = Card{ + Type: status.Card.Type, + Title: tview.Escape(strings.TrimSpace(status.Card.Title)), + Description: tview.Escape(strings.TrimSpace(status.Card.Description)), + URL: status.Card.URL, + } + } else { + toot.Card = Card{} + } + + toot.HasExtra = len(status.MediaAttachments) > 0 || status.Card != nil || status.Poll != nil + toot.Replies = int(status.RepliesCount) + toot.Boosts = int(status.ReblogsCount) + toot.Favorites = int(status.FavouritesCount) + + if main != nil { + main.ScrollToBeginning() + } + + var info []string + if status.Favourited { + info = append(info, config.ColorKey(tut.Config, "Un", "F", "avorite")) + } else { + info = append(info, config.ColorKey(tut.Config, "", "F", "avorite")) + } + if status.Reblogged { + info = append(info, config.ColorKey(tut.Config, "Un", "B", "oost")) + } else { + info = append(info, config.ColorKey(tut.Config, "", "B", "oost")) + } + info = append(info, config.ColorKey(tut.Config, "", "T", "hread")) + info = append(info, config.ColorKey(tut.Config, "", "R", "eply")) + info = append(info, config.ColorKey(tut.Config, "", "V", "iew")) + info = append(info, config.ColorKey(tut.Config, "", "U", "ser")) + if len(status.MediaAttachments) > 0 { + info = append(info, config.ColorKey(tut.Config, "", "M", "edia")) + } + _, _, _, length := item.URLs() + if length > 0 { + info = append(info, config.ColorKey(tut.Config, "", "O", "pen")) + } + info = append(info, config.ColorKey(tut.Config, "", "A", "vatar")) + if status.Account.ID == tut.Client.Me.ID { + info = append(info, config.ColorKey(tut.Config, "", "D", "elete")) + } + + if !status.Bookmarked { + info = append(info, config.ColorKey(tut.Config, "", "S", "ave")) + } else { + info = append(info, config.ColorKey(tut.Config, "Un", "S", "ave")) + } + info = append(info, config.ColorKey(tut.Config, "", "Y", "ank")) + + controlsS := strings.Join(info, " ") + + td := DisplayTootData{ + Toot: toot, + Style: tut.Config.Style, + } + var output bytes.Buffer + err := tut.Config.Templates.Toot.ExecuteTemplate(&output, "toot.tmpl", td) + if err != nil { + panic(err) + } + + if main != nil { + if additional != "" { + additional = fmt.Sprintf("%s\n\n", config.SublteText(tut.Config, additional)) + } + main.SetText(additional + output.String()) + } + controls.SetText(controlsS) +} diff --git a/ui/item_user.go b/ui/item_user.go new file mode 100644 index 0000000..fef4a99 --- /dev/null +++ b/ui/item_user.go @@ -0,0 +1,129 @@ +package ui + +import ( + "bytes" + "fmt" + "strings" + "time" + + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/config" + "github.com/RasmusLindroth/tut/util" + "github.com/rivo/tview" +) + +type User struct { + Username string + Account string + DisplayName string + Locked bool + CreatedAt time.Time + FollowersCount int64 + FollowingCount int64 + StatusCount int64 + Note string + URL string + Avatar string + AvatarStatic string + Header string + HeaderStatic string + Fields []Field + Bot bool + //Emojis []Emoji + //Moved *Account `json:"moved"` +} + +type Field struct { + Name string + Value string + VerifiedAt time.Time +} + +type DisplayUserData struct { + User User + Style config.Style +} + +func drawUser(tut *Tut, data *api.User, main *tview.TextView, controls *tview.TextView, additional string) { + user := data.Data + relation := data.Relation + showUserControl := true + u := User{ + Username: tview.Escape(user.Username), + Account: tview.Escape(user.Acct), + DisplayName: tview.Escape(user.DisplayName), + Locked: user.Locked, + CreatedAt: user.CreatedAt, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + StatusCount: user.StatusesCount, + URL: user.URL, + Avatar: user.Avatar, + AvatarStatic: user.AvatarStatic, + Header: user.Header, + HeaderStatic: user.HeaderStatic, + Bot: user.Bot, + } + + var controlsS string + + var urls []util.URL + fields := []Field{} + u.Note, urls = util.CleanHTML(user.Note) + for _, f := range user.Fields { + value, fu := util.CleanHTML(f.Value) + fields = append(fields, Field{ + Name: tview.Escape(f.Name), + Value: tview.Escape(value), + VerifiedAt: f.VerifiedAt, + }) + urls = append(urls, fu...) + } + u.Fields = fields + + var controlItems []string + if tut.Client.Me.ID != user.ID { + if relation.Following { + controlItems = append(controlItems, config.ColorKey(tut.Config, "Un", "F", "ollow")) + } else { + controlItems = append(controlItems, config.ColorKey(tut.Config, "", "F", "ollow")) + } + if relation.Blocking { + controlItems = append(controlItems, config.ColorKey(tut.Config, "Un", "B", "lock")) + } else { + controlItems = append(controlItems, config.ColorKey(tut.Config, "", "B", "lock")) + } + if relation.Muting { + controlItems = append(controlItems, config.ColorKey(tut.Config, "Un", "M", "ute")) + } else { + controlItems = append(controlItems, config.ColorKey(tut.Config, "", "M", "ute")) + } + if len(urls) > 0 { + controlItems = append(controlItems, config.ColorKey(tut.Config, "", "O", "pen")) + } + } + if showUserControl { + controlItems = append(controlItems, config.ColorKey(tut.Config, "", "U", "ser")) + } + controlItems = append(controlItems, config.ColorKey(tut.Config, "", "A", "vatar")) + controlItems = append(controlItems, config.ColorKey(tut.Config, "", "Y", "ank")) + controlsS = strings.Join(controlItems, " ") + + ud := DisplayUserData{ + User: u, + Style: tut.Config.Style, + } + var output bytes.Buffer + err := tut.Config.Templates.User.ExecuteTemplate(&output, "user.tmpl", ud) + if err != nil { + panic(err) + } + + if main != nil { + if additional != "" { + additional = fmt.Sprintf("%s\n\n", config.SublteText(tut.Config, additional)) + } + main.SetText(additional + output.String()) + } + controls.SetText(controlsS) +} diff --git a/ui/linkview.go b/ui/linkview.go new file mode 100644 index 0000000..d44d1fb --- /dev/null +++ b/ui/linkview.go @@ -0,0 +1,161 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/config" + "github.com/rivo/tview" +) + +type LinkView struct { + tutView *TutView + shared *Shared + View *tview.Flex + list *tview.List + controls *tview.TextView +} + +func NewLinkView(tv *TutView) *LinkView { + l := NewList(tv.tut.Config) + txt := NewTextView(tv.tut.Config) + lv := &LinkView{ + tutView: tv, + shared: tv.Shared, + list: l, + controls: txt, + } + lv.View = linkViewUI(lv) + return lv +} + +func linkViewUI(lv *LinkView) *tview.Flex { + lv.controls.SetBorderPadding(0, 0, 1, 1) + items := []string{ + config.ColorKey(lv.tutView.tut.Config, "", "O", "pen"), + config.ColorKey(lv.tutView.tut.Config, "", "Y", "ank"), + } + for _, cust := range lv.tutView.tut.Config.OpenCustom.OpenCustoms { + items = append(items, config.ColorKey(lv.tutView.tut.Config, "", fmt.Sprintf("%d", cust.Index), cust.Name)) + } + res := strings.Join(items, " ") + lv.controls.SetText(res) + + return tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(lv.shared.Top.View, 1, 0, false). + AddItem(lv.list, 0, 1, false). + AddItem(lv.controls, 1, 0, false). + AddItem(lv.shared.Bottom.View, 2, 0, false) +} + +func (lv *LinkView) SetLinks(item api.Item) { + lv.list.Clear() + urls, mentions, tags, _ := item.URLs() + + for _, url := range urls { + lv.list.AddItem(url.Text, "", 0, nil) + } + for _, mention := range mentions { + lv.list.AddItem(mention.Acct, "", 0, nil) + } + for _, tag := range tags { + lv.list.AddItem("#"+tag.Name, "", 0, nil) + } +} + +func (lv *LinkView) Next() { + listNext(lv.list) +} + +func (lv *LinkView) Prev() { + listPrev(lv.list) +} + +func (lv *LinkView) Open() { + item, err := lv.tutView.GetCurrentItem() + if err != nil { + return + } + urls, mentions, tags, total := item.URLs() + index := lv.list.GetCurrentItem() + + if total == 0 || index >= total { + return + } + if index < len(urls) { + openURL(lv.tutView, urls[index].URL) + return + } + mIndex := index - len(urls) + if mIndex < len(mentions) { + u, err := lv.tutView.tut.Client.GetUserByID(mentions[mIndex].ID) + if err != nil { + lv.tutView.ShowError( + fmt.Sprintf("Couldn't load user. Error:%v\n", err), + ) + return + } + lv.tutView.Timeline.AddFeed( + NewUserFeed(lv.tutView, u), + ) + lv.tutView.FocusMainNoHistory() + return + } + tIndex := index - len(mentions) - len(urls) + if tIndex < len(tags) { + lv.tutView.Timeline.AddFeed( + NewTagFeed(lv.tutView, tags[tIndex].Name), + ) + lv.tutView.FocusMainNoHistory() + return + } +} + +func (lv *LinkView) getURL() string { + item, err := lv.tutView.GetCurrentItem() + if err != nil { + return "" + } + urls, mentions, tags, total := item.URLs() + index := lv.list.GetCurrentItem() + + if total == 0 || index >= total { + return "" + } + if index < len(urls) { + return urls[index].URL + } + mIndex := index - len(urls) + if mIndex < len(mentions) { + return mentions[mIndex].URL + } + tIndex := index - len(mentions) - len(urls) + if tIndex < len(tags) { + return tags[tIndex].URL + } + return "" +} + +func (lv *LinkView) Yank() { + url := lv.getURL() + if url == "" { + return + } + copyToClipboard(url) +} + +func (lv *LinkView) OpenCustom(index int) { + url := lv.getURL() + if url == "" { + return + } + customs := lv.tutView.tut.Config.OpenCustom.OpenCustoms + for _, c := range customs { + if c.Index != index { + continue + } + openCustom(lv.tutView, c.Program, c.Args, c.Terminal, url) + return + } +} diff --git a/ui/list_helper.go b/ui/list_helper.go new file mode 100644 index 0000000..e039204 --- /dev/null +++ b/ui/list_helper.go @@ -0,0 +1,36 @@ +package ui + +import ( + "fmt" + "strconv" + + "github.com/rivo/tview" +) + +func GetCurrentID(l *tview.List) uint { + if l.GetItemCount() == 0 { + return 0 + } + i := l.GetCurrentItem() + _, sec := l.GetItemText(i) + id, err := strconv.ParseUint(sec, 10, 32) + if err != nil { + return 0 + } + return uint(id) +} + +func SetByID(id uint, l *tview.List) { + if l.GetItemCount() == 0 { + return + } + s := fmt.Sprintf("%d", id) + items := l.FindItems("", s, false, false) + for _, i := range items { + _, sec := l.GetItemText(i) + if sec == s { + l.SetCurrentItem(i) + break + } + } +} diff --git a/ui/loginview.go b/ui/loginview.go new file mode 100644 index 0000000..fa2b699 --- /dev/null +++ b/ui/loginview.go @@ -0,0 +1,47 @@ +package ui + +import ( + "fmt" + + "github.com/RasmusLindroth/tut/auth" + "github.com/rivo/tview" +) + +type LoginView struct { + tutView *TutView + accounts *auth.AccountData + View tview.Primitive + list *tview.List +} + +func NewLoginView(tv *TutView, accs *auth.AccountData) *LoginView { + list := NewList(tv.tut.Config) + for _, a := range accs.Accounts { + list.AddItem(fmt.Sprintf("%s - %s", a.Name, a.Server), "", 0, nil) + } + + v := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(tv.Shared.Top.View, 1, 0, false). + AddItem(list, 0, 1, false). + AddItem(tv.Shared.Bottom.View, 2, 0, false) + + return &LoginView{ + tutView: tv, + accounts: accs, + View: v, + list: list, + } +} + +func (l *LoginView) Selected() { + acc := l.accounts.Accounts[l.list.GetCurrentItem()] + l.tutView.loggedIn(acc) +} + +func (l *LoginView) Next() { + listNext(l.list) +} + +func (l *LoginView) Prev() { + listPrev(l.list) +} diff --git a/ui/mainview.go b/ui/mainview.go new file mode 100644 index 0000000..94e6589 --- /dev/null +++ b/ui/mainview.go @@ -0,0 +1,122 @@ +package ui + +import ( + "github.com/RasmusLindroth/tut/config" + "github.com/rivo/tview" +) + +type MainView struct { + View *tview.Flex +} + +func NewMainView(tv *TutView, update chan bool) *MainView { + mv := &MainView{ + View: mainViewUI(tv), + } + go func() { + for range update { + tv.tut.App.QueueUpdateDraw(func() { + *tv.MainView.View = *mainViewUI(tv) + }) + } + }() + return mv +} + +func feedList(mv *TutView) *tview.Flex { + iw := 3 + if !mv.tut.Config.General.ShowIcons { + iw = 0 + } + return tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(mv.Timeline.GetFeedList().Text, 0, 1, false). + AddItem(mv.Timeline.GetFeedList().Symbol, iw, 0, false) //fix so you can hide +} +func notificationList(mv *TutView) *tview.Flex { + iw := 3 + if !mv.tut.Config.General.ShowIcons { + iw = 0 + } + return tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(mv.Timeline.Notifications.List.Text, 0, 1, false). + AddItem(mv.Timeline.Notifications.List.Symbol, iw, 0, false) //fix so you can hide +} + +func mainViewUI(mv *TutView) *tview.Flex { + showMain := mv.TimelineFocus == FeedFocus + vl := NewVerticalLine(mv.tut.Config) + hl := NewHorizontalLine(mv.tut.Config) + nt := NewTextView(mv.tut.Config) + lp := mv.tut.Config.General.ListProportion + cp := mv.tut.Config.General.ContentProportion + nt.SetTextColor(mv.tut.Config.Style.Subtle) + nt.SetDynamicColors(false) + nt.SetText("[N]otifications") + + var list *tview.Flex + if mv.tut.Config.General.ListSplit == config.ListColumn { + list = tview.NewFlex().SetDirection(tview.FlexColumn) + } else { + list = tview.NewFlex().SetDirection(tview.FlexRow) + } + + if mv.tut.Config.General.NotificationFeed && !mv.tut.Config.General.HideNotificationText { + if mv.tut.Config.General.ListSplit == config.ListColumn { + list.AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 1, 0, false). + AddItem(feedList(mv), 0, 1, false), 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nt, 1, 0, false). + AddItem(notificationList(mv), 0, 1, false), 0, 1, false) + } else { + list.AddItem(feedList(mv), 0, 1, false). + AddItem(nt, 1, 0, false). + AddItem(notificationList(mv), 0, 1, false) + } + + } else if mv.tut.Config.General.NotificationFeed && mv.tut.Config.General.HideNotificationText { + if mv.tut.Config.General.ListSplit == config.ListColumn { + list.AddItem(feedList(mv), 0, 1, false). + AddItem(notificationList(mv), 0, 1, false) + + } else { + list.AddItem(feedList(mv), 0, 1, false). + AddItem(notificationList(mv), 0, 1, false) + } + } + fc := mv.Timeline.GetFeedContent(showMain) + content := fc.Main + controls := fc.Controls + r := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(mv.Shared.Top.View, 1, 0, false) + if mv.tut.Config.General.ListPlacement == config.ListPlacementTop { + r.AddItem(list, 0, lp, false). + AddItem(hl, 1, 0, false). + AddItem(content, 0, cp, false). + AddItem(controls, 1, 0, false). + AddItem(mv.Shared.Bottom.View, 2, 0, false) + } else if mv.tut.Config.General.ListPlacement == config.ListPlacementBottom { + r.AddItem(content, 0, cp, false). + AddItem(controls, 1, 0, false). + AddItem(hl, 1, 0, false). + AddItem(list, 0, lp, false). + AddItem(mv.Shared.Bottom.View, 2, 0, false) + } else if mv.tut.Config.General.ListPlacement == config.ListPlacementLeft { + r.AddItem(tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(list, 0, lp, false). + AddItem(vl, 1, 0, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(content, 0, 1, false). + AddItem(controls, 1, 0, false), 0, cp, false), 0, 1, false). + AddItem(mv.Shared.Bottom.View, 2, 0, false) + } else if mv.tut.Config.General.ListPlacement == config.ListPlacementRight { + r.AddItem(tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(content, 0, 1, false). + AddItem(controls, 1, 0, false), 0, cp, false). + AddItem(vl, 1, 0, false). + AddItem(list, 0, lp, false), 0, 1, false). + AddItem(mv.Shared.Bottom.View, 2, 0, false) + } + return r +} diff --git a/ui/media.go b/ui/media.go new file mode 100644 index 0000000..317a853 --- /dev/null +++ b/ui/media.go @@ -0,0 +1,191 @@ +package ui + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "os/exec" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/atotto/clipboard" +) + +func downloadFile(url string) (string, error) { + f, err := ioutil.TempFile("", "tutfile") + if err != nil { + return "", err + } + defer f.Close() + + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + _, err = io.Copy(f, resp.Body) + if err != nil { + return "", nil + } + + return f.Name(), nil +} + +func openAvatar(tv *TutView, user mastodon.Account) { + f, err := downloadFile(user.AvatarStatic) + if err != nil { + tv.ShowError( + fmt.Sprintf("Couldn't open avatar. Error: %v\n", err), + ) + return + } + openMediaType(tv, []string{f}, "image") +} + +func reverseFiles(filenames []string) []string { + if len(filenames) == 0 { + return filenames + } + var f []string + for i := len(filenames) - 1; i >= 0; i-- { + f = append(f, filenames[i]) + } + return f +} + +type runProgram struct { + Name string + Args []string + Terminal bool +} + +func newRunProgram(name string, args ...string) runProgram { + return runProgram{ + Name: name, + Args: args, + } +} + +func openMediaType(tv *TutView, filenames []string, mediaType string) { + terminal := []runProgram{} + external := []runProgram{} + mc := tv.tut.Config.Media + switch mediaType { + case "image": + if mc.ImageReverse { + filenames = reverseFiles(filenames) + } + if mc.ImageSingle { + for _, f := range filenames { + args := append(mc.ImageArgs, f) + c := newRunProgram(mc.ImageViewer, args...) + if mc.ImageTerminal { + terminal = append(terminal, c) + } else { + external = append(external, c) + } + } + } else { + args := append(mc.ImageArgs, filenames...) + c := newRunProgram(mc.ImageViewer, args...) + if mc.ImageTerminal { + terminal = append(terminal, c) + } else { + external = append(external, c) + } + } + case "video", "gifv": + if mc.VideoReverse { + filenames = reverseFiles(filenames) + } + if mc.VideoSingle { + for _, f := range filenames { + args := append(mc.VideoArgs, f) + c := newRunProgram(mc.VideoViewer, args...) + if mc.VideoTerminal { + terminal = append(terminal, c) + } else { + external = append(external, c) + } + } + } else { + args := append(mc.VideoArgs, filenames...) + c := newRunProgram(mc.VideoViewer, args...) + if mc.VideoTerminal { + terminal = append(terminal, c) + } else { + external = append(external, c) + } + } + case "audio": + if mc.AudioReverse { + filenames = reverseFiles(filenames) + } + if mc.AudioSingle { + for _, f := range filenames { + args := append(mc.AudioArgs, f) + c := newRunProgram(mc.AudioViewer, args...) + if mc.AudioTerminal { + terminal = append(terminal, c) + } else { + external = append(external, c) + } + } + } else { + args := append(mc.AudioArgs, filenames...) + c := newRunProgram(mc.AudioViewer, args...) + if mc.AudioTerminal { + terminal = append(terminal, c) + } else { + external = append(external, c) + } + } + } + go func() { + for _, ext := range external { + exec.Command(ext.Name, ext.Args...).Run() + } + }() + for _, term := range terminal { + openInTerminal(tv, term.Name, term.Args...) + } +} + +func openMedia(tv *TutView, status *mastodon.Status) { + if status.Reblog != nil { + status = status.Reblog + } + + if len(status.MediaAttachments) == 0 { + return + } + + mediaGroup := make(map[string][]mastodon.Attachment) + for _, m := range status.MediaAttachments { + mediaGroup[m.Type] = append(mediaGroup[m.Type], m) + } + + for key := range mediaGroup { + var files []string + for _, m := range mediaGroup[key] { + //'image', 'video', 'gifv', 'audio' or 'unknown' + f, err := downloadFile(m.URL) + if err != nil { + continue + } + files = append(files, f) + } + openMediaType(tv, files, key) + tv.FileList = append(tv.FileList, files...) + tv.ShouldSync() + } +} + +func copyToClipboard(text string) bool { + if clipboard.Unsupported { + return false + } + clipboard.WriteAll(text) + return true +} diff --git a/ui/modalview.go b/ui/modalview.go new file mode 100644 index 0000000..223b5c2 --- /dev/null +++ b/ui/modalview.go @@ -0,0 +1,56 @@ +package ui + +import ( + "github.com/rivo/tview" +) + +type ModalView struct { + tutView *TutView + View *tview.Modal + res chan bool +} + +func NewModalView(tv *TutView) *ModalView { + mv := &ModalView{ + tutView: tv, + View: NewModal(tv.tut.Config), + res: make(chan bool, 1), + } + mv.View.SetText("Are you sure?"). + AddButtons([]string{"Yes", "No"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Yes" { + mv.res <- true + } else { + mv.res <- false + } + }) + return mv +} + +func (mv *ModalView) run(text string) (chan bool, func()) { + mv.View.SetText(text) + mv.tutView.SetPage(ModalFocus) + return mv.res, func() { + mv.tutView.tut.App.QueueUpdateDraw(func() { + mv.tutView.PrevFocus() + }) + } +} +func (mv *ModalView) Run(text string, fn func()) { + if !mv.tutView.tut.Config.General.Confirmation { + fn() + return + } + r, f := mv.run(text) + go func() { + if <-r { + fn() + } + f() + }() +} + +func (mv *ModalView) Stop(fn func()) { + fn() +} diff --git a/ui/open.go b/ui/open.go new file mode 100644 index 0000000..46cd4c2 --- /dev/null +++ b/ui/open.go @@ -0,0 +1,95 @@ +package ui + +import ( + "io/ioutil" + "log" + "os" + "os/exec" + "strings" +) + +func openURL(tv *TutView, url string) { + for _, m := range tv.tut.Config.OpenPattern.Patterns { + if m.Compiled.Match(url) { + args := append(m.Args, url) + if m.Terminal { + openInTerminal(tv, m.Program, args...) + } else { + exec.Command(m.Program, args...).Start() + } + return + } + } + args := append(tv.tut.Config.Media.LinkArgs, url) + if tv.tut.Config.Media.LinkTerminal { + openInTerminal(tv, tv.tut.Config.Media.LinkViewer, args...) + } else { + exec.Command(tv.tut.Config.Media.LinkViewer, args...).Start() + } +} + +func openInTerminal(tv *TutView, command string, args ...string) error { + cmd := exec.Command(command, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + var err error + tv.tut.App.Suspend(func() { + err = cmd.Run() + if err != nil { + log.Fatalln(err) + } + }) + return err +} + +func openCustom(tv *TutView, program string, args []string, terminal bool, url string) { + args = append(args, url) + if terminal { + openInTerminal(tv, program, args...) + } else { + exec.Command(program, args...).Start() + } +} + +func OpenEditor(tv *TutView, content string) (string, error) { + editor, exists := os.LookupEnv("EDITOR") + if !exists || editor == "" { + editor = "vi" + } + args := []string{} + parts := strings.Split(editor, " ") + if len(parts) > 1 { + args = append(args, parts[1:]...) + editor = parts[0] + } + f, err := ioutil.TempFile("", "tut") + if err != nil { + return "", err + } + if content != "" { + _, err = f.WriteString(content) + if err != nil { + return "", err + } + } + args = append(args, f.Name()) + cmd := exec.Command(editor, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + var text []byte + tv.tut.App.Suspend(func() { + err = cmd.Run() + if err != nil { + log.Fatalln(err) + } + f.Seek(0, 0) + text, err = ioutil.ReadAll(f) + }) + f.Close() + if err != nil { + return "", err + } + return strings.TrimSpace(string(text)), nil +} diff --git a/ui/shared.go b/ui/shared.go new file mode 100644 index 0000000..40a450b --- /dev/null +++ b/ui/shared.go @@ -0,0 +1,13 @@ +package ui + +type Shared struct { + Top *Top + Bottom *Bottom +} + +func NewShared(tv *TutView) *Shared { + return &Shared{ + Top: NewTop(tv), + Bottom: NewBottom(tv), + } +} diff --git a/ui/statusbar.go b/ui/statusbar.go new file mode 100644 index 0000000..2b48aed --- /dev/null +++ b/ui/statusbar.go @@ -0,0 +1,62 @@ +package ui + +import "github.com/rivo/tview" + +type StatusBar struct { + tutView *TutView + View *tview.TextView +} + +func NewStatusBar(tv *TutView) *StatusBar { + sb := &StatusBar{ + tutView: tv, + View: NewTextView(tv.tut.Config), + } + sb.View.SetBackgroundColor(tv.tut.Config.Style.StatusBarBackground) + sb.View.SetTextColor(tv.tut.Config.Style.StatusBarText) + return sb +} + +type ViewMode uint + +const ( + CmdMode ViewMode = iota + ComposeMode + HelpMode + LinkMode + ListMode + MediaMode + NotificationsMode + ScrollMode + UserMode + VoteMode +) + +func (sb *StatusBar) SetMode(m ViewMode) { + sb.View.SetBackgroundColor(sb.tutView.tut.Config.Style.StatusBarBackground) + sb.View.SetTextColor(sb.tutView.tut.Config.Style.StatusBarText) + switch m { + case CmdMode: + sb.View.SetText("-- CMD --") + case ComposeMode: + sb.View.SetText("-- COMPOSE --") + case HelpMode: + sb.View.SetText("-- HELP --") + case LinkMode: + sb.View.SetText("-- LINK --") + case ListMode: + sb.View.SetText("-- LIST --") + case MediaMode: + sb.View.SetText("-- MEDIA --") + case NotificationsMode: + sb.View.SetText("-- NOTIFICATIONS --") + case VoteMode: + sb.View.SetText("-- VOTE --") + case ScrollMode: + sb.View.SetBackgroundColor(sb.tutView.tut.Config.Style.StatusBarViewBackground) + sb.View.SetTextColor(sb.tutView.tut.Config.Style.StatusBarViewText) + sb.View.SetText("-- VIEW --") + case UserMode: + sb.View.SetText("-- SELECT USER --") + } +} diff --git a/ui/styled_elements.go b/ui/styled_elements.go new file mode 100644 index 0000000..805e1bd --- /dev/null +++ b/ui/styled_elements.go @@ -0,0 +1,96 @@ +package ui + +import ( + "github.com/RasmusLindroth/tut/config" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func NewModal(cnf *config.Config) *tview.Modal { + m := tview.NewModal() + m.SetTextColor(cnf.Style.Text) + m.SetBackgroundColor(cnf.Style.Background) + m.SetBorderColor(cnf.Style.Background) + m.SetBorder(false) + tview.Styles.BorderColor = cnf.Style.Background + return m +} + +func NewTextView(cnf *config.Config) *tview.TextView { + tw := tview.NewTextView() + tw.SetBackgroundColor(cnf.Style.Background) + tw.SetTextColor(cnf.Style.Text) + tw.SetDynamicColors(true) + return tw +} + +func NewList(cnf *config.Config) *tview.List { + l := tview.NewList() + l.ShowSecondaryText(false) + l.SetHighlightFullLine(true) + l.SetBackgroundColor(cnf.Style.Background) + l.SetMainTextColor(cnf.Style.Text) + l.SetSelectedBackgroundColor(cnf.Style.ListSelectedBackground) + l.SetSelectedTextColor(cnf.Style.ListSelectedText) + return l +} + +func NewDropDown(cnf *config.Config) *tview.DropDown { + dd := tview.NewDropDown() + dd.SetBackgroundColor(cnf.Style.Background) + dd.SetFieldBackgroundColor(cnf.Style.Background) + dd.SetFieldTextColor(cnf.Style.Text) + + selected := tcell.Style{}. + Background(cnf.Style.ListSelectedBackground). + Foreground(cnf.Style.ListSelectedText) + unselected := tcell.Style{}. + Background(cnf.Style.StatusBarViewBackground). + Foreground(cnf.Style.StatusBarViewText) + dd.SetListStyles(selected, unselected) + return dd +} + +func NewInputField(cnf *config.Config) *tview.InputField { + i := tview.NewInputField() + i.SetBackgroundColor(cnf.Style.Background) + i.SetFieldBackgroundColor(cnf.Style.Background) + + selected := tcell.Style{}. + Background(cnf.Style.ListSelectedBackground). + Foreground(cnf.Style.ListSelectedText) + unselected := tcell.Style{}. + Background(cnf.Style.StatusBarViewBackground). + Foreground(cnf.Style.StatusBarViewText) + + i.SetAutocompleteStyles( + cnf.Style.Background, + selected, unselected) + return i +} + +func NewVerticalLine(cnf *config.Config) *tview.Box { + verticalLine := tview.NewBox() + verticalLine.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { + var s tcell.Style + s = s.Background(cnf.Style.Background).Foreground(cnf.Style.Subtle) + for cy := y; cy < y+height; cy++ { + screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, s) + } + return 0, 0, 0, 0 + }) + return verticalLine +} + +func NewHorizontalLine(cnf *config.Config) *tview.Box { + horizontalLine := tview.NewBox() + horizontalLine.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { + var s tcell.Style + s = s.Background(cnf.Style.Background).Foreground(cnf.Style.Subtle) + for cx := x; cx < x+width; cx++ { + screen.SetContent(cx, y, tview.BoxDrawingsLightHorizontal, nil, s) + } + return 0, 0, 0, 0 + }) + return horizontalLine +} diff --git a/ui/timeline.go b/ui/timeline.go new file mode 100644 index 0000000..96fe516 --- /dev/null +++ b/ui/timeline.go @@ -0,0 +1,228 @@ +package ui + +import ( + "fmt" + "os" + + "github.com/RasmusLindroth/tut/feed" +) + +type Timeline struct { + tutView *TutView + Feeds []*Feed + FeedIndex int + Notifications *Feed + update chan bool +} + +func NewTimeline(tv *TutView, update chan bool) *Timeline { + tl := &Timeline{ + tutView: tv, + Feeds: []*Feed{}, + FeedIndex: 0, + Notifications: nil, + update: update, + } + var nf *Feed + switch tv.tut.Config.General.StartTimeline { + case feed.TimelineFederated: + nf = NewFederatedFeed(tv) + case feed.TimelineLocal: + nf = NewLocalFeed(tv) + case feed.Conversations: + nf = NewConversationsFeed(tv) + default: + nf = NewHomeFeed(tv) + } + tl.Feeds = append(tl.Feeds, nf) + tl.Notifications = NewNotificationFeed(tv) + tl.Notifications.ListOutFocus() + + return tl +} + +func (tl *Timeline) AddFeed(f *Feed) { + tl.tutView.FocusFeed() + tl.Feeds = append(tl.Feeds, f) + tl.FeedIndex = tl.FeedIndex + 1 + tl.tutView.Shared.Top.SetText(tl.GetTitle()) + tl.update <- true +} + +func (tl *Timeline) RemoveCurrent(quit bool) { + if len(tl.Feeds) == 1 && !quit { + return + } + if len(tl.Feeds) == 1 && quit { + os.Exit(0) + } + tl.Feeds[tl.FeedIndex].Data.Close() + tl.Feeds = append(tl.Feeds[:tl.FeedIndex], tl.Feeds[tl.FeedIndex+1:]...) + ni := tl.FeedIndex - 1 + if ni < 0 { + ni = 0 + } + tl.FeedIndex = ni + tl.tutView.Shared.Top.SetText(tl.GetTitle()) + tl.update <- true +} + +func (tl *Timeline) NextFeed() { + l := len(tl.Feeds) + ni := tl.FeedIndex + 1 + if ni >= l { + ni = l - 1 + } + tl.FeedIndex = ni + tl.tutView.Shared.Top.SetText(tl.GetTitle()) + tl.update <- true +} + +func (tl *Timeline) PrevFeed() { + ni := tl.FeedIndex - 1 + if ni < 0 { + ni = 0 + } + tl.FeedIndex = ni + tl.tutView.Shared.Top.SetText(tl.GetTitle()) + tl.update <- true +} + +func (tl *Timeline) DrawContent(main bool) { + var f *Feed + if main { + f = tl.Feeds[tl.FeedIndex] + } else { + f = tl.Notifications + } + f.DrawContent() +} + +func (tl *Timeline) GetFeedList() *FeedList { + return tl.Feeds[tl.FeedIndex].List +} + +func (tl *Timeline) GetFeedContent(main bool) *FeedContent { + if main { + return tl.Feeds[tl.FeedIndex].Content + } else { + return tl.Notifications.Content + } +} + +func (tl *Timeline) GetTitle() string { + index := tl.FeedIndex + total := len(tl.Feeds) + current := tl.Feeds[index].Data.Type() + name := tl.Feeds[index].Data.Name() + ct := "" + switch current { + case feed.Favorited: + ct = "favorited" + case feed.Notification: + ct = "notifications" + case feed.Tag: + ct = fmt.Sprintf("tag #%s", name) + case feed.Thread: + ct = "thread feed" + case feed.TimelineFederated: + ct = "timeline federated" + case feed.TimelineHome: + ct = "timeline home" + case feed.TimelineLocal: + ct = "timeline local" + case feed.User: + ct = "timeline user" + case feed.UserList: + ct = fmt.Sprintf("user search %s", name) + case feed.Conversations: + ct = "timeline direct" + case feed.Lists: + ct = "lists" + case feed.List: + ct = fmt.Sprintf("list named %s", name) + case feed.Boosts: + ct = "boosts" + case feed.Favorites: + ct = "favorites" + case feed.Followers: + ct = "followers" + case feed.Following: + ct = "following" + case feed.Blocking: + ct = "blocking" + case feed.Muting: + ct = "muting" + } + return fmt.Sprintf("%s (%d/%d)", ct, index+1, total) +} + +func (tl *Timeline) ScrollUp() { + f := tl.Feeds[tl.FeedIndex] + row, _ := f.Content.Main.GetScrollOffset() + if row > 0 { + row = row - 1 + } + f.Content.Main.ScrollTo(row, 0) +} + +func (tl *Timeline) ScrollDown() { + f := tl.Feeds[tl.FeedIndex] + row, _ := f.Content.Main.GetScrollOffset() + f.Content.Main.ScrollTo(row+1, 0) +} + +func (tl *Timeline) NextItemFeed(mainFocus bool) { + var f *Feed + if mainFocus { + f = tl.Feeds[tl.FeedIndex] + } else { + f = tl.Notifications + } + loadMore := f.List.Next() + if loadMore { + f.LoadOlder() + } + tl.DrawContent(mainFocus) +} +func (tl *Timeline) PrevItemFeed(mainFocus bool) { + var f *Feed + if mainFocus { + f = tl.Feeds[tl.FeedIndex] + } else { + f = tl.Notifications + } + loadMore := f.List.Prev() + if loadMore { + f.LoadNewer() + } + tl.DrawContent(mainFocus) +} + +func (tl *Timeline) HomeItemFeed(mainFocus bool) { + var f *Feed + if mainFocus { + f = tl.Feeds[tl.FeedIndex] + } else { + f = tl.Notifications + } + f.List.SetCurrentItem(0) + f.LoadNewer() + tl.DrawContent(mainFocus) +} + +func (tl *Timeline) EndItemFeed(mainFocus bool) { + var f *Feed + if mainFocus { + f = tl.Feeds[tl.FeedIndex] + } else { + f = tl.Notifications + } + ni := f.List.GetItemCount() - 1 + if ni < 0 { + return + } + f.List.SetCurrentItem(ni) + f.LoadOlder() + tl.DrawContent(mainFocus) +} diff --git a/ui/top.go b/ui/top.go new file mode 100644 index 0000000..668ac91 --- /dev/null +++ b/ui/top.go @@ -0,0 +1,31 @@ +package ui + +import ( + "fmt" + + "github.com/rivo/tview" +) + +type Top struct { + TutView *TutView + View *tview.TextView +} + +func NewTop(tv *TutView) *Top { + t := &Top{ + TutView: tv, + View: NewTextView(tv.tut.Config), + } + t.View.SetBackgroundColor(tv.tut.Config.Style.TopBarBackground) + t.View.SetTextColor(tv.tut.Config.Style.TopBarText) + + return t +} + +func (t *Top) SetText(s string) { + if s == "" { + t.View.SetText("tut") + } else { + t.View.SetText(fmt.Sprintf("tut - %s", s)) + } +} diff --git a/ui/tutview.go b/ui/tutview.go new file mode 100644 index 0000000..f9d4ed0 --- /dev/null +++ b/ui/tutview.go @@ -0,0 +1,152 @@ +package ui + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/api" + "github.com/RasmusLindroth/tut/auth" + "github.com/RasmusLindroth/tut/config" + "github.com/rivo/tview" +) + +type TimelineFocusAt uint + +const ( + FeedFocus TimelineFocusAt = iota + NotificationFocus +) + +type SubFocusAt uint + +const ( + ListFocus SubFocusAt = iota + ContentFocus +) + +type Tut struct { + Client *api.AccountClient + App *tview.Application + Config *config.Config +} + +type TutView struct { + tut *Tut + Timeline *Timeline + PageFocus PageFocusAt + PrevPageFocus PageFocusAt + TimelineFocus TimelineFocusAt + SubFocus SubFocusAt + Shared *Shared + View *tview.Pages + + LoginView *LoginView + MainView *MainView + LinkView *LinkView + ComposeView *ComposeView + VoteView *VoteView + HelpView *HelpView + ModalView *ModalView + + FileList []string +} + +func NewTutView(t *Tut, accs *auth.AccountData, selectedUser string) *TutView { + tv := &TutView{ + tut: t, + View: tview.NewPages(), + FileList: []string{}, + } + tv.Shared = NewShared(tv) + if selectedUser != "" { + useHost := false + found := false + if strings.Contains(selectedUser, "@") { + useHost = true + } + for _, acc := range accs.Accounts { + accName := acc.Name + if useHost { + host := strings.TrimPrefix(acc.Server, "https://") + host = strings.TrimPrefix(host, "http://") + accName += "@" + host + } + if accName == selectedUser { + tv.loggedIn(acc) + found = true + } + } + if !found { + log.Fatalf("Couldn't find a user named %s. Try again", selectedUser) + } + } else if len(accs.Accounts) > 1 { + tv.LoginView = NewLoginView(tv, accs) + tv.View.AddPage("login", tv.LoginView.View, true, true) + tv.SetPage(LoginFocus) + } else { + tv.loggedIn(accs.Accounts[0]) + } + return tv +} + +func (tv *TutView) loggedIn(acc auth.Account) { + conf := &mastodon.Config{ + Server: acc.Server, + ClientID: acc.ClientID, + ClientSecret: acc.ClientSecret, + AccessToken: acc.AccessToken, + } + client := mastodon.NewClient(conf) + me, err := client.GetAccountCurrentUser(context.Background()) + if err != nil { + fmt.Printf("Couldn't login. Error %s\n", err) + os.Exit(1) + } + ac := &api.AccountClient{ + Me: me, + Client: client, + Streams: make(map[string]*api.Stream), + } + tv.tut.Client = ac + + update := make(chan bool, 1) + tv.TimelineFocus = FeedFocus + tv.SubFocus = ListFocus + tv.LinkView = NewLinkView(tv) + tv.Timeline = NewTimeline(tv, update) + tv.MainView = NewMainView(tv, update) + tv.ComposeView = NewComposeView(tv) + tv.VoteView = NewVoteView(tv) + tv.HelpView = NewHelpView(tv) + tv.ModalView = NewModalView(tv) + + tv.View.AddPage("main", tv.MainView.View, true, false) + tv.View.AddPage("link", tv.LinkView.View, true, false) + tv.View.AddPage("compose", tv.ComposeView.View, true, false) + tv.View.AddPage("vote", tv.VoteView.View, true, false) + tv.View.AddPage("help", tv.HelpView.View, true, false) + tv.View.AddPage("modal", tv.ModalView.View, true, false) + tv.SetPage(MainFocus) +} + +func (tv *TutView) FocusNotification() { + tv.TimelineFocus = NotificationFocus + for _, f := range tv.Timeline.Feeds { + f.ListOutFocus() + } + tv.Timeline.Notifications.ListInFocus() + tv.Timeline.update <- true +} + +func (tv *TutView) FocusFeed() { + tv.TimelineFocus = FeedFocus + for _, f := range tv.Timeline.Feeds { + f.ListInFocus() + } + tv.Timeline.Notifications.ListOutFocus() + tv.Timeline.update <- true +} diff --git a/ui/view.go b/ui/view.go new file mode 100644 index 0000000..48643fd --- /dev/null +++ b/ui/view.go @@ -0,0 +1,167 @@ +package ui + +import ( + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/api" +) + +type PageFocusAt uint + +const ( + LoginFocus PageFocusAt = iota + MainFocus + ViewFocus + ModalFocus + LinkFocus + ComposeFocus + MediaFocus + MediaAddFocus + CmdFocus + VoteFocus + HelpFocus +) + +func (tv *TutView) GetCurrentFeed() *Feed { + foc := tv.TimelineFocus + if foc == FeedFocus { + return tv.Timeline.Feeds[tv.Timeline.FeedIndex] + } + return tv.Timeline.Notifications +} + +func (tv *TutView) GetCurrentItem() (api.Item, error) { + f := tv.GetCurrentFeed() + return f.Data.Item(f.List.Text.GetCurrentItem()) +} + +func (tv *TutView) RedrawContent() { + f := tv.GetCurrentFeed() + item, err := f.Data.Item(f.List.Text.GetCurrentItem()) + if err != nil { + return + } + DrawItem(tv.tut, item, f.Content.Main, f.Content.Controls) +} +func (tv *TutView) RedrawPoll(poll *mastodon.Poll) { + f := tv.GetCurrentFeed() + item, err := f.Data.Item(f.List.Text.GetCurrentItem()) + if err != nil { + return + } + if item.Type() != api.StatusType { + tv.RedrawContent() + return + } + so := item.Raw().(*mastodon.Status) + if so.Reblog != nil { + so.Reblog.Poll = poll + } else { + so.Poll = poll + } + DrawItem(tv.tut, item, f.Content.Main, f.Content.Controls) +} +func (tv *TutView) RedrawControls() { + f := tv.GetCurrentFeed() + item, err := f.Data.Item(f.List.Text.GetCurrentItem()) + if err != nil { + return + } + DrawItemControls(tv.tut, item, f.Content.Controls) +} + +func (tv *TutView) SetPage(f PageFocusAt) { + if f == tv.PageFocus { + return + } + tv.PrevPageFocus = tv.PageFocus + if tv.PrevPageFocus == LoginFocus { + tv.PrevPageFocus = MainFocus + } + switch f { + case LoginFocus: + tv.PageFocus = LoginFocus + tv.View.SwitchToPage("login") + tv.Shared.Bottom.StatusBar.SetMode(UserMode) + tv.Shared.Top.SetText("select accouth with ") + tv.tut.App.SetFocus(tv.View) + case MainFocus: + tv.PageFocus = MainFocus + tv.View.SwitchToPage("main") + tv.Shared.Bottom.StatusBar.SetMode(ListMode) + tv.Shared.Top.SetText(tv.Timeline.GetTitle()) + tv.tut.App.SetFocus(tv.View) + case ViewFocus: + f := tv.GetCurrentFeed() + tv.PageFocus = ViewFocus + tv.Shared.Bottom.StatusBar.SetMode(ScrollMode) + tv.tut.App.SetFocus(f.Content.Main) + case LinkFocus: + tv.PageFocus = LinkFocus + tv.View.SwitchToPage("link") + tv.Shared.Bottom.StatusBar.SetMode(ListMode) + tv.Shared.Top.SetText("select link with ") + tv.tut.App.SetFocus(tv.View) + case ComposeFocus: + tv.PageFocus = ComposeFocus + tv.View.SwitchToPage("compose") + tv.Shared.Bottom.StatusBar.SetMode(ComposeMode) + tv.Shared.Top.SetText("write a toot") + tv.ComposeView.SetControls(ComposeNormal) + tv.tut.App.SetFocus(tv.ComposeView.content) + case MediaFocus: + tv.PageFocus = MediaFocus + tv.ComposeView.SetControls(ComposeMedia) + tv.tut.App.SetFocus(tv.View) + case MediaAddFocus: + tv.PageFocus = MediaAddFocus + tv.tut.App.SetFocus(tv.ComposeView.input.View) + case CmdFocus: + tv.PageFocus = CmdFocus + tv.tut.App.SetFocus(tv.Shared.Bottom.Cmd.View) + tv.Shared.Bottom.StatusBar.SetMode(CmdMode) + tv.Shared.Bottom.Cmd.ClearInput() + case VoteFocus: + tv.PageFocus = VoteFocus + tv.View.SwitchToPage("vote") + tv.tut.App.SetFocus(tv.View) + tv.Shared.Bottom.StatusBar.SetMode(VoteMode) + tv.Shared.Top.SetText("vote on poll") + case HelpFocus: + tv.PageFocus = HelpFocus + tv.View.SwitchToPage("help") + tv.Shared.Bottom.StatusBar.SetMode(HelpMode) + tv.tut.App.SetFocus(tv.HelpView.content) + case ModalFocus: + tv.PageFocus = ModalFocus + tv.View.SwitchToPage("modal") + tv.tut.App.SetFocus(tv.ModalView.View) + + } + tv.ShouldSync() +} + +func (tv *TutView) FocusMainNoHistory() { + tv.SetPage(MainFocus) + tv.PrevPageFocus = MainFocus +} + +func (tv *TutView) PrevFocus() { + tv.SetPage(tv.PrevPageFocus) + tv.PrevPageFocus = MainFocus +} + +func (tv *TutView) InitPost(status *mastodon.Status) { + tv.ComposeView.SetStatus(status) + tv.SetPage(ComposeFocus) +} + +func (tv *TutView) ShowError(s string) { + tv.Shared.Bottom.Cmd.ShowError(s) +} + +func (tv *TutView) ShouldSync() { + if !tv.tut.Config.General.RedrawUI { + return + } + tv.tut.App.Sync() +} diff --git a/ui/voteview.go b/ui/voteview.go new file mode 100644 index 0000000..9eb29a8 --- /dev/null +++ b/ui/voteview.go @@ -0,0 +1,147 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/RasmusLindroth/tut/config" + "github.com/rivo/tview" +) + +type VoteView struct { + tutView *TutView + shared *Shared + View *tview.Flex + textTop *tview.TextView + controls *tview.TextView + list *tview.List + poll *mastodon.Poll + selected []int +} + +func NewVoteView(tv *TutView) *VoteView { + v := &VoteView{ + tutView: tv, + shared: tv.Shared, + textTop: NewTextView(tv.tut.Config), + controls: NewTextView(tv.tut.Config), + list: NewList(tv.tut.Config), + } + v.View = voteViewUI(v) + + return v +} + +func voteViewUI(v *VoteView) *tview.Flex { + var items []string + items = append(items, config.ColorKey(v.tutView.tut.Config, "Select ", "Space/Enter", "")) + items = append(items, config.ColorKey(v.tutView.tut.Config, "", "V", "ote")) + v.controls.SetText(strings.Join(items, " ")) + + return tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(v.shared.Top.View, 1, 0, false). + AddItem(v.textTop, 3, 0, false). + AddItem(v.list, 0, 10, false). + AddItem(v.controls, 1, 0, false). + AddItem(v.shared.Bottom.View, 2, 0, false) +} + +func (v *VoteView) SetPoll(poll *mastodon.Poll) { + v.poll = poll + v.selected = []int{} + v.list.Clear() + if v.poll.Multiple { + v.textTop.SetText( + tview.Escape("You can select multiple options. Press [v] to vote when you're finished selecting"), + ) + } else { + v.textTop.SetText( + tview.Escape("You can only select ONE option. Press [v] to vote when you're finished selecting"), + ) + } + for _, o := range poll.Options { + v.list.AddItem(tview.Escape(o.Title), "", 0, nil) + } +} + +func (v *VoteView) Prev() { + index := v.list.GetCurrentItem() + if index-1 >= 0 { + v.list.SetCurrentItem(index - 1) + } +} + +func (v *VoteView) Next() { + index := v.list.GetCurrentItem() + if index+1 < v.list.GetItemCount() { + v.list.SetCurrentItem(index + 1) + } +} + +func (v *VoteView) ToggleSelect() { + index := v.list.GetCurrentItem() + inSelected := false + for _, value := range v.selected { + if index == value { + inSelected = true + break + } + } + if inSelected { + v.Unselect() + } else { + v.Select() + } +} + +func (v *VoteView) Select() { + if !v.poll.Multiple && len(v.selected) > 0 { + return + } + index := v.list.GetCurrentItem() + inSelected := false + for _, value := range v.selected { + if index == value { + inSelected = true + break + } + } + if inSelected { + return + } + v.selected = append(v.selected, index) + v.list.SetItemText(index, + tview.Escape(fmt.Sprintf("[x] %s", v.poll.Options[index].Title)), + "") +} + +func (v *VoteView) Unselect() { + index := v.list.GetCurrentItem() + sel := []int{} + for _, value := range v.selected { + if value == index { + continue + } + sel = append(sel, value) + } + v.selected = sel + v.list.SetItemText(index, + tview.Escape(v.poll.Options[index].Title), + "") +} + +func (v *VoteView) Vote() { + if len(v.selected) == 0 { + return + } + p, err := v.tutView.tut.Client.Vote(v.poll, v.selected...) + if err != nil { + v.tutView.ShowError( + fmt.Sprintf("Couldn't vote. Error: %v\n", err), + ) + return + } + v.tutView.FocusMainNoHistory() + v.tutView.RedrawPoll(p) +} diff --git a/userselectoverlay.go b/userselectoverlay.go deleted file mode 100644 index 2051b6f..0000000 --- a/userselectoverlay.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func NewUserSelectOverlay(app *App) *UserSelectOverlay { - u := &UserSelectOverlay{ - app: app, - Flex: tview.NewFlex(), - List: tview.NewList(), - Text: tview.NewTextView(), - } - - u.Flex.SetBackgroundColor(app.Config.Style.Background) - u.List.SetMainTextColor(app.Config.Style.Text) - u.List.SetBackgroundColor(app.Config.Style.Background) - u.List.SetSelectedTextColor(app.Config.Style.ListSelectedText) - u.List.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground) - u.List.ShowSecondaryText(false) - u.List.SetHighlightFullLine(true) - u.Text.SetBackgroundColor(app.Config.Style.Background) - u.Text.SetTextColor(app.Config.Style.Text) - u.Flex.SetDrawFunc(app.Config.ClearContent) - return u -} - -type UserSelectOverlay struct { - app *App - Flex *tview.Flex - List *tview.List - Text *tview.TextView -} - -func (u *UserSelectOverlay) Prev() { - index := u.List.GetCurrentItem() - if index-1 >= 0 { - u.List.SetCurrentItem(index - 1) - } -} - -func (u *UserSelectOverlay) Next() { - index := u.List.GetCurrentItem() - if index+1 < u.List.GetItemCount() { - u.List.SetCurrentItem(index + 1) - } -} -func (u *UserSelectOverlay) Done() { - index := u.List.GetCurrentItem() - u.app.Login(index) - u.app.UI.LoggedIn() -} - -func (u *UserSelectOverlay) InputHandler(event *tcell.EventKey) { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'j', 'J': - u.Next() - case 'k', 'K': - u.Prev() - case 'q', 'Q': - u.app.UI.Root.Stop() - } - } else { - switch event.Key() { - case tcell.KeyEnter: - u.Done() - case tcell.KeyUp: - u.Prev() - case tcell.KeyDown: - u.Next() - } - } -} - -func (u *UserSelectOverlay) Draw() { - u.Text.SetText("Select the user you want to use for this session by pressing Enter.") - if len(u.app.Accounts.Accounts) > 0 { - for i := 0; i < len(u.app.Accounts.Accounts); i++ { - acc := u.app.Accounts.Accounts[i] - u.List.AddItem(fmt.Sprintf("%s - %s", acc.Name, acc.Server), "", 0, nil) - } - } -} diff --git a/util.go b/util.go deleted file mode 100644 index f17a81a..0000000 --- a/util.go +++ /dev/null @@ -1,503 +0,0 @@ -package main - -import ( - "fmt" - "io" - "io/ioutil" - "log" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/atotto/clipboard" - "github.com/gdamore/tcell/v2" - "github.com/gen2brain/beeep" - "github.com/icza/gox/timex" - "github.com/mattn/go-mastodon" - "github.com/microcosm-cc/bluemonday" - "github.com/rivo/tview" - "golang.org/x/net/html" -) - -type URL struct { - Text string - URL string - Classes []string -} - -//Runs commands prefixed !CMD! -func CmdToString(cmd string) (string, error) { - cmd = strings.TrimPrefix(cmd, "!CMD!") - parts := strings.Split(cmd, " ") - s, err := exec.Command(parts[0], parts[1:]...).CombinedOutput() - return strings.TrimSpace(string(s)), err -} - -func getURLs(text string) []URL { - doc := html.NewTokenizer(strings.NewReader(text)) - var urls []URL - - for { - n := doc.Next() - switch n { - case html.ErrorToken: - return urls - - case html.StartTagToken: - token := doc.Token() - if token.Data == "a" { - url := URL{} - var appendUrl = true - for _, a := range token.Attr { - switch a.Key { - case "href": - url.URL = a.Val - url.Text = a.Val - case "class": - url.Classes = strings.Split(a.Val, " ") - - if strings.Contains(a.Val, "hashtag") { - appendUrl = false - } - } - } - if appendUrl { - urls = append(urls, url) - } - } - } - } -} - -func cleanTootHTML(content string) (string, []URL) { - stripped := bluemonday.NewPolicy().AllowElements("p", "br").AllowAttrs("href", "class").OnElements("a").Sanitize(content) - 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) - stripped = html.UnescapeString(stripped) - return stripped, urls -} - -func openEditor(app *tview.Application, content string) (string, error) { - editor, exists := os.LookupEnv("EDITOR") - if !exists || editor == "" { - editor = "vi" - } - args := []string{} - parts := strings.Split(editor, " ") - if len(parts) > 1 { - args = append(args, parts[1:]...) - editor = parts[0] - } - f, err := ioutil.TempFile("", "tut") - if err != nil { - return "", err - } - if content != "" { - _, err = f.WriteString(content) - if err != nil { - return "", err - } - } - args = append(args, f.Name()) - cmd := exec.Command(editor, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - var text []byte - app.Suspend(func() { - err = cmd.Run() - if err != nil { - log.Fatalln(err) - } - f.Seek(0, 0) - text, err = ioutil.ReadAll(f) - }) - f.Close() - if err != nil { - return "", err - } - return strings.TrimSpace(string(text)), nil -} - -func copyToClipboard(text string) bool { - if clipboard.Unsupported { - return false - } - clipboard.WriteAll(text) - return true -} - -func openCustom(app *tview.Application, program string, args []string, terminal bool, url string) { - args = append(args, url) - if terminal { - openInTerminal(app, program, args...) - } else { - exec.Command(program, args...).Start() - } -} - -func openURL(app *tview.Application, conf MediaConfig, pc OpenPatternConfig, url string) { - for _, m := range pc.Patterns { - if m.Compiled.Match(url) { - args := append(m.Args, url) - if m.Terminal { - openInTerminal(app, m.Program, args...) - } else { - exec.Command(m.Program, args...).Start() - } - return - } - } - args := append(conf.LinkArgs, url) - if conf.LinkTerminal { - openInTerminal(app, conf.LinkViewer, args...) - } else { - exec.Command(conf.LinkViewer, args...).Start() - } -} - -func reverseFiles(filenames []string) []string { - if len(filenames) == 0 { - return filenames - } - var f []string - for i := len(filenames) - 1; i >= 0; i-- { - f = append(f, filenames[i]) - } - return f -} - -type runProgram struct { - Name string - Args []string - Terminal bool -} - -func newRunProgram(name string, args ...string) runProgram { - return runProgram{ - Name: name, - Args: args, - } -} - -func openMediaType(app *tview.Application, conf MediaConfig, filenames []string, mediaType string) { - terminal := []runProgram{} - external := []runProgram{} - - switch mediaType { - case "image": - if conf.ImageReverse { - filenames = reverseFiles(filenames) - } - if conf.ImageSingle { - for _, f := range filenames { - args := append(conf.ImageArgs, f) - c := newRunProgram(conf.ImageViewer, args...) - if conf.ImageTerminal { - terminal = append(terminal, c) - } else { - external = append(external, c) - } - } - } else { - args := append(conf.ImageArgs, filenames...) - c := newRunProgram(conf.ImageViewer, args...) - if conf.ImageTerminal { - terminal = append(terminal, c) - } else { - external = append(external, c) - } - } - case "video", "gifv": - if conf.VideoReverse { - filenames = reverseFiles(filenames) - } - if conf.VideoSingle { - for _, f := range filenames { - args := append(conf.VideoArgs, f) - c := newRunProgram(conf.VideoViewer, args...) - if conf.VideoTerminal { - terminal = append(terminal, c) - } else { - external = append(external, c) - } - } - } else { - args := append(conf.VideoArgs, filenames...) - c := newRunProgram(conf.VideoViewer, args...) - if conf.VideoTerminal { - terminal = append(terminal, c) - } else { - external = append(external, c) - } - } - case "audio": - if conf.AudioReverse { - filenames = reverseFiles(filenames) - } - if conf.AudioSingle { - for _, f := range filenames { - args := append(conf.AudioArgs, f) - c := newRunProgram(conf.AudioViewer, args...) - if conf.AudioTerminal { - terminal = append(terminal, c) - } else { - external = append(external, c) - } - } - } else { - args := append(conf.AudioArgs, filenames...) - c := newRunProgram(conf.AudioViewer, args...) - if conf.AudioTerminal { - terminal = append(terminal, c) - } else { - external = append(external, c) - } - } - } - go func() { - for _, ext := range external { - exec.Command(ext.Name, ext.Args...).Run() - } - }() - for _, term := range terminal { - openInTerminal(app, term.Name, term.Args...) - } -} - -func openInTerminal(app *tview.Application, command string, args ...string) error { - cmd := exec.Command(command, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - var err error - app.Suspend(func() { - err = cmd.Run() - if err != nil { - log.Fatalln(err) - } - }) - return err -} - -func downloadFile(url string) (string, error) { - f, err := ioutil.TempFile("", "tutfile") - if err != nil { - return "", err - } - defer f.Close() - - resp, err := http.Get(url) - if err != nil { - return "", err - } - defer resp.Body.Close() - - _, err = io.Copy(f, resp.Body) - if err != nil { - return "", nil - } - - return f.Name(), nil -} - -func getConfigDir() string { - home, _ := os.LookupEnv("HOME") - xdgConfig, exists := os.LookupEnv("XDG_CONFIG_HOME") - if !exists { - xdgConfig = home + "/.config" - } - xdgConfig += "/tut" - return xdgConfig -} - -func testConfigPath(name string) (string, error) { - xdgConfig := getConfigDir() - path := xdgConfig + "/" + name - _, err := os.Stat(path) - if os.IsNotExist(err) { - return "", err - } - if err != nil { - return "", err - } - return path, nil -} - -func GetAccountsPath() (string, error) { - return testConfigPath("accounts.yaml") -} - -func GetConfigPath() (string, error) { - return testConfigPath("config.ini") -} - -func CheckPath(input string, inclHidden bool) (string, bool) { - info, err := os.Stat(input) - if err != nil { - return "", false - } - if !inclHidden && strings.HasPrefix(info.Name(), ".") { - return "", false - } - - if info.IsDir() { - if input == "/" { - return input, true - } - return input + "/", true - } - return input, true -} - -func IsDir(input string) bool { - info, err := os.Stat(input) - if err != nil { - return false - } - return info.IsDir() -} - -func FindFiles(s string) []string { - input := filepath.Clean(s) - if len(s) > 2 && s[len(s)-2:] == "/." { - input += "/." - } - var files []string - path, exists := CheckPath(input, true) - if exists { - files = append(files, path) - } - - base := filepath.Base(input) - inclHidden := strings.HasPrefix(base, ".") || (len(input) > 1 && input[len(input)-2:] == "/.") - matches, _ := filepath.Glob(input + "*") - if strings.HasSuffix(path, "/") { - matchesDir, _ := filepath.Glob(path + "*") - matches = append(matches, matchesDir...) - } - for _, f := range matches { - p, exists := CheckPath(f, inclHidden) - if exists && p != path { - files = append(files, p) - } - } - return files -} - -func ColorKey(c *Config, pre, key, end string) string { - color := ColorMark(c.Style.TextSpecial2) - normal := ColorMark(c.Style.Text) - key = TextFlags("b") + key + TextFlags("-") - if c.General.ShortHints { - pre = "" - end = "" - } - text := fmt.Sprintf("%s%s%s%s%s%s", normal, pre, color, key, normal, end) - return text -} - -func TextFlags(s string) string { - return fmt.Sprintf("[::%s]", s) -} - -func ColorMark(color tcell.Color) string { - return fmt.Sprintf("[#%06x]", color.Hex()) -} - -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 := ColorMark(style.Subtle) - return fmt.Sprintf("%s%s", subtle, text) -} - -func FloorDate(t time.Time) time.Time { - return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) -} - -func OutputDate(status time.Time, today time.Time, long, short string, relativeDate int) string { - ty, tm, td := today.Date() - sy, sm, sd := status.Date() - - format := long - sameDay := false - displayRelative := false - - if ty == sy && tm == sm && td == sd { - format = short - sameDay = true - } - - todayFloor := FloorDate(today) - statusFloor := FloorDate(status) - - if relativeDate > -1 && !sameDay { - days := int(todayFloor.Sub(statusFloor).Hours() / 24) - if relativeDate == 0 || days <= relativeDate { - displayRelative = true - } - } - var dateOutput string - if displayRelative { - y, m, d, _, _, _ := timex.Diff(statusFloor, todayFloor) - if y > 0 { - dateOutput = fmt.Sprintf("%s%dy", dateOutput, y) - } - if dateOutput != "" || m > 0 { - dateOutput = fmt.Sprintf("%s%dm", dateOutput, m) - } - if dateOutput != "" || d > 0 { - dateOutput = fmt.Sprintf("%s%dd", dateOutput, d) - } - } else { - dateOutput = status.Format(format) - } - return dateOutput -} - -func Notify(nc NotificationConfig, t NotificationType, title string, body string) { - switch t { - case NotificationFollower: - if !nc.NotificationFollower { - return - } - case NotificationFavorite: - if !nc.NotificationFavorite { - return - } - case NotificationMention: - if !nc.NotificationMention { - return - } - case NotificationBoost: - if !nc.NotificationBoost { - return - } - case NotificationPoll: - if !nc.NotificationPoll { - return - } - case NotificationPost: - if !nc.NotificationPost { - return - } - default: - return - } - - beeep.Notify(title, body, "") -} diff --git a/util/terminal_input.go b/util/terminal_input.go new file mode 100644 index 0000000..f05c0fe --- /dev/null +++ b/util/terminal_input.go @@ -0,0 +1,15 @@ +package util + +import ( + "bufio" + "strings" +) + +func ReadLine(r *bufio.Reader) (string, error) { + text, err := r.ReadString('\n') + if err != nil { + return text, err + } + text = strings.TrimSpace(text) + return text, err +} diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..8ea4beb --- /dev/null +++ b/util/util.go @@ -0,0 +1,159 @@ +package util + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/RasmusLindroth/go-mastodon" + "github.com/microcosm-cc/bluemonday" + "golang.org/x/net/html" +) + +type URL struct { + Text string + URL string + Classes []string +} + +func CleanHTML(content string) (string, []URL) { + stripped := bluemonday.NewPolicy().AllowElements("p", "br").AllowAttrs("href", "class").OnElements("a").Sanitize(content) + 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) + stripped = html.UnescapeString(stripped) + return stripped, urls +} + +func getURLs(text string) []URL { + doc := html.NewTokenizer(strings.NewReader(text)) + var urls []URL + + for { + n := doc.Next() + switch n { + case html.ErrorToken: + return urls + + case html.StartTagToken: + token := doc.Token() + if token.Data == "a" { + url := URL{} + var appendUrl = true + for _, a := range token.Attr { + switch a.Key { + case "href": + url.URL = a.Val + url.Text = a.Val + case "class": + url.Classes = strings.Split(a.Val, " ") + + if strings.Contains(a.Val, "hashtag") { + appendUrl = false + } + } + } + if appendUrl { + urls = append(urls, url) + } + } + } + } +} + +func CmdToString(cmd string) (string, error) { + cmd = strings.TrimPrefix(cmd, "!CMD!") + parts := strings.Split(cmd, " ") + s, err := exec.Command(parts[0], parts[1:]...).CombinedOutput() + return strings.TrimSpace(string(s)), err +} + +func CheckConfig(filename string) (path string, exists bool, err error) { + cd, err := os.UserConfigDir() + if err != nil { + log.Fatalf("couldn't find $HOME. Err %v", err) + } + dir := cd + "/tut/" + path = dir + filename + _, err = os.Stat(path) + if os.IsNotExist(err) { + return path, false, nil + } else if err != nil { + return path, true, err + } + return path, true, err +} + +func FormatUsername(a mastodon.Account) string { + if a.DisplayName != "" { + return fmt.Sprintf("%s (%s)", a.DisplayName, a.Acct) + } + return a.Acct +} + +func CheckPath(input string, inclHidden bool) (string, bool) { + info, err := os.Stat(input) + if err != nil { + return "", false + } + if !inclHidden && strings.HasPrefix(info.Name(), ".") { + return "", false + } + + if info.IsDir() { + if input == "/" { + return input, true + } + return input + "/", true + } + return input, true +} + +func IsDir(input string) bool { + info, err := os.Stat(input) + if err != nil { + return false + } + return info.IsDir() +} + +func FindFiles(s string) []string { + input := filepath.Clean(s) + if len(s) > 2 && s[len(s)-2:] == "/." { + input += "/." + } + var files []string + path, exists := CheckPath(input, true) + if exists { + files = append(files, path) + } + + base := filepath.Base(input) + inclHidden := strings.HasPrefix(base, ".") || (len(input) > 1 && input[len(input)-2:] == "/.") + matches, _ := filepath.Glob(input + "*") + if strings.HasSuffix(path, "/") { + matchesDir, _ := filepath.Glob(path + "*") + matches = append(matches, matchesDir...) + } + for _, f := range matches { + p, exists := CheckPath(f, inclHidden) + if exists && p != path { + files = append(files, p) + } + } + return files +} + +func StatusOrReblog(s *mastodon.Status) *mastodon.Status { + if s.Reblog != nil { + return s.Reblog + } + return s +} diff --git a/util/xdg.go b/util/xdg.go new file mode 100644 index 0000000..e38da9b --- /dev/null +++ b/util/xdg.go @@ -0,0 +1,7 @@ +package util + +import "os/exec" + +func OpenURL(url string) { + exec.Command("xdg-open", url).Start() +} diff --git a/visibilityoverlay.go b/visibilityoverlay.go deleted file mode 100644 index 18f45ec..0000000 --- a/visibilityoverlay.go +++ /dev/null @@ -1,116 +0,0 @@ -package main - -import ( - "github.com/gdamore/tcell/v2" - "github.com/mattn/go-mastodon" - "github.com/rivo/tview" -) - -func NewVisibilityOverlay(app *App) *VisibilityOverlay { - v := &VisibilityOverlay{ - app: app, - Flex: tview.NewFlex(), - TextBottom: tview.NewTextView(), - List: tview.NewList(), - } - - v.TextBottom.SetBackgroundColor(app.Config.Style.Background) - v.TextBottom.SetDynamicColors(true) - v.List.SetBackgroundColor(app.Config.Style.Background) - v.List.SetMainTextColor(app.Config.Style.Text) - v.List.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground) - v.List.SetSelectedTextColor(app.Config.Style.ListSelectedText) - v.List.ShowSecondaryText(false) - v.List.SetHighlightFullLine(true) - v.Flex.SetDrawFunc(app.Config.ClearContent) - v.TextBottom.SetText(ColorKey(app.Config, "", "Enter", "")) - return v -} - -type VisibilityOverlay struct { - app *App - Flex *tview.Flex - TextBottom *tview.TextView - List *tview.List - Selected int -} - -func (v *VisibilityOverlay) SetVisibilty(s string) { - v.List.Clear() - visibilities := []string{ - mastodon.VisibilityPublic, - mastodon.VisibilityFollowersOnly, - mastodon.VisibilityUnlisted, - mastodon.VisibilityDirectMessage, - } - - selected := 0 - for i, item := range visibilities { - if s == item { - selected = i - } - v.List.AddItem( - VisibilityToText(item), - "", 0, nil) - } - v.List.SetCurrentItem(selected) - v.Selected = selected -} - -func (v *VisibilityOverlay) Show() { - v.List.SetCurrentItem(v.Selected) -} - -func (v *VisibilityOverlay) Prev() { - index := v.List.GetCurrentItem() - if index-1 >= 0 { - v.List.SetCurrentItem(index - 1) - } -} - -func (v *VisibilityOverlay) Next() { - index := v.List.GetCurrentItem() - if index+1 < v.List.GetItemCount() { - v.List.SetCurrentItem(index + 1) - } -} - -func (v *VisibilityOverlay) SetVisibilityIndex() { - index := v.List.GetCurrentItem() - v.Selected = index -} - -func (v *VisibilityOverlay) GetVisibility() string { - visibilities := []string{ - mastodon.VisibilityPublic, - mastodon.VisibilityFollowersOnly, - mastodon.VisibilityUnlisted, - mastodon.VisibilityDirectMessage, - } - return visibilities[v.Selected] -} - -func (v *VisibilityOverlay) InputHandler(event *tcell.EventKey) { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'j', 'J': - v.Next() - case 'k', 'K': - v.Prev() - case 'q', 'Q': - v.app.UI.SetFocus(MessageFocus) - } - } else { - switch event.Key() { - case tcell.KeyEnter: - v.SetVisibilityIndex() - v.app.UI.SetFocus(MessageFocus) - case tcell.KeyUp: - v.Prev() - case tcell.KeyDown: - v.Next() - case tcell.KeyEsc: - v.app.UI.SetFocus(MessageFocus) - } - } -} diff --git a/voteoverlay.go b/voteoverlay.go deleted file mode 100644 index 7c32b6d..0000000 --- a/voteoverlay.go +++ /dev/null @@ -1,173 +0,0 @@ -package main - -import ( - "fmt" - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/mattn/go-mastodon" - "github.com/rivo/tview" -) - -func NewVoteOverlay(app *App) *VoteOverlay { - v := &VoteOverlay{ - app: app, - Flex: tview.NewFlex(), - TextTop: tview.NewTextView(), - TextBottom: tview.NewTextView(), - List: tview.NewList(), - } - - v.TextTop.SetBackgroundColor(app.Config.Style.Background) - v.TextTop.SetTextColor(app.Config.Style.Text) - v.TextTop.SetDynamicColors(true) - v.TextBottom.SetBackgroundColor(app.Config.Style.Background) - v.TextBottom.SetDynamicColors(true) - v.List.SetBackgroundColor(app.Config.Style.Background) - v.List.SetMainTextColor(app.Config.Style.Text) - v.List.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground) - v.List.SetSelectedTextColor(app.Config.Style.ListSelectedText) - v.List.ShowSecondaryText(false) - v.List.SetHighlightFullLine(true) - v.Flex.SetDrawFunc(app.Config.ClearContent) - var items []string - items = append(items, ColorKey(app.Config, "Select ", "Space/Enter", "")) - items = append(items, ColorKey(app.Config, "", "V", "ote")) - v.TextBottom.SetText(strings.Join(items, " ")) - return v -} - -type VoteOverlay struct { - app *App - Flex *tview.Flex - TextTop *tview.TextView - TextBottom *tview.TextView - List *tview.List - poll *mastodon.Poll - selected []int -} - -func (v *VoteOverlay) SetPoll(poll *mastodon.Poll) { - v.poll = poll - v.selected = []int{} - v.List.Clear() - if v.poll.Multiple { - v.TextTop.SetText( - tview.Escape("You can select multiple options. Press [v] to vote when you're finished selecting"), - ) - } else { - v.TextTop.SetText( - tview.Escape("You can only select ONE option. Press [v] to vote when you're finished selecting"), - ) - } - for _, o := range poll.Options { - v.List.AddItem(tview.Escape(o.Title), "", 0, nil) - } -} - -func (v *VoteOverlay) Prev() { - index := v.List.GetCurrentItem() - if index-1 >= 0 { - v.List.SetCurrentItem(index - 1) - } -} - -func (v *VoteOverlay) Next() { - index := v.List.GetCurrentItem() - if index+1 < v.List.GetItemCount() { - v.List.SetCurrentItem(index + 1) - } -} - -func (v *VoteOverlay) ToggleSelect() { - index := v.List.GetCurrentItem() - inSelected := false - for _, value := range v.selected { - if index == value { - inSelected = true - break - } - } - if inSelected { - v.Unselect() - } else { - v.Select() - } -} - -func (v *VoteOverlay) Select() { - if !v.poll.Multiple && len(v.selected) > 0 { - return - } - index := v.List.GetCurrentItem() - inSelected := false - for _, value := range v.selected { - if index == value { - inSelected = true - break - } - } - if inSelected { - return - } - v.selected = append(v.selected, index) - v.List.SetItemText(index, - tview.Escape(fmt.Sprintf("[x] %s", v.poll.Options[index].Title)), - "") -} - -func (v *VoteOverlay) Unselect() { - index := v.List.GetCurrentItem() - sel := []int{} - for _, value := range v.selected { - if value == index { - continue - } - sel = append(sel, value) - } - v.selected = sel - v.List.SetItemText(index, - tview.Escape(v.poll.Options[index].Title), - "") -} - -func (v *VoteOverlay) Vote() { - if len(v.selected) == 0 { - return - } - p, err := v.app.API.Vote(v.poll, v.selected...) - if err != nil { - v.app.UI.CmdBar.ShowError(fmt.Sprintf("Couldn't vote. Error: %v\n", err)) - return - } - v.app.UI.StatusView.RedrawPoll(p) - v.app.UI.StatusView.giveBackFocus() -} - -func (v *VoteOverlay) InputHandler(event *tcell.EventKey) { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'j', 'J': - v.Next() - case 'k', 'K': - v.Prev() - case 'v', 'V': - v.Vote() - case ' ': - v.ToggleSelect() - case 'q', 'Q': - v.app.UI.StatusView.giveBackFocus() - } - } else { - switch event.Key() { - case tcell.KeyEnter: - v.ToggleSelect() - case tcell.KeyUp: - v.Prev() - case tcell.KeyDown: - v.Next() - case tcell.KeyEsc: - v.app.UI.StatusView.giveBackFocus() - } - } -}