Browse Source

Added notifications and nested timelines

pull/8/head 0.0.3
Rasmus Lindroth 6 years ago
parent
commit
17f6b02058
  1. 5
      README.md
  2. 192
      api.go
  3. 10
      config.go
  4. 948
      feed.go
  5. 4
      linkoverlay.go
  6. 105
      main.go
  7. 8
      media.go
  8. 9
      messagebox.go
  9. 12
      paneview.go
  10. 268
      statusview.go
  11. 257
      tootlist.go
  12. 167
      tootview.go
  13. 168
      ui.go
  14. 23
      util.go

5
README.md

@ -10,7 +10,8 @@ You can find Linux binaries under [releases](https://github.com/RasmusLindroth/t
### Currently supported commands
* `:q` `:quit` exit
* `:timeline` home, local, federated, direct
* `:timeline` home, local, federated, direct, notifications
* `:tl` h, l, f, d, n (a shorter form of the former)
Explanation of the non obvious keys when viewing a toot
* `V` = view. In this mode you can scroll throught the text of the toot if it doesn't fit the screen
@ -45,11 +46,9 @@ you will have to add `go/bin` to your `$PATH`.
### On my TODO-list:
* Support for config files (theme, default image/video viewer)
* Multiple accounts
* View users profiles
* Support search
* Support tags
* Support lists
* Notifications
* Better error handling (in other words, don't crash the whole program)
### Thanks to

192
api.go

@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"github.com/mattn/go-mastodon"
)
@ -98,27 +99,194 @@ func (api *API) GetThread(s *mastodon.Status) ([]*mastodon.Status, int, error) {
return thread, len(cont.Ancestors), nil
}
func (api *API) Boost(s *mastodon.Status) error {
_, err := api.Client.Reblog(context.Background(), s.ID)
return err
func (api *API) GetUserStatuses(u mastodon.Account) ([]*mastodon.Status, error) {
return api.Client.GetAccountStatuses(context.Background(), u.ID, nil)
}
func (api *API) Unboost(s *mastodon.Status) error {
_, err := api.Client.Unreblog(context.Background(), s.ID)
return err
func (api *API) GetUserStatusesOlder(u mastodon.Account, s *mastodon.Status) ([]*mastodon.Status, bool, error) {
pg := &mastodon.Pagination{
MaxID: s.ID,
}
statuses, err := api.Client.GetAccountStatuses(context.Background(), u.ID, pg)
if err != nil {
return statuses, false, err
}
if pg.MinID == "" {
return statuses, false, err
}
return statuses, true, err
}
func (api *API) Favorite(s *mastodon.Status) error {
_, err := api.Client.Favourite(context.Background(), s.ID)
return err
func (api *API) GetUserStatusesNewer(u mastodon.Account, s *mastodon.Status) ([]*mastodon.Status, bool, error) {
pg := &mastodon.Pagination{
MinID: s.ID,
}
statuses, err := api.Client.GetAccountStatuses(context.Background(), u.ID, pg)
if err != nil {
return statuses, false, err
}
if pg.MaxID == "" {
return statuses, false, err
}
return statuses, true, err
}
func (api *API) Unfavorite(s *mastodon.Status) error {
_, err := api.Client.Unfavourite(context.Background(), s.ID)
return err
func (api *API) GetNotifications() ([]*mastodon.Notification, error) {
return api.Client.GetNotifications(context.Background(), nil)
}
func (api *API) GetNotificationsOlder(n *mastodon.Notification) ([]*mastodon.Notification, bool, error) {
pg := &mastodon.Pagination{
MaxID: n.ID,
}
statuses, err := api.Client.GetNotifications(context.Background(), pg)
if err != nil {
return statuses, false, err
}
if pg.MinID == "" {
return statuses, false, err
}
return statuses, true, err
}
func (api *API) GetNotificationsNewer(n *mastodon.Notification) ([]*mastodon.Notification, bool, error) {
pg := &mastodon.Pagination{
MinID: n.ID,
}
statuses, err := api.Client.GetNotifications(context.Background(), pg)
if err != nil {
return statuses, false, err
}
if pg.MaxID == "" {
return statuses, false, err
}
return statuses, true, err
}
func (api *API) BoostToggle(s *mastodon.Status) (*mastodon.Status, error) {
if s == nil {
return nil, fmt.Errorf("No status")
}
if s.Reblogged == true {
return api.Unboost(s)
}
return api.Boost(s)
}
func (api *API) Boost(s *mastodon.Status) (*mastodon.Status, error) {
status, err := api.Client.Reblog(context.Background(), s.ID)
return status, err
}
func (api *API) Unboost(s *mastodon.Status) (*mastodon.Status, error) {
status, err := api.Client.Unreblog(context.Background(), s.ID)
return status, err
}
func (api *API) FavoriteToogle(s *mastodon.Status) (*mastodon.Status, error) {
if s == nil {
return nil, fmt.Errorf("No status")
}
if s.Favourited == true {
return api.Unfavorite(s)
}
return api.Favorite(s)
}
func (api *API) Favorite(s *mastodon.Status) (*mastodon.Status, error) {
status, err := api.Client.Favourite(context.Background(), s.ID)
return status, err
}
func (api *API) Unfavorite(s *mastodon.Status) (*mastodon.Status, error) {
status, err := api.Client.Unfavourite(context.Background(), s.ID)
return status, err
}
func (api *API) DeleteStatus(s *mastodon.Status) error {
//TODO: check user here?
return api.Client.DeleteStatus(context.Background(), s.ID)
}
func (api *API) UserRelation(u mastodon.Account) (*mastodon.Relationship, error) {
relations, err := api.Client.GetAccountRelationships(context.Background(), []string{string(u.ID)})
if err != nil {
return nil, err
}
if len(relations) == 0 {
return nil, fmt.Errorf("no accounts found")
}
return relations[0], nil
}
func (api *API) FollowToggle(u mastodon.Account) (*mastodon.Relationship, error) {
relation, err := api.UserRelation(u)
if err != nil {
return nil, err
}
if relation.Following {
return api.UnfollowUser(u)
}
return api.FollowUser(u)
}
func (api *API) FollowUser(u mastodon.Account) (*mastodon.Relationship, error) {
return api.Client.AccountFollow(context.Background(), u.ID)
}
func (api *API) UnfollowUser(u mastodon.Account) (*mastodon.Relationship, error) {
return api.Client.AccountUnfollow(context.Background(), u.ID)
}
func (api *API) BlockToggle(u mastodon.Account) (*mastodon.Relationship, error) {
relation, err := api.UserRelation(u)
if err != nil {
return nil, err
}
if relation.Blocking {
return api.UnblockUser(u)
}
return api.BlockUser(u)
}
func (api *API) BlockUser(u mastodon.Account) (*mastodon.Relationship, error) {
return api.Client.AccountBlock(context.Background(), u.ID)
}
func (api *API) UnblockUser(u mastodon.Account) (*mastodon.Relationship, error) {
return api.Client.AccountUnblock(context.Background(), u.ID)
}
func (api *API) MuteToggle(u mastodon.Account) (*mastodon.Relationship, error) {
relation, err := api.UserRelation(u)
if err != nil {
return nil, err
}
if relation.Blocking {
return api.UnmuteUser(u)
}
return api.MuteUser(u)
}
func (api *API) MuteUser(u mastodon.Account) (*mastodon.Relationship, error) {
return api.Client.AccountMute(context.Background(), u.ID)
}
func (api *API) UnmuteUser(u mastodon.Account) (*mastodon.Relationship, error) {
return api.Client.AccountUnmute(context.Background(), u.ID)
}

10
config.go

@ -8,8 +8,14 @@ import (
)
type Config struct {
Style StyleConfig
Media MediaConfig
General GeneralConfig
Style StyleConfig
Media MediaConfig
}
type GeneralConfig struct {
DateTodayFormat string
DateFormat string
}
type StyleConfig struct {

948
feed.go

@ -0,0 +1,948 @@
package main
import (
"fmt"
"log"
"strings"
"time"
"github.com/gdamore/tcell"
"github.com/mattn/go-mastodon"
"github.com/rivo/tview"
)
type FeedType uint
const (
TimelineFeed FeedType = iota
ThreadFeed
UserFeed
NotificationFeed
)
type Feed interface {
GetFeedList() <-chan string
LoadNewer() int
LoadOlder() int
DrawList()
DrawToot()
FeedType() FeedType
GetSavedIndex() int
Input(event *tcell.EventKey)
}
func showTootOptions(app *App, status *mastodon.Status, showSensitive bool) (string, string) {
var line string
width := app.UI.StatusView.GetTextWidth()
for i := 0; i < width; i++ {
line += "-"
}
line += "\n"
shouldDisplay := !status.Sensitive || showSensitive
var stripped string
var urls []URL
var u []URL
if status.Sensitive && !showSensitive {
stripped, u = cleanTootHTML(status.SpoilerText)
urls = append(urls, u...)
stripped += "\n" + line
stripped += "Press [s] to show hidden text"
} else {
stripped, u = cleanTootHTML(status.Content)
urls = append(urls, u...)
if status.Sensitive {
sens, u := cleanTootHTML(status.SpoilerText)
urls = append(urls, u...)
stripped = sens + "\n\n" + stripped
}
}
app.UI.LinkOverlay.SetURLs(urls)
subtleColor := fmt.Sprintf("[#%x]", app.Config.Style.Subtle.Hex())
special1 := fmt.Sprintf("[#%x]", app.Config.Style.TextSpecial1.Hex())
special2 := fmt.Sprintf("[#%x]", app.Config.Style.TextSpecial2.Hex())
var head string
if status.Reblog != nil {
if status.Account.DisplayName != "" {
head += fmt.Sprintf(subtleColor+"%s (%s)\n", status.Account.DisplayName, status.Account.Acct)
} else {
head += fmt.Sprintf(subtleColor+"%s\n", status.Account.Acct)
}
head += subtleColor + "Boosted\n"
head += subtleColor + line
status = status.Reblog
}
if status.Account.DisplayName != "" {
head += fmt.Sprintf(special2+"%s\n", status.Account.DisplayName)
}
head += fmt.Sprintf(special1+"%s\n\n", status.Account.Acct)
output := head
content := tview.Escape(stripped)
if content != "" {
output += content + "\n\n"
}
var poll string
if status.Poll != nil {
poll += subtleColor + "Poll\n"
poll += subtleColor + line
poll += fmt.Sprintf("Number of votes: %d\n\n", status.Poll.VotesCount)
votes := float64(status.Poll.VotesCount)
for _, o := range status.Poll.Options {
res := 0.0
if votes != 0 {
res = float64(o.VotesCount) / votes * 100
}
poll += fmt.Sprintf("%s - %.2f%% (%d)\n", tview.Escape(o.Title), res, o.VotesCount)
}
poll += "\n"
}
var media string
for _, att := range status.MediaAttachments {
media += subtleColor + line
media += fmt.Sprintf(subtleColor+"Attached %s\n", att.Type)
media += fmt.Sprintf("%s\n", att.URL)
}
if len(status.MediaAttachments) > 0 {
media += "\n"
}
var card string
if status.Card != nil {
card += subtleColor + "Card type: " + status.Card.Type + "\n"
card += subtleColor + line
if status.Card.Title != "" {
card += status.Card.Title + "\n\n"
}
desc := strings.TrimSpace(status.Card.Description)
if desc != "" {
card += desc + "\n\n"
}
card += status.Card.URL
}
if shouldDisplay {
output += poll + media + card
}
app.UI.StatusView.ScrollToBeginning()
var info []string
if status.Favourited == true {
info = append(info, ColorKey(app.Config.Style, "Un", "F", "avorite"))
} else {
info = append(info, ColorKey(app.Config.Style, "", "F", "avorite"))
}
if status.Reblogged == true {
info = append(info, ColorKey(app.Config.Style, "Un", "B", "boost"))
} else {
info = append(info, ColorKey(app.Config.Style, "", "B", "boost"))
}
info = append(info, ColorKey(app.Config.Style, "", "T", "hread"))
info = append(info, ColorKey(app.Config.Style, "", "R", "eply"))
info = append(info, ColorKey(app.Config.Style, "", "V", "iew"))
info = append(info, ColorKey(app.Config.Style, "", "U", "ser"))
if len(status.MediaAttachments) > 0 {
info = append(info, ColorKey(app.Config.Style, "", "M", "edia"))
}
if len(urls) > 0 {
info = append(info, ColorKey(app.Config.Style, "", "O", "pen"))
}
if status.Account.ID == app.Me.ID {
info = append(info, ColorKey(app.Config.Style, "", "D", "elete"))
}
controls := strings.Join(info, " ")
return output, controls
}
func drawStatusList(statuses []*mastodon.Status) <-chan string {
ch := make(chan string)
go func() {
today := time.Now()
ty, tm, td := today.Date()
for _, s := range statuses {
sLocal := s.CreatedAt.Local()
sy, sm, sd := sLocal.Date()
format := "2006-01-02 15:04"
if ty == sy && tm == sm && td == sd {
format = "15:04"
}
content := fmt.Sprintf("%s %s", sLocal.Format(format), s.Account.Acct)
ch <- content
}
close(ch)
}()
return ch
}
func NewTimeline(app *App, tl TimelineType) *Timeline {
t := &Timeline{
app: app,
timelineType: tl,
}
t.statuses, _ = t.app.API.GetStatuses(t.timelineType)
return t
}
type Timeline struct {
app *App
timelineType TimelineType
statuses []*mastodon.Status
index int
showSpoiler bool
}
func (t *Timeline) FeedType() FeedType {
return TimelineFeed
}
func (t *Timeline) GetCurrentStatus() *mastodon.Status {
index := t.app.UI.StatusView.GetCurrentItem()
if index >= len(t.statuses) {
return nil
}
return t.statuses[t.app.UI.StatusView.GetCurrentItem()]
}
func (t *Timeline) GetFeedList() <-chan string {
return drawStatusList(t.statuses)
}
func (t *Timeline) LoadNewer() int {
var statuses []*mastodon.Status
var err error
if len(t.statuses) == 0 {
statuses, err = t.app.API.GetStatuses(t.timelineType)
} else {
statuses, _, err = t.app.API.GetStatusesNewer(t.timelineType, t.statuses[0])
}
if err != nil {
log.Fatalln(err)
}
if len(statuses) == 0 {
return 0
}
old := t.statuses
t.statuses = append(statuses, old...)
return len(statuses)
}
func (t *Timeline) LoadOlder() int {
var statuses []*mastodon.Status
var err error
if len(t.statuses) == 0 {
statuses, err = t.app.API.GetStatuses(t.timelineType)
} else {
statuses, _, err = t.app.API.GetStatusesOlder(t.timelineType, t.statuses[len(t.statuses)-1])
}
if err != nil {
log.Fatalln(err)
}
if len(statuses) == 0 {
return 0
}
t.statuses = append(t.statuses, statuses...)
return len(statuses)
}
func (t *Timeline) DrawList() {
t.app.UI.StatusView.SetList(t.GetFeedList())
}
func (t *Timeline) DrawToot() {
if len(t.statuses) == 0 {
t.app.UI.StatusView.SetText("")
t.app.UI.StatusView.SetControls("")
return
}
t.index = t.app.UI.StatusView.GetCurrentItem()
text, controls := showTootOptions(t.app, t.statuses[t.index], t.showSpoiler)
t.showSpoiler = false
t.app.UI.StatusView.SetText(text)
t.app.UI.StatusView.SetControls(controls)
}
func (t *Timeline) redrawControls() {
status := t.GetCurrentStatus()
if status == nil {
return
}
_, controls := showTootOptions(t.app, status, t.showSpoiler)
t.app.UI.StatusView.SetControls(controls)
}
func (t *Timeline) GetSavedIndex() int {
return t.index
}
func (t *Timeline) Input(event *tcell.EventKey) {
status := t.GetCurrentStatus()
if status == nil {
return
}
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 't', 'T':
t.app.UI.StatusView.AddFeed(
NewThread(t.app, status),
)
case 'u', 'U':
t.app.UI.StatusView.AddFeed(
NewUser(t.app, status.Account),
)
case 's', 'S':
t.showSpoiler = true
t.DrawToot()
case 'c', 'C':
t.app.UI.NewToot()
case 'o', 'O':
t.app.UI.ShowLinks()
case 'r', 'R':
t.app.UI.Reply(status)
case 'm', 'M':
t.app.UI.OpenMedia(status)
case 'f', 'F':
index := t.app.UI.StatusView.GetCurrentItem()
newStatus, err := t.app.API.FavoriteToogle(status)
if err != nil {
log.Fatalln(err)
}
t.statuses[index] = newStatus
t.redrawControls()
case 'b', 'B':
index := t.app.UI.StatusView.GetCurrentItem()
newStatus, err := t.app.API.BoostToggle(status)
if err != nil {
log.Fatalln(err)
}
t.statuses[index] = newStatus
t.redrawControls()
case 'd', 'D':
t.app.API.DeleteStatus(status)
}
}
}
func NewThread(app *App, s *mastodon.Status) *Thread {
t := &Thread{
app: app,
}
statuses, index, err := t.app.API.GetThread(s)
if err != nil {
log.Fatalln(err)
}
t.statuses = statuses
t.status = s
t.index = index
return t
}
type Thread struct {
app *App
statuses []*mastodon.Status
status *mastodon.Status
index int
showSpoiler bool
}
func (t *Thread) FeedType() FeedType {
return ThreadFeed
}
func (t *Thread) GetCurrentStatus() *mastodon.Status {
index := t.app.UI.StatusView.GetCurrentItem()
if index >= len(t.statuses) {
return nil
}
return t.statuses[t.app.UI.StatusView.GetCurrentItem()]
}
func (t *Thread) GetFeedList() <-chan string {
return drawStatusList(t.statuses)
}
func (t *Thread) LoadNewer() int {
return 0
}
func (t *Thread) LoadOlder() int {
return 0
}
func (t *Thread) DrawList() {
t.app.UI.StatusView.SetList(t.GetFeedList())
}
func (t *Thread) DrawToot() {
status := t.GetCurrentStatus()
if status == nil {
t.app.UI.StatusView.SetText("")
t.app.UI.StatusView.SetControls("")
return
}
t.index = t.app.UI.StatusView.GetCurrentItem()
text, controls := showTootOptions(t.app, status, t.showSpoiler)
t.showSpoiler = false
t.app.UI.StatusView.SetText(text)
t.app.UI.StatusView.SetControls(controls)
}
func (t *Thread) redrawControls() {
status := t.GetCurrentStatus()
if status == nil {
t.app.UI.StatusView.SetText("")
t.app.UI.StatusView.SetControls("")
return
}
_, controls := showTootOptions(t.app, status, t.showSpoiler)
t.app.UI.StatusView.SetControls(controls)
}
func (t *Thread) GetSavedIndex() int {
return t.index
}
func (t *Thread) Input(event *tcell.EventKey) {
status := t.GetCurrentStatus()
if status == nil {
return
}
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 't', 'T':
if t.status.ID != status.ID {
t.app.UI.StatusView.AddFeed(
NewThread(t.app, status),
)
}
case 'u', 'U':
t.app.UI.StatusView.AddFeed(
NewUser(t.app, status.Account),
)
case 's', 'S':
t.showSpoiler = true
t.DrawToot()
case 'c', 'C':
t.app.UI.NewToot()
case 'o', 'O':
t.app.UI.ShowLinks()
case 'r', 'R':
t.app.UI.Reply(status)
case 'm', 'M':
t.app.UI.OpenMedia(status)
case 'f', 'F':
index := t.app.UI.StatusView.GetCurrentItem()
newStatus, err := t.app.API.FavoriteToogle(status)
if err != nil {
log.Fatalln(err)
}
t.statuses[index] = newStatus
t.redrawControls()
case 'b', 'B':
index := t.app.UI.StatusView.GetCurrentItem()
newStatus, err := t.app.API.BoostToggle(status)
if err != nil {
log.Fatalln(err)
}
t.statuses[index] = newStatus
t.redrawControls()
case 'd', 'D':
t.app.API.DeleteStatus(status)
}
}
}
func NewUser(app *App, a mastodon.Account) *User {
u := &User{
app: app,
}
statuses, err := app.API.GetUserStatuses(a)
if err != nil {
log.Fatalln(err)
}
u.statuses = statuses
relation, err := app.API.UserRelation(a)
if err != nil {
log.Fatalln(err)
}
u.relation = relation
u.user = a
return u
}
type User struct {
app *App
statuses []*mastodon.Status
user mastodon.Account
relation *mastodon.Relationship
index int
showSpoiler bool
}
func (u *User) FeedType() FeedType {
return UserFeed
}
func (u *User) GetCurrentStatus() *mastodon.Status {
index := u.app.UI.app.UI.StatusView.GetCurrentItem()
if index > 0 && index-1 >= len(u.statuses) {
return nil
}
return u.statuses[index-1]
}
func (u *User) GetFeedList() <-chan string {
ch := make(chan string)
go func() {
ch <- "Profile"
for s := range drawStatusList(u.statuses) {
ch <- s
}
close(ch)
}()
return ch
}
func (u *User) LoadNewer() int {
var statuses []*mastodon.Status
var err error
if len(u.statuses) == 0 {
statuses, err = u.app.API.GetUserStatuses(u.user)
} else {
statuses, _, err = u.app.API.GetUserStatusesNewer(u.user, u.statuses[0])
}
if err != nil {
log.Fatalln(err)
}
if len(statuses) == 0 {
return 0
}
old := u.statuses
u.statuses = append(statuses, old...)
return len(statuses)
}
func (u *User) LoadOlder() int {
var statuses []*mastodon.Status
var err error
if len(u.statuses) == 0 {
statuses, err = u.app.API.GetUserStatuses(u.user)
} else {
statuses, _, err = u.app.API.GetUserStatusesOlder(u.user, u.statuses[len(u.statuses)-1])
}
if err != nil {
log.Fatalln(err)
}
if len(statuses) == 0 {
return 0
}
u.statuses = append(u.statuses, statuses...)
return len(statuses)
}
func (u *User) DrawList() {
u.app.UI.StatusView.SetList(u.GetFeedList())
}
func (u *User) DrawToot() {
u.index = u.app.UI.StatusView.GetCurrentItem()
var text string
var controls string
if u.index == 0 {
n := fmt.Sprintf("[#%x]", u.app.Config.Style.Text.Hex())
s1 := fmt.Sprintf("[#%x]", u.app.Config.Style.TextSpecial1.Hex())
s2 := fmt.Sprintf("[#%x]", u.app.Config.Style.TextSpecial2.Hex())
if u.user.DisplayName != "" {
text = fmt.Sprintf(s2+"%s\n", u.user.DisplayName)
}
text += fmt.Sprintf(s1+"%s\n\n", u.user.Acct)
text += fmt.Sprintf("Toots %s%d %sFollowers %s%d %sFollowing %s%d\n\n",
s2, u.user.StatusesCount, n, s2, u.user.FollowersCount, n, s2, u.user.FollowingCount)
note, urls := cleanTootHTML(u.user.Note)
text += note + "\n\n"
for _, f := range u.user.Fields {
value, fu := cleanTootHTML(f.Value)
text += fmt.Sprintf("%s%s: %s%s\n", s2, f.Name, n, value)
urls = append(urls, fu...)
}
u.app.UI.LinkOverlay.SetURLs(urls)
var controlItems []string
if u.app.Me.ID != u.user.ID {
if u.relation.Following {
controlItems = append(controlItems, ColorKey(u.app.Config.Style, "Un", "F", "ollow"))
} else {
controlItems = append(controlItems, ColorKey(u.app.Config.Style, "", "F", "ollow"))
}
if u.relation.Blocking {
controlItems = append(controlItems, ColorKey(u.app.Config.Style, "Un", "B", "lock"))
} else {
controlItems = append(controlItems, ColorKey(u.app.Config.Style, "", "B", "lock"))
}
if u.relation.Muting {
controlItems = append(controlItems, ColorKey(u.app.Config.Style, "Un", "M", "ute"))
} else {
controlItems = append(controlItems, ColorKey(u.app.Config.Style, "", "M", "ute"))
}
if len(urls) > 0 {
controlItems = append(controlItems, ColorKey(u.app.Config.Style, "", "O", "pen"))
}
controls = strings.Join(controlItems, " ")
}
} else {
status := u.GetCurrentStatus()
if status == nil {
text = ""
controls = ""
} else {
text, controls = showTootOptions(u.app, status, u.showSpoiler)
}
u.showSpoiler = false
}
u.app.UI.StatusView.SetText(text)
u.app.UI.StatusView.SetControls(controls)
}
func (u *User) redrawControls() {
var controls string
status := u.GetCurrentStatus()
if status == nil {
controls = ""
} else {
_, controls = showTootOptions(u.app, status, u.showSpoiler)
}
u.app.UI.StatusView.SetControls(controls)
}
func (u *User) GetSavedIndex() int {
return u.index
}
func (u *User) Input(event *tcell.EventKey) {
index := u.GetSavedIndex()
if index == 0 {
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 'f', 'F':
var relation *mastodon.Relationship
var err error
if u.relation.Following {
relation, err = u.app.API.UnfollowUser(u.user)
} else {
relation, err = u.app.API.FollowUser(u.user)
}
if err != nil {
log.Fatalln(err)
}
u.relation = relation
u.DrawToot()
case 'b', 'B':
var relation *mastodon.Relationship
var err error
if u.relation.Blocking {
relation, err = u.app.API.UnblockUser(u.user)
} else {
relation, err = u.app.API.BlockUser(u.user)
}
if err != nil {
log.Fatalln(err)
}
u.relation = relation
u.DrawToot()
case 'm', 'M':
var relation *mastodon.Relationship
var err error
if u.relation.Muting {
relation, err = u.app.API.UnmuteUser(u.user)
} else {
relation, err = u.app.API.MuteUser(u.user)
}
if err != nil {
log.Fatalln(err)
}
u.relation = relation
u.DrawToot()
case 'r', 'R':
//toots and replies?
case 'o', 'O':
u.app.UI.ShowLinks()
}
}
return
}
if event.Key() == tcell.KeyRune {
status := u.GetCurrentStatus()
if status == nil {
return
}
switch event.Rune() {
case 't', 'T':
u.app.UI.StatusView.AddFeed(
NewThread(u.app, status),
)
case 'u', 'U':
if u.user.ID != status.Account.ID {
u.app.UI.StatusView.AddFeed(
NewUser(u.app, status.Account),
)
}
case 's', 'S':
u.showSpoiler = true
u.DrawToot()
case 'c', 'C':
u.app.UI.NewToot()
case 'o', 'O':
u.app.UI.ShowLinks()
case 'r', 'R':
u.app.UI.Reply(status)
case 'm', 'M':
u.app.UI.OpenMedia(status)
case 'f', 'F':
index := u.app.UI.StatusView.GetCurrentItem()
newStatus, err := u.app.API.FavoriteToogle(status)
if err != nil {
log.Fatalln(err)
}
u.statuses[index-1] = newStatus
u.redrawControls()
case 'b', 'B':
index := u.app.UI.StatusView.GetCurrentItem()
newStatus, err := u.app.API.BoostToggle(status)
if err != nil {
log.Fatalln(err)
}
u.statuses[index-1] = newStatus
u.redrawControls()
case 'd', 'D':
u.app.API.DeleteStatus(status)
}
}
}
func NewNoticifations(app *App) *Notifications {
n := &Notifications{
app: app,
}
n.notifications, _ = n.app.API.GetNotifications()
return n
}
type Notifications struct {
app *App
timelineType TimelineType
notifications []*mastodon.Notification
index int
showSpoiler bool
}
func (n *Notifications) FeedType() FeedType {
return NotificationFeed
}
func (n *Notifications) GetCurrentNotification() *mastodon.Notification {
index := n.app.UI.StatusView.GetCurrentItem()
if index >= len(n.notifications) {
return nil
}
return n.notifications[index]
}
func (n *Notifications) GetFeedList() <-chan string {
ch := make(chan string)
notifications := n.notifications
go func() {
today := time.Now()
ty, tm, td := today.Date()
for _, item := range notifications {
sLocal := item.CreatedAt.Local()
sy, sm, sd := sLocal.Date()
format := "2006-01-02 15:04"
if ty == sy && tm == sm && td == sd {
format = "15:04"
}
content := fmt.Sprintf("%s %s", sLocal.Format(format), item.Account.Acct)
ch <- content
}
close(ch)
}()
return ch
}
func (n *Notifications) LoadNewer() int {
var notifications []*mastodon.Notification
var err error
if len(n.notifications) == 0 {
notifications, err = n.app.API.GetNotifications()
} else {
notifications, _, err = n.app.API.GetNotificationsNewer(n.notifications[0])
}
if err != nil {
log.Fatalln(err)
}
if len(notifications) == 0 {
return 0
}
old := n.notifications
n.notifications = append(notifications, old...)
return len(notifications)
}
func (n *Notifications) LoadOlder() int {
var notifications []*mastodon.Notification
var err error
if len(n.notifications) == 0 {
notifications, err = n.app.API.GetNotifications()
} else {
notifications, _, err = n.app.API.GetNotificationsOlder(n.notifications[len(n.notifications)-1])
}
if err != nil {
log.Fatalln(err)
}
if len(notifications) == 0 {
return 0
}
n.notifications = append(n.notifications, notifications...)
return len(notifications)
}
func (n *Notifications) DrawList() {
n.app.UI.StatusView.SetList(n.GetFeedList())
}
func (n *Notifications) DrawToot() {
n.index = n.app.UI.StatusView.GetCurrentItem()
notification := n.GetCurrentNotification()
if notification == nil {
n.app.UI.StatusView.SetText("")
n.app.UI.StatusView.SetControls("")
return
}
var text string
var controls string
defer func() { n.showSpoiler = false }()
switch notification.Type {
case "follow":
text = SublteText(n.app.Config.Style, FormatUsername(notification.Account)+" started following you\n\n")
controls = ColorKey(n.app.Config.Style, "", "U", "ser")
case "favourite":
pre := SublteText(n.app.Config.Style, FormatUsername(notification.Account)+" favorited your toot") + "\n\n"
text, controls = showTootOptions(n.app, notification.Status, n.showSpoiler)
text = pre + text
case "reblog":
pre := SublteText(n.app.Config.Style, FormatUsername(notification.Account)+" boosted your toot") + "\n\n"
text, controls = showTootOptions(n.app, notification.Status, n.showSpoiler)
text = pre + text
case "mention":
pre := SublteText(n.app.Config.Style, FormatUsername(notification.Account)+" mentioned you") + "\n\n"
text, controls = showTootOptions(n.app, notification.Status, n.showSpoiler)
text = pre + text
case "poll":
pre := SublteText(n.app.Config.Style, "A poll of yours or one you participated in has ended") + "\n\n"
text, controls = showTootOptions(n.app, notification.Status, n.showSpoiler)
text = pre + text
}
n.app.UI.StatusView.SetText(text)
n.app.UI.StatusView.SetControls(controls)
}
func (n *Notifications) redrawControls() {
notification := n.GetCurrentNotification()
if notification == nil {
n.app.UI.StatusView.SetControls("")
return
}
switch notification.Type {
case "favourite", "reblog", "mention", "poll":
_, controls := showTootOptions(n.app, notification.Status, n.showSpoiler)
n.app.UI.StatusView.SetControls(controls)
}
}
func (n *Notifications) GetSavedIndex() int {
return n.index
}
func (n *Notifications) Input(event *tcell.EventKey) {
notification := n.GetCurrentNotification()
if notification == nil {
return
}
if notification.Type == "follow" {
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 'u', 'U':
n.app.UI.StatusView.AddFeed(
NewUser(n.app, notification.Account),
)
}
}
return
}
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 't', 'T':
n.app.UI.StatusView.AddFeed(
NewThread(n.app, notification.Status),
)
case 'u', 'U':
n.app.UI.StatusView.AddFeed(
NewUser(n.app, notification.Account),
)
case 's', 'S':
n.showSpoiler = true
n.DrawToot()
case 'c', 'C':
n.app.UI.NewToot()
case 'o', 'O':
n.app.UI.ShowLinks()
case 'r', 'R':
n.app.UI.Reply(notification.Status)
case 'm', 'M':
n.app.UI.OpenMedia(notification.Status)
case 'f', 'F':
index := n.app.UI.StatusView.GetCurrentItem()
status, err := n.app.API.FavoriteToogle(notification.Status)
if err != nil {
log.Fatalln(err)
}
n.notifications[index].Status = status
n.redrawControls()
case 'b', 'B':
index := n.app.UI.StatusView.GetCurrentItem()
status, err := n.app.API.BoostToggle(notification.Status)
if err != nil {
log.Fatalln(err)
}
n.notifications[index].Status = status
n.redrawControls()
case 'd', 'D':
n.app.API.DeleteStatus(notification.Status)
}
}
}

4
linkoverlay.go

@ -14,6 +14,7 @@ func NewLinkOverlay(app *App) *LinkOverlay {
}
l.TextBottom.SetBackgroundColor(app.Config.Style.Background)
l.TextBottom.SetDynamicColors(true)
l.List.SetBackgroundColor(app.Config.Style.Background)
l.List.SetMainTextColor(app.Config.Style.Text)
l.List.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground)
@ -21,8 +22,7 @@ func NewLinkOverlay(app *App) *LinkOverlay {
l.List.ShowSecondaryText(false)
l.List.SetHighlightFullLine(true)
l.Flex.SetDrawFunc(app.Config.ClearContent)
l.TextBottom.SetText("[O]pen")
l.TextBottom.SetText(ColorKey(app.Config.Style, "", "O", "pen"))
return l
}

105
main.go

@ -1,6 +1,7 @@
package main
import (
"context"
"log"
"strings"
@ -39,7 +40,6 @@ func main() {
HaveAccount: false,
Config: &config,
}
app.UI = NewUI(app)
if exists {
accounts, err := GetAccounts(path)
@ -50,12 +50,21 @@ func main() {
a := accounts.Accounts[0]
client, err := a.Login()
if err == nil {
app.API.Client = client
app.API.SetClient(client)
app.HaveAccount = true
me, err := app.API.Client.GetAccountCurrentUser(context.Background())
if err != nil {
log.Fatalln(err)
}
app.Me = me
}
}
}
app.UI = NewUI(app)
app.UI.Init()
if !app.HaveAccount {
app.UI.SetFocus(AuthOverlayFocus)
} else {
@ -169,53 +178,6 @@ func main() {
return event
}
if app.UI.Focus == LeftPaneFocus {
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 'v', 'V':
app.UI.SetFocus(RightPaneFocus)
return nil
case 'k', 'K':
app.UI.TootList.Prev()
return nil
case 'j', 'J':
app.UI.TootList.Next()
return nil
case 'q', 'Q':
if app.UI.TootList.Focus == TootListThreadFocus {
app.UI.TootList.GoBack()
} else {
app.UI.Root.Stop()
}
return nil
}
} else {
switch event.Key() {
case tcell.KeyUp:
app.UI.TootList.Prev()
return nil
case tcell.KeyDown:
app.UI.TootList.Next()
return nil
case tcell.KeyEsc:
app.UI.TootList.GoBack()
return nil
case tcell.KeyCtrlC:
app.UI.Root.Stop()
return nil
}
}
}
if app.UI.Focus == RightPaneFocus {
if event.Key() != tcell.KeyRune {
switch event.Key() {
case tcell.KeyEsc:
app.UI.SetFocus(LeftPaneFocus)
}
}
}
if app.UI.Focus == LeftPaneFocus || app.UI.Focus == RightPaneFocus {
if event.Key() == tcell.KeyRune {
switch event.Rune() {
@ -223,28 +185,9 @@ func main() {
app.UI.CmdBar.Input.SetText(":")
app.UI.SetFocus(CmdBarFocus)
return nil
case 't', 'T':
app.UI.ShowThread()
case 's', 'S':
app.UI.ShowSensetive()
case 'c', 'C':
app.UI.NewToot()
case 'o', 'O':
app.UI.ShowLinks()
case 'r', 'R':
app.UI.Reply()
case 'm', 'M':
app.UI.OpenMedia()
case 'f', 'F':
//TODO UPDATE TOOT IN LIST
app.UI.FavoriteEvent()
case 'b':
//TODO UPDATE TOOT IN LIST
app.UI.BoostEvent()
case 'd':
app.UI.DeleteStatus()
}
}
return app.UI.StatusView.Input(event)
}
return event
@ -254,7 +197,7 @@ func main() {
app.UI.MediaOverlay.InputField.HandleChanges,
)
words := strings.Split(":q,:quit,:timeline", ",")
words := strings.Split(":q,:quit,:timeline,:tl", ",")
app.UI.CmdBar.Input.SetAutocompleteFunc(func(currentText string) (entries []string) {
if currentText == "" {
return
@ -281,25 +224,29 @@ func main() {
fallthrough
case ":quit":
app.UI.Root.Stop()
case ":timeline":
case ":timeline", ":tl":
if len(parts) < 2 {
break
}
switch parts[1] {
case "local":
app.UI.SetTimeline(TimelineLocal)
case "local", "l":
app.UI.StatusView.AddFeed(NewTimeline(app, TimelineLocal))
app.UI.SetFocus(LeftPaneFocus)
app.UI.CmdBar.ClearInput()
case "federated", "f":
app.UI.StatusView.AddFeed(NewTimeline(app, TimelineFederated))
app.UI.SetFocus(LeftPaneFocus)
app.UI.CmdBar.ClearInput()
case "federated":
app.UI.SetTimeline(TimelineFederated)
case "direct", "d":
app.UI.StatusView.AddFeed(NewTimeline(app, TimelineDirect))
app.UI.SetFocus(LeftPaneFocus)
app.UI.CmdBar.ClearInput()
case "direct":
app.UI.SetTimeline(TimelineDirect)
case "home", "h":
app.UI.StatusView.AddFeed(NewTimeline(app, TimelineHome))
app.UI.SetFocus(LeftPaneFocus)
app.UI.CmdBar.ClearInput()
case "home":
app.UI.SetTimeline(TimelineHome)
case "notifications", "n":
app.UI.StatusView.AddFeed(NewNoticifations(app))
app.UI.SetFocus(LeftPaneFocus)
app.UI.CmdBar.ClearInput()
}

8
media.go

@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"github.com/rivo/tview"
)
@ -38,6 +39,7 @@ func NewMediaOverlay(app *App) *MediaView {
m.TextBottom.SetBackgroundColor(app.Config.Style.Background)
m.TextBottom.SetTextColor(app.Config.Style.Text)
m.TextBottom.SetDynamicColors(true)
m.InputField.View.SetBackgroundColor(app.Config.Style.Background)
m.InputField.View.SetFieldBackgroundColor(app.Config.Style.Background)
@ -75,7 +77,11 @@ func (m *MediaView) AddFile(f string) {
func (m *MediaView) Draw() {
m.TextTop.SetText("List of attached files:")
m.TextBottom.SetText("[A]dd file [D]elete file [Esc] Done")
var items []string
items = append(items, ColorKey(m.app.Config.Style, "", "A", "dd file"))
items = append(items, ColorKey(m.app.Config.Style, "", "D", "elete file"))
items = append(items, ColorKey(m.app.Config.Style, "", "Esc", " Done"))
m.TextBottom.SetText(strings.Join(items, " "))
}
func (m *MediaView) SetFocus(f MediaFocus) {

9
messagebox.go

@ -121,8 +121,13 @@ func (m *MessageBox) Post() {
}
func (m *MessageBox) Draw() {
info := "\n[P]ost [E]dit text, [T]oggle CW, [C]ontent warning text [M]edia attachment"
status := tview.Escape(info)
var items []string
items = append(items, ColorKey(m.app.Config.Style, "", "P", "ost"))
items = append(items, ColorKey(m.app.Config.Style, "", "E", "dit"))
items = append(items, ColorKey(m.app.Config.Style, "", "T", "oggle CW"))
items = append(items, ColorKey(m.app.Config.Style, "", "C", "ontent warning text"))
items = append(items, ColorKey(m.app.Config.Style, "", "M", "edia attachment"))
status := strings.Join(items, " ")
m.Controls.SetText(status)
var outputHead string

12
paneview.go

@ -0,0 +1,12 @@
package main
import (
"github.com/gdamore/tcell"
"github.com/rivo/tview"
)
type PaneView interface {
GetLeftView() tview.Primitive
GetRightView() tview.Primitive
Input(event *tcell.EventKey) *tcell.EventKey
}

268
statusview.go

@ -0,0 +1,268 @@
package main
import (
"github.com/gdamore/tcell"
"github.com/rivo/tview"
)
func NewStatusView(app *App, tl TimelineType) *StatusView {
t := &StatusView{
app: app,
timelineType: tl,
list: tview.NewList(),
text: tview.NewTextView(),
controls: tview.NewTextView(),
focus: LeftPaneFocus,
loadingNewer: false,
loadingOlder: false,
}
t.flex = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(t.text, 0, 9, false).
AddItem(t.controls, 1, 0, false)
t.list.SetBackgroundColor(app.Config.Style.Background)
t.list.SetSelectedTextColor(app.Config.Style.ListSelectedText)
t.list.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground)
t.list.ShowSecondaryText(false)
t.list.SetHighlightFullLine(true)
t.list.SetChangedFunc(func(i int, _ string, _ string, _ rune) {
if app.HaveAccount {
t.showToot(i)
}
})
t.text.SetWordWrap(true).SetDynamicColors(true)
t.text.SetBackgroundColor(app.Config.Style.Background)
t.text.SetTextColor(app.Config.Style.Text)
t.controls.SetDynamicColors(true)
t.controls.SetBackgroundColor(app.Config.Style.Background)
return t
}
type StatusView struct {
app *App
timelineType TimelineType
list *tview.List
flex *tview.Flex
text *tview.TextView
controls *tview.TextView
feeds []Feed
focus FocusAt
loadingNewer bool
loadingOlder bool
}
func (t *StatusView) AddFeed(f Feed) {
t.feeds = append(t.feeds, f)
f.DrawList()
t.list.SetCurrentItem(f.GetSavedIndex())
f.DrawToot()
}
func (t *StatusView) RemoveLatestFeed() {
t.feeds = t.feeds[:len(t.feeds)-1]
feed := t.feeds[len(t.feeds)-1]
feed.DrawList()
t.list.SetCurrentItem(feed.GetSavedIndex())
feed.DrawToot()
}
func (t *StatusView) GetLeftView() tview.Primitive {
if len(t.feeds) > 0 {
feed := t.feeds[len(t.feeds)-1]
feed.DrawList()
feed.DrawToot()
}
return t.list
}
func (t *StatusView) GetRightView() tview.Primitive {
return t.flex
}
func (t *StatusView) GetTextWidth() int {
_, _, width, _ := t.text.GetInnerRect()
return width
}
func (t *StatusView) GetCurrentItem() int {
return t.list.GetCurrentItem()
}
func (t *StatusView) ScrollToBeginning() {
t.text.ScrollToBeginning()
}
func (t *StatusView) inputBoth(event *tcell.EventKey) {
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 'q', 'Q':
if len(t.feeds) > 1 {
t.RemoveLatestFeed()
} else {
t.app.UI.Root.Stop()
}
}
} else {
switch event.Key() {
case tcell.KeyCtrlC:
t.app.UI.Root.Stop()
}
}
if len(t.feeds) > 0 {
feed := t.feeds[len(t.feeds)-1]
feed.Input(event)
}
}
func (t *StatusView) inputLeft(event *tcell.EventKey) {
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 'v', 'V':
t.app.UI.FocusAt(t.text, "--VIEW--")
t.focus = RightPaneFocus
case 'k', 'K':
t.prev()
case 'j', 'J':
t.next()
}
} else {
switch event.Key() {
case tcell.KeyUp:
t.prev()
case tcell.KeyDown:
t.next()
case tcell.KeyEsc:
if len(t.feeds) > 1 {
t.RemoveLatestFeed()
}
}
}
}
func (t *StatusView) inputRight(event *tcell.EventKey) {
if event.Key() == tcell.KeyRune {
switch event.Rune() {
}
} else {
switch event.Key() {
case tcell.KeyEsc:
t.app.UI.FocusAt(nil, "--LIST--")
t.focus = LeftPaneFocus
}
}
}
func (t *StatusView) Input(event *tcell.EventKey) *tcell.EventKey {
t.inputBoth(event)
if len(t.feeds) == 0 {
return event
}
if t.focus == LeftPaneFocus {
t.inputLeft(event)
return nil
} else {
t.inputRight(event)
}
return event
}
func (t *StatusView) SetList(items <-chan string) {
t.list.Clear()
for s := range items {
t.list.AddItem(s, "", 0, nil)
}
}
func (t *StatusView) SetText(text string) {
t.text.SetText(text)
}
func (t *StatusView) SetControls(text string) {
t.controls.SetText(text)
}
func (t *StatusView) showToot(index int) {
}
func (t *StatusView) showTootOptions(index int, showSensitive bool) {
}
func (t *StatusView) prev() {
current := t.list.GetCurrentItem()
if current-1 >= 0 {
current--
}
t.list.SetCurrentItem(current)
t.feeds[len(t.feeds)-1].DrawToot()
if current < 4 {
t.loadNewer()
}
}
func (t *StatusView) next() {
t.list.SetCurrentItem(
t.list.GetCurrentItem() + 1,
)
t.feeds[len(t.feeds)-1].DrawToot()
count := t.list.GetItemCount()
current := t.list.GetCurrentItem()
if (count - current + 1) < 5 {
t.loadOlder()
}
}
func (t *StatusView) loadNewer() {
if t.loadingNewer {
return
}
t.loadingNewer = true
feedIndex := len(t.feeds) - 1
go func() {
new := t.feeds[feedIndex].LoadNewer()
if new == 0 {
return
}
if feedIndex != len(t.feeds)-1 {
return
}
t.app.UI.Root.QueueUpdateDraw(func() {
index := t.list.GetCurrentItem()
t.feeds[feedIndex].DrawList()
newIndex := index + new
if index == 0 && t.feeds[feedIndex].FeedType() == UserFeed {
newIndex = 0
}
t.list.SetCurrentItem(newIndex)
t.loadingNewer = false
})
}()
}
func (t *StatusView) loadOlder() {
if t.loadingOlder {
return
}
t.loadingOlder = true
feedIndex := len(t.feeds) - 1
go func() {
new := t.feeds[feedIndex].LoadOlder()
if new == 0 {
return
}
if feedIndex != len(t.feeds)-1 {
return
}
t.app.UI.Root.QueueUpdateDraw(func() {
index := t.list.GetCurrentItem()
t.feeds[feedIndex].DrawList()
t.list.SetCurrentItem(index)
t.loadingOlder = false
})
}()
}

257
tootlist.go

@ -1,257 +0,0 @@
package main
import (
"fmt"
"log"
"time"
"github.com/mattn/go-mastodon"
"github.com/rivo/tview"
)
type TootListFocus int
const (
TootListFeedFocus TootListFocus = iota
TootListThreadFocus
)
type TootList struct {
app *App
Index int
Statuses []*mastodon.Status
Thread []*mastodon.Status
ThreadIndex int
List *tview.List
Focus TootListFocus
loadingFeedOld bool
loadingFeedNew bool
}
func NewTootList(app *App) *TootList {
t := &TootList{
app: app,
Index: 0,
Focus: TootListFeedFocus,
List: tview.NewList(),
}
t.List.SetBackgroundColor(app.Config.Style.Background)
t.List.SetSelectedTextColor(app.Config.Style.ListSelectedText)
t.List.SetSelectedBackgroundColor(app.Config.Style.ListSelectedBackground)
t.List.ShowSecondaryText(false)
t.List.SetHighlightFullLine(true)
t.List.SetChangedFunc(func(index int, _ string, _ string, _ rune) {
if app.HaveAccount {
app.UI.TootView.ShowToot(index)
}
})
return t
}
func (t *TootList) GetStatuses() []*mastodon.Status {
if t.Focus == TootListThreadFocus {
return t.GetThread()
}
return t.GetFeed()
}
func (t *TootList) GetStatus(index int) (*mastodon.Status, error) {
if t.Focus == TootListThreadFocus {
return t.GetThreadStatus(index)
}
return t.GetFeedStatus(index)
}
func (t *TootList) SetFeedStatuses(s []*mastodon.Status) {
t.Statuses = s
t.Draw()
}
func (t *TootList) PrependFeedStatuses(s []*mastodon.Status) {
t.Statuses = append(s, t.Statuses...)
t.SetFeedIndex(
t.GetFeedIndex() + len(s),
)
t.List.SetCurrentItem(t.GetFeedIndex())
}
func (t *TootList) AppendFeedStatuses(s []*mastodon.Status) {
t.Statuses = append(t.Statuses, s...)
}
func (t *TootList) GetFeed() []*mastodon.Status {
return t.Statuses
}
func (t *TootList) GetFeedStatus(index int) (*mastodon.Status, error) {
statuses := t.GetFeed()
if index < len(statuses) {
return statuses[index], nil
}
return nil, fmt.Errorf("no status with that index")
}
func (t *TootList) GetIndex() int {
if t.Focus == TootListThreadFocus {
return t.GetThreadIndex()
}
return t.GetFeedIndex()
}
func (t *TootList) SetIndex(index int) {
switch t.Focus {
case TootListFeedFocus:
t.SetFeedIndex(index)
case TootListThreadFocus:
t.SetThreadIndex(index)
}
}
func (t *TootList) GetFeedIndex() int {
return t.Index
}
func (t *TootList) SetFeedIndex(index int) {
t.Index = index
}
func (t *TootList) GetThreadIndex() int {
return t.ThreadIndex
}
func (t *TootList) SetThreadIndex(index int) {
t.ThreadIndex = index
}
func (t *TootList) Prev() {
index := t.GetIndex()
statuses := t.GetStatuses()
if index-1 > -1 {
index--
}
if index < 5 && t.Focus == TootListFeedFocus {
go func() {
if t.loadingFeedNew {
return
}
t.loadingFeedNew = true
t.app.UI.LoadNewer(statuses[0])
t.app.UI.Root.QueueUpdateDraw(func() {
t.Draw()
t.loadingFeedNew = false
})
}()
}
t.SetIndex(index)
t.List.SetCurrentItem(index)
}
func (t *TootList) Next() {
index := t.GetIndex()
statuses := t.GetStatuses()
if index+1 < len(statuses) {
index++
}
if (len(statuses)-index) < 10 && t.Focus == TootListFeedFocus {
go func() {
if t.loadingFeedOld || len(statuses) == 0 {
return
}
t.loadingFeedOld = true
t.app.UI.LoadOlder(statuses[len(statuses)-1])
t.app.UI.Root.QueueUpdateDraw(func() {
t.Draw()
t.loadingFeedOld = false
})
}()
}
t.SetIndex(index)
t.List.SetCurrentItem(index)
}
func (t *TootList) Draw() {
t.List.Clear()
var statuses []*mastodon.Status
var index int
switch t.Focus {
case TootListFeedFocus:
statuses = t.GetFeed()
index = t.GetFeedIndex()
case TootListThreadFocus:
statuses = t.GetThread()
index = t.GetThreadIndex()
}
if len(statuses) == 0 {
return
}
today := time.Now()
ty, tm, td := today.Date()
currRow := 0
for _, s := range statuses {
sLocal := s.CreatedAt.Local()
sy, sm, sd := sLocal.Date()
format := "2006-01-02 15:04"
if ty == sy && tm == sm && td == sd {
format = "15:04"
}
content := fmt.Sprintf("%s %s", sLocal.Format(format), s.Account.Acct)
t.List.InsertItem(currRow, content, "", 0, nil)
currRow++
}
t.List.SetCurrentItem(index)
t.app.UI.TootView.ShowToot(index)
}
func (t *TootList) SetThread(s []*mastodon.Status, index int) {
t.Thread = s
t.SetThreadIndex(index)
}
func (t *TootList) GetThread() []*mastodon.Status {
return t.Thread
}
func (t *TootList) GetThreadStatus(index int) (*mastodon.Status, error) {
statuses := t.GetThread()
if index < len(statuses) {
return statuses[index], nil
}
return nil, fmt.Errorf("no status with that index")
}
func (t *TootList) FocusFeed() {
t.Focus = TootListFeedFocus
}
func (t *TootList) FocusThread() {
t.Focus = TootListThreadFocus
}
func (t *TootList) GoBack() {
t.Focus = TootListFeedFocus
t.Draw()
}
func (t *TootList) Reply() {
status, err := t.GetStatus(t.GetIndex())
if err != nil {
log.Fatalln(err)
}
if status.Reblog != nil {
status = status.Reblog
}
users := []string{"@" + status.Account.Acct}
for _, m := range status.Mentions {
users = append(users, "@"+m.Acct)
}
}

167
tootview.go

@ -1,167 +0,0 @@
package main
import (
"fmt"
"log"
"strings"
"github.com/rivo/tview"
)
func NewTootView(app *App) *TootView {
t := &TootView{
app: app,
Index: 0,
Text: tview.NewTextView(),
Controls: tview.NewTextView(),
}
t.Text.SetWordWrap(true).SetDynamicColors(true)
t.Text.SetBackgroundColor(app.Config.Style.Background)
t.Text.SetTextColor(app.Config.Style.Text)
t.Controls.SetDynamicColors(true)
t.Controls.SetBackgroundColor(app.Config.Style.Background)
return t
}
type TootView struct {
app *App
Index int
Text *tview.TextView
Controls *tview.TextView
}
func (s *TootView) ShowToot(index int) {
s.ShowTootOptions(index, false)
}
func (s *TootView) ShowTootOptions(index int, showSensitive bool) {
status, err := s.app.UI.TootList.GetStatus(index)
if err != nil {
log.Fatalln(err)
}
var line string
_, _, width, _ := s.Text.GetInnerRect()
for i := 0; i < width; i++ {
line += "-"
}
line += "\n"
shouldDisplay := !status.Sensitive || showSensitive
var stripped string
var urls []URL
var u []URL
if status.Sensitive && !showSensitive {
stripped, u = cleanTootHTML(status.SpoilerText)
urls = append(urls, u...)
stripped += "\n" + line
stripped += "Press [s] to show hidden text"
} else {
stripped, u = cleanTootHTML(status.Content)
urls = append(urls, u...)
if status.Sensitive {
sens, u := cleanTootHTML(status.SpoilerText)
urls = append(urls, u...)
stripped = sens + "\n\n" + stripped
}
}
s.app.UI.LinkOverlay.SetURLs(urls)
subtleColor := fmt.Sprintf("[#%x]", s.app.Config.Style.Subtle.Hex())
special1 := fmt.Sprintf("[#%x]", s.app.Config.Style.TextSpecial1.Hex())
special2 := fmt.Sprintf("[#%x]", s.app.Config.Style.TextSpecial2.Hex())
var head string
if status.Reblog != nil {
if status.Account.DisplayName != "" {
head += fmt.Sprintf(subtleColor+"%s (%s)\n", status.Account.DisplayName, status.Account.Acct)
} else {
head += fmt.Sprintf(subtleColor+"%s\n", status.Account.Acct)
}
head += subtleColor + "Boosted\n"
head += subtleColor + line
status = status.Reblog
}
if status.Account.DisplayName != "" {
head += fmt.Sprintf(special2+"%s\n", status.Account.DisplayName)
}
head += fmt.Sprintf(special1+"%s\n\n", status.Account.Acct)
output := head
content := tview.Escape(stripped)
if content != "" {
output += content + "\n\n"
}
var poll string
if status.Poll != nil {
poll += subtleColor + "Poll\n"
poll += subtleColor + line
poll += fmt.Sprintf("Number of votes: %d\n\n", status.Poll.VotesCount)
votes := float64(status.Poll.VotesCount)
for _, o := range status.Poll.Options {
res := 0.0
if votes != 0 {
res = float64(o.VotesCount) / votes * 100
}
poll += fmt.Sprintf("%s - %.2f%% (%d)\n", tview.Escape(o.Title), res, o.VotesCount)
}
poll += "\n"
}
var media string
for _, att := range status.MediaAttachments {
media += subtleColor + line
media += fmt.Sprintf(subtleColor+"Attached %s\n", att.Type)
media += fmt.Sprintf("%s\n", att.URL)
}
var card string
if status.Card != nil {
card += subtleColor + "Card type: " + status.Card.Type + "\n"
card += subtleColor + line
if status.Card.Title != "" {
card += status.Card.Title + "\n\n"
}
desc := strings.TrimSpace(status.Card.Description)
if desc != "" {
card += desc + "\n\n"
}
card += status.Card.URL
}
if shouldDisplay {
output += poll + media + card
}
s.Text.SetText(output)
s.Text.ScrollToBeginning()
var info []string
if status.Favourited == true {
info = append(info, "Un[F]avorite")
} else {
info = append(info, "[F]avorite")
}
if status.Reblogged == true {
info = append(info, "Un[B]oost")
} else {
info = append(info, "[B]oost")
}
info = append(info, "[T]hread", "[R]eply", "[V]iew")
if len(status.MediaAttachments) > 0 {
info = append(info, "[M]edia")
}
if len(urls) > 0 {
info = append(info, "[O]pen")
}
if status.Account.ID == s.app.Me.ID {
info = append(info, "[D]elete")
}
s.Controls.SetText(tview.Escape(strings.Join(info, " ")))
}

168
ui.go

@ -24,42 +24,44 @@ const (
func NewUI(app *App) *UI {
ui := &UI{
app: app,
Root: tview.NewApplication(),
Top: NewTop(app),
Pages: tview.NewPages(),
Timeline: TimelineHome,
TootList: NewTootList(app),
TootView: NewTootView(app),
CmdBar: NewCmdBar(app),
StatusBar: NewStatusBar(app),
MessageBox: NewMessageBox(app),
LinkOverlay: NewLinkOverlay(app),
AuthOverlay: NewAuthOverlay(app),
MediaOverlay: NewMediaOverlay(app),
app: app,
Root: tview.NewApplication(),
}
verticalLine := tview.NewBox().SetBackgroundColor(app.Config.Style.Background)
return ui
}
func (ui *UI) Init() {
ui.Top = NewTop(ui.app)
ui.Pages = tview.NewPages()
ui.Timeline = TimelineHome
ui.CmdBar = NewCmdBar(ui.app)
ui.StatusBar = NewStatusBar(ui.app)
ui.MessageBox = NewMessageBox(ui.app)
ui.LinkOverlay = NewLinkOverlay(ui.app)
ui.AuthOverlay = NewAuthOverlay(ui.app)
ui.MediaOverlay = NewMediaOverlay(ui.app)
ui.StatusView = NewStatusView(ui.app, ui.Timeline)
verticalLine := tview.NewBox().SetBackgroundColor(ui.app.Config.Style.Background)
verticalLine.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) {
for cy := y; cy < y+height; cy++ {
screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, tcell.StyleDefault.Foreground(app.Config.Style.Subtle))
screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, tcell.StyleDefault.Foreground(ui.app.Config.Style.Subtle))
}
return 0, 0, 0, 0
})
ui.Pages.SetBackgroundColor(app.Config.Style.Background)
ui.Pages.SetBackgroundColor(ui.app.Config.Style.Background)
ui.Pages.AddPage("main",
tview.NewFlex().
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(ui.Top.Text, 1, 0, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(ui.TootList.List, 0, 2, false).
AddItem(ui.StatusView.GetLeftView(), 0, 2, false).
AddItem(verticalLine, 1, 0, false).
AddItem(tview.NewBox().SetBackgroundColor(app.Config.Style.Background), 1, 0, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(ui.TootView.Text, 0, 9, false).
AddItem(ui.TootView.Controls, 1, 0, false),
AddItem(tview.NewBox().SetBackgroundColor(ui.app.Config.Style.Background), 1, 0, false).
AddItem(ui.StatusView.GetRightView(),
0, 4, false),
0, 1, false).
AddItem(ui.StatusBar.Text, 1, 1, false).
@ -111,8 +113,6 @@ func NewUI(app *App) *UI {
screen.Clear()
return false
})
return ui
}
type UI struct {
@ -120,8 +120,6 @@ type UI struct {
Root *tview.Application
Focus FocusAt
Top *Top
TootView *TootView
TootList *TootList
MessageBox *MessageBox
CmdBar *CmdBar
StatusBar *StatusBar
@ -130,6 +128,18 @@ type UI struct {
AuthOverlay *AuthOverlay
MediaOverlay *MediaView
Timeline TimelineType
StatusView *StatusView
}
func (ui *UI) FocusAt(p tview.Primitive, s string) {
if p == nil {
ui.Root.SetFocus(ui.Pages)
} else {
ui.Root.SetFocus(p)
}
if s != "" {
ui.StatusBar.SetText(s)
}
}
func (ui *UI) SetFocus(f FocusAt) {
@ -137,7 +147,6 @@ func (ui *UI) SetFocus(f FocusAt) {
switch f {
case RightPaneFocus:
ui.StatusBar.SetText("-- VIEW --")
ui.Root.SetFocus(ui.TootView.Text)
case CmdBarFocus:
ui.StatusBar.SetText("-- CMD --")
ui.Root.SetFocus(ui.CmdBar.Input)
@ -166,40 +175,6 @@ func (ui *UI) SetFocus(f FocusAt) {
}
}
func (ui *UI) SetTimeline(tl TimelineType) {
ui.Timeline = tl
statuses, err := ui.app.API.GetStatuses(tl)
if err != nil {
log.Fatalln(err)
}
ui.TootList.SetFeedStatuses(statuses)
}
func (ui *UI) ShowThread() {
status, err := ui.TootList.GetStatus(ui.TootList.GetIndex())
if err != nil {
log.Fatalln(err)
}
if status.Reblog != nil {
status = status.Reblog
}
thread, index, err := ui.app.API.GetThread(status)
if err != nil {
log.Fatalln(err)
}
ui.TootList.SetThread(thread, index)
ui.TootList.FocusThread()
ui.SetFocus(LeftPaneFocus)
ui.TootList.Draw()
}
func (ui *UI) ShowSensetive() {
ui.TootView.ShowTootOptions(ui.TootList.GetIndex(), true)
}
func (ui *UI) NewToot() {
ui.Root.SetFocus(ui.MessageBox.View)
ui.MediaOverlay.Reset()
@ -208,11 +183,7 @@ func (ui *UI) NewToot() {
ui.SetFocus(MessageFocus)
}
func (ui *UI) Reply() {
status, err := ui.TootList.GetStatus(ui.TootList.GetIndex())
if err != nil {
log.Fatalln(err)
}
func (ui *UI) Reply(status *mastodon.Status) {
if status.Reblog != nil {
status = status.Reblog
}
@ -226,11 +197,7 @@ func (ui *UI) ShowLinks() {
ui.SetFocus(LinkOverlayFocus)
}
func (ui *UI) OpenMedia() {
status, err := ui.TootList.GetStatus(ui.TootList.GetIndex())
if err != nil {
log.Fatalln(err)
}
func (ui *UI) OpenMedia(status *mastodon.Status) {
if status.Reblog != nil {
status = status.Reblog
}
@ -267,64 +234,9 @@ func (ui *UI) LoggedIn() {
log.Fatalln(err)
}
ui.app.Me = me
ui.SetTimeline(ui.Timeline)
}
func (ui *UI) LoadNewer(status *mastodon.Status) int {
statuses, _, err := ui.app.API.GetStatusesNewer(ui.Timeline, status)
if err != nil {
log.Fatalln(err)
}
ui.TootList.PrependFeedStatuses(statuses)
return len(statuses)
}
func (ui *UI) LoadOlder(status *mastodon.Status) int {
statuses, _, err := ui.app.API.GetStatusesOlder(ui.Timeline, status)
if err != nil {
log.Fatalln(err)
}
ui.TootList.AppendFeedStatuses(statuses)
return len(statuses)
}
func (ui *UI) FavoriteEvent() {
status, err := ui.TootList.GetStatus(ui.TootList.GetIndex())
if err != nil {
log.Fatalln(err)
}
if status.Favourited == true {
err = ui.app.API.Unfavorite(status)
} else {
err = ui.app.API.Favorite(status)
}
}
func (ui *UI) BoostEvent() {
status, err := ui.TootList.GetStatus(ui.TootList.GetIndex())
if err != nil {
log.Fatalln(err)
}
if status.Reblogged == true {
err = ui.app.API.Unboost(status)
} else {
err = ui.app.API.Boost(status)
}
if err != nil {
log.Fatalln(err)
}
}
func (ui *UI) DeleteStatus() {
status, err := ui.TootList.GetStatus(ui.TootList.GetIndex())
if err != nil {
log.Fatalln(err)
}
err = ui.app.API.DeleteStatus(status)
if err != nil {
log.Fatalln(err)
}
ui.StatusView.AddFeed(
NewTimeline(ui.app, TimelineHome),
)
}
func (conf *Config) ClearContent(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) {

23
util.go

@ -1,6 +1,7 @@
package main
import (
"fmt"
"io"
"io/ioutil"
"log"
@ -11,6 +12,7 @@ import (
"regexp"
"strings"
"github.com/mattn/go-mastodon"
"github.com/microcosm-cc/bluemonday"
"github.com/rivo/tview"
"golang.org/x/net/html"
@ -54,6 +56,7 @@ func cleanTootHTML(content string) (string, []URL) {
urls := getURLs(stripped)
stripped = bluemonday.NewPolicy().AllowElements("p", "br").Sanitize(content)
stripped = strings.ReplaceAll(stripped, "<br>", "\n")
stripped = strings.ReplaceAll(stripped, "<br/>", "\n")
stripped = strings.ReplaceAll(stripped, "<p>", "")
stripped = strings.ReplaceAll(stripped, "</p>", "\n\n")
stripped = strings.TrimSpace(stripped)
@ -211,3 +214,23 @@ func FindFiles(s string) []string {
}
return files
}
func ColorKey(style StyleConfig, pre, key, end string) string {
color := fmt.Sprintf("[#%x]", style.TextSpecial2.Hex())
normal := fmt.Sprintf("[#%x]", style.Text.Hex())
key = tview.Escape("[" + key + "]")
text := fmt.Sprintf("%s%s%s%s%s", pre, color, key, normal, end)
return text
}
func FormatUsername(a mastodon.Account) string {
if a.DisplayName != "" {
return fmt.Sprintf("%s (%s)", a.DisplayName, a.Acct)
}
return a.Acct
}
func SublteText(style StyleConfig, text string) string {
subtle := fmt.Sprintf("[#%x]", style.Subtle.Hex())
return fmt.Sprintf("%s%s", subtle, text)
}

Loading…
Cancel
Save