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() - } - } -}