Browse Source

refactor: modularize into packages

ayntgl 4 năm trước cách đây
mục cha
commit
d0b47b2588
8 tập tin đã thay đổi với 547 bổ sung561 xóa
  1. 75 76
      main.go
  2. 0 335
      ui.go
  3. 77 12
      ui/app.go
  4. 293 0
      ui/handlers.go
  5. 32 122
      ui/util.go
  6. 67 1
      ui/views.go
  7. 3 4
      util/discord.go
  8. 0 11
      util/ui.go

+ 75 - 76
main.go

@@ -1,70 +1,21 @@
 package main
 
 import (
+	"encoding/json"
 	"os"
 
 	"github.com/ayntgl/discordgo"
 	"github.com/ayntgl/discordo/config"
 	"github.com/ayntgl/discordo/ui"
-	"github.com/gdamore/tcell/v2"
 	"github.com/rivo/tview"
 	"github.com/zalando/go-keyring"
 )
 
 const service = "discordo"
 
-var (
-	app               *ui.App
-	loginForm         *tview.Form
-	channelsTreeView  *tview.TreeView
-	messagesTextView  *tview.TextView
-	messageInputField *tview.InputField
-	mainFlex          *tview.Flex
-
-	selectedChannel *discordgo.Channel
-	selectedMessage int = -1
-)
-
 func main() {
-	app = ui.NewApp()
-	app.
-		EnableMouse(config.General.Mouse).
-		SetInputCapture(onAppInputCapture)
-
-	app.ChannelsTreeView.
-		SetTopLevel(1).
-		SetRoot(tview.NewTreeNode("")).
-		SetSelectedFunc(onChannelsTreeSelected).
-		SetTitleAlign(tview.AlignLeft).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0)
-
-	app.MessagesTextView.
-		SetRegions(true).
-		SetDynamicColors(true).
-		SetWordWrap(true).
-		SetChangedFunc(func() { app.Draw() }).
-		SetTitleAlign(tview.AlignLeft).
-		SetInputCapture(onMessagesViewInputCapture).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0)
-
-	app.MessageInputField.
-		SetPlaceholder("Message...").
-		SetPlaceholderTextColor(tcell.ColorWhite).
-		SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
-		SetInputCapture(onMessageInputFieldInputCapture).
-		SetTitleAlign(tview.AlignLeft).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0)
-
-	rightFlex := tview.NewFlex().
-		SetDirection(tview.FlexRow).
-		AddItem(messagesTextView, 0, 1, false).
-		AddItem(messageInputField, 3, 1, false)
-	mainFlex = tview.NewFlex().
-		AddItem(channelsTreeView, 0, 1, false).
-		AddItem(rightFlex, 0, 4, false)
+	app := ui.NewApp()
+	app.EnableMouse(config.General.Mouse)
 
 	token := os.Getenv("DISCORDO_TOKEN")
 	if token == "" {
@@ -72,19 +23,20 @@ func main() {
 	}
 
 	if token != "" {
-		app.
-			SetRoot(mainFlex, true).
-			SetFocus(channelsTreeView)
-
-		app.Session.AddHandlerOnce(onSessionReady)
-		app.Session.AddHandler(onSessionMessageCreate)
 		err := app.Connect(token)
 		if err != nil {
 			panic(err)
 		}
+
+		ui.DrawMainFlex(app)
+		app.
+			SetRoot(app.MainFlex, true).
+			SetFocus(app.GuildsList)
 	} else {
-		loginForm = ui.NewLoginForm(onLoginFormLoginButtonSelected, false)
-		app.SetRoot(loginForm, true)
+		app.LoginForm = ui.NewLoginForm(func() {
+			onLoginFormLoginButtonSelected(app)
+		}, false)
+		app.SetRoot(app.LoginForm, true)
 	}
 
 	if err := app.Run(); err != nil {
@@ -92,26 +44,24 @@ func main() {
 	}
 }
 
-func onLoginFormLoginButtonSelected() {
-	email := loginForm.GetFormItem(0).(*tview.InputField).GetText()
-	password := loginForm.GetFormItem(1).(*tview.InputField).GetText()
+func onLoginFormLoginButtonSelected(app *ui.App) {
+	email := app.LoginForm.GetFormItem(0).(*tview.InputField).GetText()
+	password := app.LoginForm.GetFormItem(1).(*tview.InputField).GetText()
 	if email == "" || password == "" {
 		return
 	}
 
 	// Login using the email and password
-	lr, err := login(email, password)
+	lr, err := login(app.Session, email, password)
 	if err != nil {
 		panic(err)
 	}
 
 	if lr.Token != "" && !lr.MFA {
 		app.
-			SetRoot(mainFlex, true).
-			SetFocus(channelsTreeView)
+			SetRoot(app.MainFlex, true).
+			SetFocus(app.GuildsList)
 
-		app.Session.AddHandlerOnce(onSessionReady)
-		app.Session.AddHandler(onSessionMessageCreate)
 		err = app.Connect(lr.Token)
 		if err != nil {
 			panic(err)
@@ -120,23 +70,21 @@ func onLoginFormLoginButtonSelected() {
 		go keyring.Set(service, "token", lr.Token)
 	} else if lr.MFA {
 		// The account has MFA enabled, reattempt login with code and ticket.
-		loginForm = ui.NewLoginForm(func() {
-			code := loginForm.GetFormItem(0).(*tview.InputField).GetText()
+		app.LoginForm = ui.NewLoginForm(func() {
+			code := app.LoginForm.GetFormItem(0).(*tview.InputField).GetText()
 			if code == "" {
 				return
 			}
 
-			lr, err = totp(code, lr.Ticket)
+			lr, err = totp(app.Session, code, lr.Ticket)
 			if err != nil {
 				panic(err)
 			}
 
 			app.
-				SetRoot(mainFlex, true).
-				SetFocus(channelsTreeView)
+				SetRoot(app.MainFlex, true).
+				SetFocus(app.GuildsList)
 
-			app.Session.AddHandlerOnce(onSessionReady)
-			app.Session.AddHandler(onSessionMessageCreate)
 			err = app.Connect(lr.Token)
 			if err != nil {
 				panic(err)
@@ -145,6 +93,57 @@ func onLoginFormLoginButtonSelected() {
 			go keyring.Set(service, "token", lr.Token)
 		}, true)
 
-		app.SetRoot(loginForm, true)
+		app.SetRoot(app.LoginForm, true)
+	}
+}
+
+type loginResponse struct {
+	MFA    bool   `json:"mfa"`
+	SMS    bool   `json:"sms"`
+	Ticket string `json:"ticket"`
+	Token  string `json:"token"`
+}
+
+func login(s *discordgo.Session, email string, password string) (*loginResponse, error) {
+	data := struct {
+		Email    string `json:"email"`
+		Password string `json:"password"`
+	}{email, password}
+	resp, err := s.RequestWithBucketID(
+		"POST",
+		discordgo.EndpointLogin,
+		data,
+		discordgo.EndpointLogin,
+	)
+	if err != nil {
+		return nil, err
 	}
+
+	var lr loginResponse
+	err = json.Unmarshal(resp, &lr)
+	if err != nil {
+		return nil, err
+	}
+
+	return &lr, nil
+}
+
+func totp(s *discordgo.Session, code string, ticket string) (*loginResponse, error) {
+	data := struct {
+		Code   string `json:"code"`
+		Ticket string `json:"ticket"`
+	}{code, ticket}
+	e := discordgo.EndpointAuth + "mfa/totp"
+	resp, err := s.RequestWithBucketID("POST", e, data, e)
+	if err != nil {
+		return nil, err
+	}
+
+	var lr loginResponse
+	err = json.Unmarshal(resp, &lr)
+	if err != nil {
+		return nil, err
+	}
+
+	return &lr, nil
 }

+ 0 - 335
ui.go

@@ -1,335 +0,0 @@
-package main
-
-import (
-	"sort"
-	"strings"
-
-	"github.com/atotto/clipboard"
-	"github.com/ayntgl/discordgo"
-	"github.com/ayntgl/discordo/config"
-	"github.com/ayntgl/discordo/util"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	if util.HasKeybinding(config.Keybindings.FocusChannelsTreeView, e.Name()) {
-		app.SetFocus(channelsTreeView)
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.FocusMessagesView, e.Name()) {
-		app.SetFocus(messagesTextView)
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.FocusMessageInputField, e.Name()) {
-		app.SetFocus(messageInputField)
-		return nil
-	}
-
-	return e
-}
-
-func onChannelsTreeSelected(n *tview.TreeNode) {
-	selectedChannel = nil
-	selectedMessage = 0
-	messagesTextView.
-		Clear().
-		SetTitle("")
-	messageInputField.SetText("")
-	// Unhighlight the already-highlighted regions.
-	messagesTextView.Highlight()
-
-	id := n.GetReference()
-	switch n.GetLevel() {
-	case 1: // Guilds or Direct Messages
-		if len(n.GetChildren()) == 0 {
-			// If the reference of the selected `*TreeNode` is `nil`, it is the direct messages `*TreeNode`.
-			if id == nil {
-				cs := app.Session.State.PrivateChannels
-				sort.Slice(cs, func(i, j int) bool {
-					return cs[i].LastMessageID > cs[j].LastMessageID
-				})
-
-				for _, c := range cs {
-					tag := "[::d]"
-					if util.ChannelIsUnread(app.Session.State, c) {
-						tag = "[::b]"
-					}
-
-					cn := tview.NewTreeNode(tag + util.ChannelToString(c) + "[::-]").
-						SetReference(c.ID).
-						Collapse()
-					n.AddChild(cn)
-				}
-			} else {
-				g, err := app.Session.State.Guild(id.(string))
-				if err != nil {
-					return
-				}
-
-				sort.Slice(g.Channels, func(i, j int) bool {
-					return g.Channels[i].Position < g.Channels[j].Position
-				})
-
-				// Top-level channels
-				createTopLevelChannelsNodes(channelsTreeView, app.Session.State, n, g.Channels)
-				// Category channels
-				createCategoryChannelsNodes(channelsTreeView, app.Session.State, n, g.Channels)
-				// Second-level channels
-				createSecondLevelChannelsNodes(channelsTreeView, app.Session.State, g.Channels)
-			}
-		}
-
-		n.SetExpanded(!n.IsExpanded())
-	default: // Channels
-		c, err := app.Session.State.Channel(id.(string))
-		if err != nil {
-			return
-		}
-
-		selectedChannel = c
-		app.SetFocus(messageInputField)
-
-		switch c.Type {
-		case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews:
-			title := util.ChannelToString(c)
-			if c.Topic != "" {
-				title += " - " + c.Topic
-			}
-
-			messagesTextView.SetTitle(title)
-		case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM:
-			messagesTextView.SetTitle(util.ChannelToString(c))
-		}
-
-		if strings.HasPrefix(n.GetText(), "[::b]") {
-			n.SetText("[::d]" + util.ChannelToString(c) + "[::-]")
-		}
-
-		go func() {
-			ms, err := app.Session.ChannelMessages(c.ID, config.General.FetchMessagesLimit, "", "", "")
-			if err != nil {
-				return
-			}
-
-			for i := len(ms) - 1; i >= 0; i-- {
-				selectedChannel.Messages = append(selectedChannel.Messages, ms[i])
-				messagesTextView.Write(buildMessage(ms[i]))
-			}
-			// Scroll to the end of the text after the messages have been written to the TextView.
-			messagesTextView.ScrollToEnd()
-
-			if len(ms) != 0 && util.ChannelIsUnread(app.Session.State, c) {
-				app.Session.ChannelMessageAck(c.ID, c.LastMessageID, "")
-			}
-		}()
-	}
-}
-
-// createTopLevelChannelsNodes builds and creates `*tview.TreeNode`s for top-level (channels that have an empty parent ID and of type GUILD_TEXT, GUILD_NEWS) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent.
-func createTopLevelChannelsNodes(treeView *tview.TreeView, s *discordgo.State, n *tview.TreeNode, cs []*discordgo.Channel) {
-	for _, c := range cs {
-		if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) &&
-			(c.ParentID == "") {
-			if !util.HasPermission(s, c.ID, discordgo.PermissionViewChannel) {
-				continue
-			}
-
-			n.AddChild(util.CreateChannelNode(s, c))
-			continue
-		}
-	}
-}
-
-// createCategoryChannelsNodes builds and creates `*tview.TreeNode`s for category (type: GUILD_CATEGORY) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent.
-func createCategoryChannelsNodes(treeView *tview.TreeView, s *discordgo.State, n *tview.TreeNode, cs []*discordgo.Channel) {
-CategoryLoop:
-	for _, c := range cs {
-		if c.Type == discordgo.ChannelTypeGuildCategory {
-			if !util.HasPermission(s, c.ID, discordgo.PermissionViewChannel) {
-				continue
-			}
-
-			for _, child := range cs {
-				if child.ParentID == c.ID {
-					n.AddChild(util.CreateChannelNode(s, c))
-					continue CategoryLoop
-				}
-			}
-
-			n.AddChild(util.CreateChannelNode(s, c))
-		}
-	}
-}
-
-// createSecondLevelChannelsNodes builds and creates `*tview.TreeNode`s for second-level (channels that have a non-empty parent ID and of type GUILD_TEXT, GUILD_NEWS) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent.
-func createSecondLevelChannelsNodes(treeView *tview.TreeView, s *discordgo.State, cs []*discordgo.Channel) {
-	for _, c := range cs {
-		if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) &&
-			(c.ParentID != "") {
-			if !util.HasPermission(s, c.ID, discordgo.PermissionViewChannel) {
-				continue
-			}
-
-			pn := util.GetNodeByReference(treeView, c.ParentID)
-			if pn != nil {
-				pn.AddChild(util.CreateChannelNode(s, c))
-			}
-		}
-	}
-}
-
-func onMessagesViewInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	if selectedChannel == nil {
-		return nil
-	}
-
-	ms := selectedChannel.Messages
-	if len(ms) == 0 {
-		return nil
-	}
-
-	if util.HasKeybinding(config.Keybindings.SelectPreviousMessage, e.Name()) {
-		if len(messagesTextView.GetHighlights()) == 0 {
-			selectedMessage = len(ms) - 1
-		} else {
-			selectedMessage--
-			if selectedMessage < 0 {
-				selectedMessage = 0
-			}
-		}
-
-		messagesTextView.
-			Highlight(ms[selectedMessage].ID).
-			ScrollToHighlight()
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.SelectNextMessage, e.Name()) {
-		if len(messagesTextView.GetHighlights()) == 0 {
-			selectedMessage = len(ms) - 1
-		} else {
-			selectedMessage++
-			if selectedMessage >= len(ms) {
-				selectedMessage = len(ms) - 1
-			}
-		}
-
-		messagesTextView.
-			Highlight(ms[selectedMessage].ID).
-			ScrollToHighlight()
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.SelectFirstMessage, e.Name()) {
-		selectedMessage = 0
-		messagesTextView.
-			Highlight(ms[selectedMessage].ID).
-			ScrollToHighlight()
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.SelectLastMessage, e.Name()) {
-		selectedMessage = len(ms) - 1
-		messagesTextView.
-			Highlight(ms[selectedMessage].ID).
-			ScrollToHighlight()
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.SelectMessageReference, e.Name()) {
-		hs := messagesTextView.GetHighlights()
-		if len(hs) == 0 {
-			return nil
-		}
-
-		_, m := util.FindMessageByID(selectedChannel.Messages, hs[0])
-		if m.ReferencedMessage != nil {
-			selectedMessage, _ = util.FindMessageByID(selectedChannel.Messages, m.ReferencedMessage.ID)
-			messagesTextView.
-				Highlight(m.ReferencedMessage.ID).
-				ScrollToHighlight()
-		}
-
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.ReplySelectedMessage, e.Name()) {
-		hs := messagesTextView.GetHighlights()
-		if len(hs) == 0 {
-			return nil
-		}
-
-		_, m := util.FindMessageByID(selectedChannel.Messages, hs[0])
-		messageInputField.SetTitle("Replying to " + m.Author.String())
-		app.SetFocus(messageInputField)
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.MentionReplySelectedMessage, e.Name()) {
-		hs := messagesTextView.GetHighlights()
-		if len(hs) == 0 {
-			return nil
-		}
-
-		_, m := util.FindMessageByID(selectedChannel.Messages, hs[0])
-		messageInputField.SetTitle("[@] Replying to " + m.Author.String())
-		app.SetFocus(messageInputField)
-		return nil
-	} else if util.HasKeybinding(config.Keybindings.CopySelectedMessage, e.Name()) {
-		hs := messagesTextView.GetHighlights()
-		if len(hs) == 0 {
-			return nil
-		}
-
-		_, m := util.FindMessageByID(selectedChannel.Messages, hs[0])
-		err := clipboard.WriteAll(m.Content)
-		if err != nil {
-			return nil
-		}
-
-		return nil
-	}
-
-	return e
-}
-
-func onMessageInputFieldInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	switch e.Key() {
-	case tcell.KeyEnter:
-		if selectedChannel == nil {
-			return nil
-		}
-
-		t := strings.TrimSpace(messageInputField.GetText())
-		if t == "" {
-			return nil
-		}
-
-		if len(messagesTextView.GetHighlights()) != 0 {
-			_, m := util.FindMessageByID(selectedChannel.Messages, messagesTextView.GetHighlights()[0])
-			d := &discordgo.MessageSend{
-				Content:         t,
-				Reference:       m.Reference(),
-				AllowedMentions: &discordgo.MessageAllowedMentions{RepliedUser: false},
-			}
-			if strings.HasPrefix(messageInputField.GetTitle(), "[@]") {
-				d.AllowedMentions.RepliedUser = true
-			} else {
-				d.AllowedMentions.RepliedUser = false
-			}
-
-			go app.Session.ChannelMessageSendComplex(m.ChannelID, d)
-
-			selectedMessage = -1
-			messagesTextView.Highlight()
-
-			messageInputField.SetTitle("")
-		} else {
-			go app.Session.ChannelMessageSend(selectedChannel.ID, t)
-		}
-
-		messageInputField.SetText("")
-		return nil
-	case tcell.KeyCtrlV:
-		text, _ := clipboard.ReadAll()
-		text = messageInputField.GetText() + text
-		messageInputField.SetText(text)
-		return nil
-	case tcell.KeyEscape:
-		messageInputField.SetText("")
-		messageInputField.SetTitle("")
-
-		selectedMessage = -1
-		messagesTextView.Highlight()
-		return nil
-	}
-
-	return e
-}

+ 77 - 12
ui/app.go

@@ -1,19 +1,28 @@
 package ui
 
 import (
+	"fmt"
+	"sort"
+
 	"github.com/ayntgl/discordgo"
 	"github.com/ayntgl/discordo/config"
+	"github.com/gen2brain/beeep"
 	"github.com/rivo/tview"
 )
 
 type App struct {
 	*tview.Application
 
+	LoginForm         *tview.Form
+	MainFlex          *tview.Flex
+	GuildsList        *tview.List
 	ChannelsTreeView  *tview.TreeView
 	MessagesTextView  *tview.TextView
 	MessageInputField *tview.InputField
 
-	Session *discordgo.Session
+	Session         *discordgo.Session
+	SelectedChannel *discordgo.Channel
+	SelectedMessage int
 }
 
 func NewApp() *App {
@@ -21,11 +30,15 @@ func NewApp() *App {
 	return &App{
 		Application: tview.NewApplication(),
 
+		LoginForm:         tview.NewForm(),
+		MainFlex:          tview.NewFlex(),
+		GuildsList:        tview.NewList(),
 		ChannelsTreeView:  tview.NewTreeView(),
 		MessagesTextView:  tview.NewTextView(),
 		MessageInputField: tview.NewInputField(),
 
-		Session: s,
+		Session:         s,
+		SelectedMessage: -1,
 	}
 }
 
@@ -33,16 +46,68 @@ func (app *App) Connect(token string) error {
 	app.Session.Token = token
 	app.Session.UserAgent = config.General.UserAgent
 
-	app.Session.Identify.Token = token
-	app.Session.Identify.Compress = false
-	app.Session.Identify.Intents = 0
-	app.Session.Identify.LargeThreshold = 0
-	app.Session.Identify.Properties.Device = ""
-	app.Session.Identify.Properties.Browser = "Firefox"
-	app.Session.Identify.Properties.OS = "Linux"
-
-	// app.Session.AddHandlerOnce(onSessionReady)
-	// app.Session.AddHandler(onSessionMessageCreate)
+	app.Session.Identify = discordgo.Identify{
+		Token:          token,
+		Compress:       false,
+		LargeThreshold: 0,
+		Intents:        0,
+		Properties: discordgo.IdentifyProperties{
+			OS:      "Linux",
+			Browser: "Firefox",
+			Device:  "",
+		},
+	}
+	app.Session.AddHandlerOnce(app.onSessionReady)
+	app.Session.AddHandler(app.onSessionMessageCreate)
 
 	return app.Session.Open()
 }
+
+func (app *App) onSessionReady(_ *discordgo.Session, r *discordgo.Ready) {
+	sort.Slice(r.Guilds, func(a, b int) bool {
+		found := false
+		for _, guildID := range r.Settings.GuildPositions {
+			if found && guildID == r.Guilds[b].ID {
+				return true
+			}
+			if !found && guildID == r.Guilds[a].ID {
+				found = true
+			}
+		}
+
+		return false
+	})
+
+	for _, g := range r.Guilds {
+		app.GuildsList.AddItem(g.Name, "", 0, nil)
+	}
+}
+
+func (app *App) onSessionMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) {
+	if app.SelectedChannel == nil || app.SelectedChannel.ID != m.ChannelID {
+		if config.General.Notifications {
+			for _, u := range m.Mentions {
+				if u.ID == app.Session.State.User.ID {
+					g, err := app.Session.State.Guild(m.GuildID)
+					if err != nil {
+						return
+					}
+
+					c, err := app.Session.State.Channel(m.ChannelID)
+					if err != nil {
+						return
+					}
+
+					go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "")
+				}
+			}
+		}
+	} else {
+		app.SelectedChannel.Messages = append(app.SelectedChannel.Messages, m.Message)
+		app.MessagesTextView.Write(buildMessage(app, m.Message))
+
+		if len(app.MessagesTextView.GetHighlights()) == 0 {
+			app.MessagesTextView.ScrollToEnd()
+		}
+	}
+}

+ 293 - 0
ui/handlers.go

@@ -0,0 +1,293 @@
+package ui
+
+import (
+	"sort"
+	"strings"
+
+	"github.com/atotto/clipboard"
+	"github.com/ayntgl/discordgo"
+	"github.com/ayntgl/discordo/config"
+	"github.com/ayntgl/discordo/util"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+func onAppInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey {
+	if hasKeybinding(config.Keybindings.FocusChannelsTreeView, e.Name()) {
+		app.SetFocus(app.ChannelsTreeView)
+		return nil
+	} else if hasKeybinding(config.Keybindings.FocusMessagesView, e.Name()) {
+		app.SetFocus(app.MessagesTextView)
+		return nil
+	} else if hasKeybinding(config.Keybindings.FocusMessageInputField, e.Name()) {
+		app.SetFocus(app.MessageInputField)
+		return nil
+	}
+
+	return e
+}
+
+func onGuildsListSelected(app *App, guildIdx int) {
+	rootTreeNode := app.ChannelsTreeView.GetRoot()
+	rootTreeNode.ClearChildren()
+	app.MessagesTextView.
+		Highlight().
+		Clear().
+		SetTitle("")
+	app.MessageInputField.SetText("")
+
+	if guildIdx == 0 { // Direct Messages
+		cs := app.Session.State.PrivateChannels
+		sort.Slice(cs, func(i, j int) bool {
+			return cs[i].LastMessageID > cs[j].LastMessageID
+		})
+
+		for _, c := range cs {
+			channelTreeNode := tview.NewTreeNode(channelToString(c)).
+				SetReference(c.ID)
+			rootTreeNode.AddChild(channelTreeNode)
+		}
+	} else { // Guild
+		cs := app.Session.State.Guilds[guildIdx-1].Channels
+		sort.Slice(cs, func(i, j int) bool {
+			return cs[i].Position < cs[j].Position
+		})
+
+		for _, c := range cs {
+			if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && (c.ParentID == "") {
+				channelTreeNode := tview.NewTreeNode(channelToString(c)).
+					SetReference(c.ID)
+				rootTreeNode.AddChild(channelTreeNode)
+			}
+		}
+
+	CATEGORY:
+		for _, c := range cs {
+			if c.Type == discordgo.ChannelTypeGuildCategory {
+				for _, nestedChannel := range cs {
+					if nestedChannel.ParentID == c.ID {
+						channelTreeNode := tview.NewTreeNode(c.Name).
+							SetReference(c.ID)
+						rootTreeNode.AddChild(channelTreeNode)
+						continue CATEGORY
+					}
+				}
+
+				channelTreeNode := tview.NewTreeNode(c.Name).
+					SetReference(c.ID)
+				rootTreeNode.AddChild(channelTreeNode)
+			}
+		}
+
+		for _, c := range cs {
+			if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && (c.ParentID != "") {
+				var parentTreeNode *tview.TreeNode
+				rootTreeNode.Walk(func(node, _ *tview.TreeNode) bool {
+					if node.GetReference() == c.ParentID {
+						parentTreeNode = node
+						return false
+					}
+
+					return true
+				})
+
+				if parentTreeNode != nil {
+					channelTreeNode := tview.NewTreeNode(channelToString(c)).
+						SetReference(c.ID)
+					parentTreeNode.AddChild(channelTreeNode)
+				}
+			}
+		}
+	}
+
+	app.ChannelsTreeView.SetCurrentNode(rootTreeNode)
+	app.SetFocus(app.ChannelsTreeView)
+}
+
+func onChannelsTreeViewSelected(app *App, n *tview.TreeNode) {
+	c, err := app.Session.State.Channel(n.GetReference().(string))
+	if err != nil {
+		return
+	}
+
+	if c.Type == discordgo.ChannelTypeGuildCategory {
+		n.SetExpanded(!n.IsExpanded())
+		return
+	}
+
+	app.SelectedChannel = c
+
+	app.MessagesTextView.SetTitle(channelToString(c))
+	app.SetFocus(app.MessageInputField)
+
+	go func() {
+		ms, err := app.Session.ChannelMessages(c.ID, config.General.FetchMessagesLimit, "", "", "")
+		if err != nil {
+			return
+		}
+
+		for i := len(ms) - 1; i >= 0; i-- {
+			app.SelectedChannel.Messages = ms
+			app.MessagesTextView.Write(buildMessage(app, ms[i]))
+		}
+
+		app.MessagesTextView.ScrollToEnd()
+	}()
+}
+
+func onMessagesTextViewInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey {
+	if app.SelectedChannel == nil {
+		return nil
+	}
+
+	ms := app.SelectedChannel.Messages
+	if len(ms) == 0 {
+		return nil
+	}
+
+	if hasKeybinding(config.Keybindings.SelectPreviousMessage, e.Name()) {
+		if len(app.MessagesTextView.GetHighlights()) == 0 {
+			app.SelectedMessage = len(ms) - 1
+		} else {
+			app.SelectedMessage--
+			if app.SelectedMessage < 0 {
+				app.SelectedMessage = 0
+			}
+		}
+
+		app.MessagesTextView.
+			Highlight(ms[app.SelectedMessage].ID).
+			ScrollToHighlight()
+		return nil
+	} else if hasKeybinding(config.Keybindings.SelectNextMessage, e.Name()) {
+		if len(app.MessagesTextView.GetHighlights()) == 0 {
+			app.SelectedMessage = len(ms) - 1
+		} else {
+			app.SelectedMessage++
+			if app.SelectedMessage >= len(ms) {
+				app.SelectedMessage = len(ms) - 1
+			}
+		}
+
+		app.MessagesTextView.
+			Highlight(ms[app.SelectedMessage].ID).
+			ScrollToHighlight()
+		return nil
+	} else if hasKeybinding(config.Keybindings.SelectFirstMessage, e.Name()) {
+		app.SelectedMessage = 0
+		app.MessagesTextView.
+			Highlight(ms[app.SelectedMessage].ID).
+			ScrollToHighlight()
+		return nil
+	} else if hasKeybinding(config.Keybindings.SelectLastMessage, e.Name()) {
+		app.SelectedMessage = len(ms) - 1
+		app.MessagesTextView.
+			Highlight(ms[app.SelectedMessage].ID).
+			ScrollToHighlight()
+		return nil
+	} else if hasKeybinding(config.Keybindings.SelectMessageReference, e.Name()) {
+		hs := app.MessagesTextView.GetHighlights()
+		if len(hs) == 0 {
+			return nil
+		}
+
+		_, m := util.FindMessageByID(app.SelectedChannel.Messages, hs[0])
+		if m.ReferencedMessage != nil {
+			app.SelectedMessage, _ = util.FindMessageByID(app.SelectedChannel.Messages, m.ReferencedMessage.ID)
+			app.MessagesTextView.
+				Highlight(m.ReferencedMessage.ID).
+				ScrollToHighlight()
+		}
+
+		return nil
+	} else if hasKeybinding(config.Keybindings.ReplySelectedMessage, e.Name()) {
+		hs := app.MessagesTextView.GetHighlights()
+		if len(hs) == 0 {
+			return nil
+		}
+
+		_, m := util.FindMessageByID(app.SelectedChannel.Messages, hs[0])
+		app.MessageInputField.SetTitle("Replying to " + m.Author.String())
+		app.SetFocus(app.MessageInputField)
+		return nil
+	} else if hasKeybinding(config.Keybindings.MentionReplySelectedMessage, e.Name()) {
+		hs := app.MessagesTextView.GetHighlights()
+		if len(hs) == 0 {
+			return nil
+		}
+
+		_, m := util.FindMessageByID(app.SelectedChannel.Messages, hs[0])
+		app.MessageInputField.SetTitle("[@] Replying to " + m.Author.String())
+		app.SetFocus(app.MessageInputField)
+		return nil
+	} else if hasKeybinding(config.Keybindings.CopySelectedMessage, e.Name()) {
+		hs := app.MessagesTextView.GetHighlights()
+		if len(hs) == 0 {
+			return nil
+		}
+
+		_, m := util.FindMessageByID(app.SelectedChannel.Messages, hs[0])
+		err := clipboard.WriteAll(m.Content)
+		if err != nil {
+			return nil
+		}
+
+		return nil
+	}
+
+	return e
+}
+
+func onMessageInputFieldInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey {
+	switch e.Key() {
+	case tcell.KeyEnter:
+		if app.SelectedChannel == nil {
+			return nil
+		}
+
+		t := strings.TrimSpace(app.MessageInputField.GetText())
+		if t == "" {
+			return nil
+		}
+
+		if len(app.MessagesTextView.GetHighlights()) != 0 {
+			_, m := util.FindMessageByID(app.SelectedChannel.Messages, app.MessagesTextView.GetHighlights()[0])
+			d := &discordgo.MessageSend{
+				Content:         t,
+				Reference:       m.Reference(),
+				AllowedMentions: &discordgo.MessageAllowedMentions{RepliedUser: false},
+			}
+			if strings.HasPrefix(app.MessageInputField.GetTitle(), "[@]") {
+				d.AllowedMentions.RepliedUser = true
+			} else {
+				d.AllowedMentions.RepliedUser = false
+			}
+
+			go app.Session.ChannelMessageSendComplex(m.ChannelID, d)
+
+			app.SelectedMessage = -1
+			app.MessagesTextView.Highlight()
+
+			app.MessageInputField.SetTitle("")
+		} else {
+			go app.Session.ChannelMessageSend(app.SelectedChannel.ID, t)
+		}
+
+		app.MessageInputField.SetText("")
+		return nil
+	case tcell.KeyCtrlV:
+		text, _ := clipboard.ReadAll()
+		text = app.MessageInputField.GetText() + text
+		app.MessageInputField.SetText(text)
+		return nil
+	case tcell.KeyEscape:
+		app.MessageInputField.SetText("")
+		app.MessageInputField.SetTitle("")
+
+		app.SelectedMessage = -1
+		app.MessagesTextView.Highlight()
+		return nil
+	}
+
+	return e
+}

+ 32 - 122
discord.go → ui/util.go

@@ -1,16 +1,11 @@
-package main
+package ui
 
 import (
-	"encoding/json"
 	"fmt"
 	"regexp"
-	"sort"
 	"strings"
 
 	"github.com/ayntgl/discordgo"
-	"github.com/ayntgl/discordo/config"
-	"github.com/gen2brain/beeep"
-	"github.com/rivo/tview"
 )
 
 var (
@@ -20,121 +15,36 @@ var (
 	strikeThroughRegex = regexp.MustCompile(`(?m)~~(.*?)~~`)
 )
 
-func onSessionReady(_ *discordgo.Session, r *discordgo.Ready) {
-	dmNode := tview.NewTreeNode("Direct Messages").
-		Collapse()
-	n := channelsTreeView.GetRoot()
-	n.AddChild(dmNode)
-
-	sort.Slice(r.Guilds, func(a, b int) bool {
-		found := false
-		for _, gID := range r.Settings.GuildPositions {
-			if found {
-				if gID == r.Guilds[b].ID {
-					return true
-				}
-			} else {
-				if gID == r.Guilds[a].ID {
-					found = true
-				}
-			}
-		}
-
-		return false
-	})
-
-	for _, g := range r.Guilds {
-		gn := tview.NewTreeNode(g.Name).
-			SetReference(g.ID).
-			Collapse()
-		n.AddChild(gn)
-	}
-
-	channelsTreeView.SetCurrentNode(n)
-}
-
-func onSessionMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) {
-	if selectedChannel == nil || selectedChannel.ID != m.ChannelID {
-		if config.General.Notifications {
-			for _, u := range m.Mentions {
-				if u.ID == app.Session.State.User.ID {
-					g, err := app.Session.State.Guild(m.GuildID)
-					if err != nil {
-						return
-					}
-
-					c, err := app.Session.State.Channel(m.ChannelID)
-					if err != nil {
-						return
-					}
-
-					go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "")
-				}
-			}
-		}
+func channelToString(c *discordgo.Channel) string {
+	var repr string
+	if c.Name != "" {
+		repr = "#" + c.Name
+	} else if len(c.Recipients) == 1 {
+		rp := c.Recipients[0]
+		repr = rp.Username + "#" + rp.Discriminator
 	} else {
-		selectedChannel.Messages = append(selectedChannel.Messages, m.Message)
-		messagesTextView.Write(buildMessage(m.Message))
-
-		// Scroll to the end of the text if a message is not selected.
-		if len(messagesTextView.GetHighlights()) == 0 {
-			messagesTextView.ScrollToEnd()
+		rps := make([]string, len(c.Recipients))
+		for i, r := range c.Recipients {
+			rps[i] = r.Username + "#" + r.Discriminator
 		}
-	}
-}
-
-type loginResponse struct {
-	MFA    bool   `json:"mfa"`
-	SMS    bool   `json:"sms"`
-	Ticket string `json:"ticket"`
-	Token  string `json:"token"`
-}
 
-func login(email, password string) (*loginResponse, error) {
-	data := struct {
-		Email    string `json:"email"`
-		Password string `json:"password"`
-	}{email, password}
-	resp, err := app.Session.RequestWithBucketID(
-		"POST",
-		discordgo.EndpointLogin,
-		data,
-		discordgo.EndpointLogin,
-	)
-	if err != nil {
-		return nil, err
+		repr = strings.Join(rps, ", ")
 	}
 
-	var lr loginResponse
-	err = json.Unmarshal(resp, &lr)
-	if err != nil {
-		return nil, err
-	}
-
-	return &lr, nil
+	return repr
 }
 
-func totp(code, ticket string) (*loginResponse, error) {
-	data := struct {
-		Code   string `json:"code"`
-		Ticket string `json:"ticket"`
-	}{code, ticket}
-	e := discordgo.EndpointAuth + "mfa/totp"
-	resp, err := app.Session.RequestWithBucketID("POST", e, data, e)
-	if err != nil {
-		return nil, err
-	}
-
-	var lr loginResponse
-	err = json.Unmarshal(resp, &lr)
-	if err != nil {
-		return nil, err
+func hasKeybinding(ks []string, k string) bool {
+	for _, repr := range ks {
+		if repr == k {
+			return true
+		}
 	}
 
-	return &lr, nil
+	return false
 }
 
-func buildMessage(m *discordgo.Message) []byte {
+func buildMessage(app *App, m *discordgo.Message) []byte {
 	var b strings.Builder
 
 	switch m.Type {
@@ -146,11 +56,11 @@ func buildMessage(m *discordgo.Message) []byte {
 		b.WriteString(m.ID)
 		b.WriteString("\"]")
 		// Build the message associated with crosspost, channel follow add, pin, or a reply.
-		buildReferencedMessage(&b, m.ReferencedMessage)
+		buildReferencedMessage(&b, m.ReferencedMessage, app.Session.State.User.ID)
 		// Build the author of this message.
-		buildAuthor(&b, m.Author)
+		buildAuthor(&b, m.Author, app.Session.State.User.ID)
 		// Build the contents of the message.
-		buildContent(&b, m)
+		buildContent(&b, m, app.Session.State.User.ID)
 
 		if m.EditedTimestamp != "" {
 			b.WriteString(" [::d](edited)[::-]")
@@ -194,14 +104,14 @@ func buildMessage(m *discordgo.Message) []byte {
 	return nil
 }
 
-func buildReferencedMessage(b *strings.Builder, rm *discordgo.Message) {
+func buildReferencedMessage(b *strings.Builder, rm *discordgo.Message, clientID string) {
 	if rm != nil {
 		b.WriteString(" ╭ ")
 		b.WriteString("[::d]")
-		buildAuthor(b, rm.Author)
+		buildAuthor(b, rm.Author, clientID)
 
 		if rm.Content != "" {
-			rm.Content = buildMentions(rm.Content, rm.Mentions)
+			rm.Content = buildMentions(rm.Content, rm.Mentions, clientID)
 			b.WriteString(parseMarkdown(rm.Content))
 		}
 
@@ -210,9 +120,9 @@ func buildReferencedMessage(b *strings.Builder, rm *discordgo.Message) {
 	}
 }
 
-func buildContent(b *strings.Builder, m *discordgo.Message) {
+func buildContent(b *strings.Builder, m *discordgo.Message, clientID string) {
 	if m.Content != "" {
-		m.Content = buildMentions(m.Content, m.Mentions)
+		m.Content = buildMentions(m.Content, m.Mentions, clientID)
 		b.WriteString(parseMarkdown(m.Content))
 	}
 }
@@ -292,10 +202,10 @@ func buildAttachments(b *strings.Builder, as []*discordgo.MessageAttachment) {
 	}
 }
 
-func buildMentions(content string, mentions []*discordgo.User) string {
+func buildMentions(content string, mentions []*discordgo.User, clientID string) string {
 	for _, mUser := range mentions {
 		var color string
-		if mUser.ID == app.Session.State.User.ID {
+		if mUser.ID == clientID {
 			color = "[:#5865F2]"
 		} else {
 			color = "[#EB459E]"
@@ -314,8 +224,8 @@ func buildMentions(content string, mentions []*discordgo.User) string {
 	return content
 }
 
-func buildAuthor(b *strings.Builder, u *discordgo.User) {
-	if u.ID == app.Session.State.User.ID {
+func buildAuthor(b *strings.Builder, u *discordgo.User, clientID string) {
+	if u.ID == clientID {
 		b.WriteString("[#57F287]")
 	} else {
 		b.WriteString("[#ED4245]")

+ 67 - 1
ui/views.go

@@ -1,6 +1,72 @@
 package ui
 
-import "github.com/rivo/tview"
+import (
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+func DrawMainFlex(app *App) {
+	app.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
+		return onAppInputCapture(app, e)
+	})
+
+	app.GuildsList.
+		ShowSecondaryText(false).
+		AddItem("Direct Messages", "", 0, nil).
+		SetSelectedFunc(func(guildIdx int, _ string, _ string, _ rune) {
+			onGuildsListSelected(app, guildIdx)
+		}).
+		SetTitle("Guilds").
+		SetTitleAlign(tview.AlignLeft).
+		SetBorder(true).
+		SetBorderPadding(0, 0, 1, 1)
+
+	app.ChannelsTreeView.
+		SetTopLevel(1).
+		SetRoot(tview.NewTreeNode("")).
+		SetSelectedFunc(func(n *tview.TreeNode) {
+			onChannelsTreeViewSelected(app, n)
+		}).
+		SetTitle("Channels").
+		SetTitleAlign(tview.AlignLeft).
+		SetBorder(true).
+		SetBorderPadding(0, 0, 1, 1)
+
+	app.MessagesTextView.
+		SetRegions(true).
+		SetDynamicColors(true).
+		SetWordWrap(true).
+		SetChangedFunc(func() { app.Draw() }).
+		SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
+			return onMessagesTextViewInputCapture(app, e)
+		}).
+		SetTitleAlign(tview.AlignLeft).
+		SetBorder(true).
+		SetBorderPadding(0, 0, 1, 1)
+
+	app.MessageInputField.
+		SetPlaceholder("Message...").
+		SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
+		SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
+			return onMessageInputFieldInputCapture(app, e)
+		}).
+		SetTitleAlign(tview.AlignLeft).
+		SetBorder(true).
+		SetBorderPadding(0, 0, 1, 0)
+
+	leftFlex := tview.NewFlex().
+		SetDirection(tview.FlexRow).
+		AddItem(app.GuildsList, 10, 1, false).
+		AddItem(app.ChannelsTreeView, 0, 1, false)
+	rightFlex := tview.NewFlex().
+		SetDirection(tview.FlexRow).
+		AddItem(app.MessagesTextView, 0, 1, false).
+		AddItem(app.MessageInputField, 3, 1, false)
+
+	app.MainFlex.
+		AddItem(leftFlex, 0, 1, false).
+		AddItem(rightFlex, 0, 4, false)
+}
 
 func NewLoginForm(onLoginFormLoginButtonSelected func(), mfa bool) *tview.Form {
 	f := tview.NewForm()

+ 3 - 4
util/discord.go

@@ -1,8 +1,9 @@
 package util
 
-import "github.com/ayntgl/discordgo"
+import (
+	"github.com/ayntgl/discordgo"
+)
 
-// FindMessageByID returns the index and the `*Message` struct of the current message if the given message ID *mID* is equal to the current message ID. If the given message ID *mID* is not found in the given slice *ms*, `-1` and `nil` are returned instead.
 func FindMessageByID(ms []*discordgo.Message, mID string) (int, *discordgo.Message) {
 	for i, m := range ms {
 		if m.ID == mID {
@@ -13,7 +14,6 @@ func FindMessageByID(ms []*discordgo.Message, mID string) (int, *discordgo.Messa
 	return -1, nil
 }
 
-// ChannelIsUnread returns `true` if the given channel is marked as read by the client user, otherwise `false`.
 func ChannelIsUnread(s *discordgo.State, c *discordgo.Channel) bool {
 	if c.LastMessageID == "" {
 		return false
@@ -28,7 +28,6 @@ func ChannelIsUnread(s *discordgo.State, c *discordgo.Channel) bool {
 	return false
 }
 
-// HasPermission returns a boolean that indicates whether the client user has the given permission *p* in the given channel ID *cID*.
 func HasPermission(s *discordgo.State, cID string, p int64) bool {
 	perm, err := s.UserChannelPermissions(s.User.ID, cID)
 	if err != nil {

+ 0 - 11
util/ui.go

@@ -60,14 +60,3 @@ func CreateChannelNode(s *discordgo.State, c *discordgo.Channel) *tview.TreeNode
 
 	return cn
 }
-
-// HasKeybinding returns a boolean that indicates whether the given keybinding string representation *k* is in the slice *ks*.
-func HasKeybinding(ks []string, k string) bool {
-	for _, repr := range ks {
-		if repr == k {
-			return true
-		}
-	}
-
-	return false
-}