Browse Source

Switch to a flat project structure (#48)

ayntgl 4 years ago
parent
commit
f945fbf4f8
9 changed files with 676 additions and 745 deletions
  1. 5 8
      config.go
  2. 144 0
      discord.go
  3. 0 455
      discordo.go
  4. 140 0
      main.go
  5. 24 83
      renderer.go
  6. 363 0
      ui.go
  7. 0 119
      ui/guilds.go
  8. 0 40
      ui/messages.go
  9. 0 40
      ui/misc.go

+ 5 - 8
util/config.go → config.go

@@ -1,4 +1,4 @@
-package util
+package main
 
 import (
 	"encoding/json"
@@ -7,9 +7,7 @@ import (
 	"github.com/rivo/tview"
 )
 
-// Config consists of fields, such as theme, mouse, so on, that may be
-// customized by the user.
-type Config struct {
+type config struct {
 	Token            string      `json:"token"`
 	Mouse            bool        `json:"mouse"`
 	Notifications    bool        `json:"notifications"`
@@ -18,8 +16,7 @@ type Config struct {
 	Theme            tview.Theme `json:"theme"`
 }
 
-// LoadConfig creates (if the configuration file does not exist) and reads the configuration file and returns a new config.
-func LoadConfig() *Config {
+func loadConfig() *config {
 	u, err := os.UserHomeDir()
 	if err != nil {
 		panic(err)
@@ -32,7 +29,7 @@ func LoadConfig() *Config {
 			panic(err)
 		}
 
-		c := Config{
+		c := config{
 			Mouse:         true,
 			Notifications: true,
 			UserAgent: "" +
@@ -60,7 +57,7 @@ func LoadConfig() *Config {
 		panic(err)
 	}
 
-	var c Config
+	var c config
 	if err = json.Unmarshal(d, &c); err != nil {
 		panic(err)
 	}

+ 144 - 0
discord.go

@@ -0,0 +1,144 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"sort"
+
+	"github.com/bwmarrin/discordgo"
+	"github.com/gen2brain/beeep"
+	"github.com/rivo/tview"
+)
+
+func newSession() *discordgo.Session {
+	s, err := discordgo.New()
+	if err != nil {
+		panic(err)
+	}
+
+	s.UserAgent = conf.UserAgent
+	s.Identify.Compress = false
+	s.Identify.Intents = 0
+	s.Identify.LargeThreshold = 0
+	s.Identify.Properties.Device = ""
+	s.Identify.Properties.Browser = "Chrome"
+	s.Identify.Properties.OS = "Linux"
+
+	s.AddHandlerOnce(onSessionReady)
+	s.AddHandler(onSessionMessageCreate)
+
+	return s
+}
+
+func onSessionReady(_ *discordgo.Session, r *discordgo.Ready) {
+	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
+	})
+
+	n := guildsTreeView.GetRoot()
+	for _, g := range r.Guilds {
+		gn := tview.NewTreeNode(g.Name).
+			SetReference(g.ID)
+		n.AddChild(gn)
+	}
+
+	guildsTreeView.SetCurrentNode(n)
+}
+
+func onSessionMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) {
+	if selectedChannel == nil {
+		selectedChannel = &discordgo.Channel{ID: ""}
+	}
+
+	if selectedChannel.ID != m.ChannelID {
+		if conf.Notifications {
+			for _, u := range m.Mentions {
+				if u.ID == session.State.User.ID {
+					g, err := session.State.Guild(m.GuildID)
+					if err != nil {
+						return
+					}
+					c, err := session.State.Channel(m.ChannelID)
+					if err != nil {
+						return
+					}
+
+					go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "")
+					return
+				}
+			}
+		}
+
+		return
+	}
+
+	selectedChannel.Messages = append(selectedChannel.Messages, m.Message)
+	renderMessage(m.Message)
+}
+
+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, 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, 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 - 455
discordo.go

@@ -1,455 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"sort"
-	"strings"
-
-	"github.com/atotto/clipboard"
-	"github.com/bwmarrin/discordgo"
-	"github.com/gdamore/tcell/v2"
-	"github.com/gen2brain/beeep"
-	"github.com/rigormorrtiss/discordo/ui"
-	"github.com/rigormorrtiss/discordo/util"
-	"github.com/rivo/tview"
-	"github.com/zalando/go-keyring"
-)
-
-var (
-	app            *tview.Application
-	loginWidget    *tview.Form
-	guildsWidget   *tview.TreeView
-	messagesWidget *tview.TextView
-	inputWidget    *tview.InputField
-	mainFlex       *tview.Flex
-
-	config          *util.Config
-	session         *discordgo.Session
-	selectedChannel *discordgo.Channel
-	selectedMessage *discordgo.Message
-)
-
-func main() {
-	config = util.LoadConfig()
-	tview.Styles = config.Theme
-
-	app = tview.NewApplication()
-	app.EnableMouse(config.Mouse)
-	app.SetInputCapture(onAppInputCapture)
-
-	guildsWidget = ui.NewGuildsWidget()
-	guildsWidget.SetSelectedFunc(onGuildsWidgetSelected)
-
-	messagesWidget = ui.NewMessagesWidget(app)
-	messagesWidget.SetInputCapture(onMessagesWidgetInputCapture)
-
-	inputWidget = ui.NewInputWidget()
-	inputWidget.SetInputCapture(onInputWidgetInputCapture)
-
-	mainFlex = ui.NewMainFlex(
-		guildsWidget,
-		messagesWidget,
-		inputWidget,
-	)
-
-	token := config.Token
-	if t, _ := keyring.Get("discordo", "token"); t != "" {
-		token = t
-	}
-
-	if token != "" {
-		app.
-			SetRoot(mainFlex, true).
-			SetFocus(guildsWidget)
-
-		session = newSession()
-		session.Token = token
-		session.Identify.Token = token
-		if err := session.Open(); err != nil {
-			panic(err)
-		}
-	} else {
-		loginWidget = ui.NewLoginWidget(onLoginFormLoginButtonSelected, false)
-		app.SetRoot(loginWidget, true)
-	}
-
-	if err := app.Run(); err != nil {
-		panic(err)
-	}
-}
-
-func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	if e.Modifiers() == tcell.ModAlt {
-		switch e.Rune() {
-		case '1':
-			app.SetFocus(guildsWidget)
-		case '2':
-			app.SetFocus(messagesWidget)
-		case '3':
-			app.SetFocus(inputWidget)
-		}
-	}
-
-	return e
-}
-
-func findByMessageID(ms []*discordgo.Message, mID string) (int, *discordgo.Message) {
-	for i, m := range ms {
-		if mID == m.ID {
-			return i, m
-		}
-	}
-
-	return -1, nil
-}
-
-func onMessagesWidgetInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	if selectedChannel == nil {
-		return nil
-	}
-
-	switch {
-	case e.Key() == tcell.KeyUp || e.Rune() == 'k': // Up
-		ms := selectedChannel.Messages
-		if len(ms) == 0 {
-			return nil
-		}
-
-		hs := messagesWidget.GetHighlights()
-		if len(hs) == 0 {
-			messagesWidget.
-				Highlight(ms[len(ms)-1].ID).
-				ScrollToHighlight()
-		} else {
-			idx, _ := findByMessageID(ms, hs[0])
-			if idx == -1 || idx == 0 {
-				return nil
-			}
-
-			messagesWidget.
-				Highlight(ms[idx-1].ID).
-				ScrollToHighlight()
-		}
-
-		return nil
-	case e.Key() == tcell.KeyDown || e.Rune() == 'j': // Down
-		ms := selectedChannel.Messages
-		if len(ms) == 0 {
-			return nil
-		}
-
-		hs := messagesWidget.GetHighlights()
-		if len(hs) == 0 {
-			messagesWidget.
-				Highlight(ms[len(ms)-1].ID).
-				ScrollToHighlight()
-		} else {
-			idx, _ := findByMessageID(ms, hs[0])
-			if idx == -1 || idx == len(ms)-1 {
-				return nil
-			}
-
-			messagesWidget.
-				Highlight(ms[idx+1].ID).
-				ScrollToHighlight()
-		}
-
-		return nil
-	case e.Key() == tcell.KeyHome || e.Rune() == 'g': // Top
-		ms := selectedChannel.Messages
-		if len(ms) == 0 {
-			return nil
-		}
-
-		messagesWidget.
-			Highlight(ms[0].ID).
-			ScrollToHighlight()
-	case e.Key() == tcell.KeyEnd || e.Rune() == 'G': // Bottom
-		ms := selectedChannel.Messages
-		if len(ms) == 0 {
-			return nil
-		}
-
-		messagesWidget.
-			Highlight(ms[len(ms)-1].ID).
-			ScrollToHighlight()
-	case e.Rune() == 'r': // Inline reply
-		ms := selectedChannel.Messages
-		if len(ms) == 0 {
-			return nil
-		}
-
-		hs := messagesWidget.GetHighlights()
-		if len(hs) == 0 {
-			return nil
-		}
-
-		_, selectedMessage = findByMessageID(ms, hs[0])
-		inputWidget.SetTitle(
-			"Replying to " + selectedMessage.Author.Username,
-		)
-		app.SetFocus(inputWidget)
-	}
-
-	return e
-}
-
-func onInputWidgetInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	// If the "Alt" modifier key is pressed, do not handle the event.
-	if e.Modifiers() == tcell.ModAlt {
-		return nil
-	}
-
-	switch e.Key() {
-	case tcell.KeyEnter:
-		if selectedChannel == nil {
-			return nil
-		}
-
-		t := strings.TrimSpace(inputWidget.GetText())
-		if t == "" {
-			return nil
-		}
-
-		if selectedMessage != nil {
-			inputWidget.SetTitle("")
-			go session.ChannelMessageSendReply(
-				selectedMessage.ChannelID,
-				t,
-				selectedMessage.Reference(),
-			)
-
-			selectedMessage = nil
-		} else {
-			go session.ChannelMessageSend(selectedChannel.ID, t)
-		}
-
-		inputWidget.SetText("")
-	case tcell.KeyCtrlV:
-		text, _ := clipboard.ReadAll()
-		text = inputWidget.GetText() + text
-		inputWidget.SetText(text)
-	case tcell.KeyEscape:
-		inputWidget.SetTitle("")
-		selectedMessage = nil
-	}
-
-	return e
-}
-
-func newSession() *discordgo.Session {
-	s, err := discordgo.New()
-	if err != nil {
-		panic(err)
-	}
-
-	s.UserAgent = config.UserAgent
-	s.Identify.Compress = false
-	s.Identify.Intents = 0
-	s.Identify.LargeThreshold = 0
-	s.Identify.Properties.Device = ""
-	s.Identify.Properties.Browser = "Chrome"
-	s.Identify.Properties.OS = "Linux"
-
-	s.AddHandlerOnce(onSessionReady)
-	s.AddHandler(onSessionMessageCreate)
-
-	return s
-}
-
-func onSessionReady(_ *discordgo.Session, r *discordgo.Ready) {
-	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
-	})
-
-	n := guildsWidget.GetRoot()
-	for _, g := range r.Guilds {
-		gn := tview.NewTreeNode(g.Name).
-			SetReference(g.ID)
-		n.AddChild(gn)
-	}
-
-	guildsWidget.SetCurrentNode(n)
-}
-
-func onSessionMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
-	if selectedChannel == nil {
-		selectedChannel = &discordgo.Channel{ID: ""}
-	}
-
-	if selectedChannel.ID != m.ChannelID {
-		if config.Notifications {
-			for _, u := range m.Mentions {
-				if u.ID == s.State.User.ID {
-					g, err := s.State.Guild(m.GuildID)
-					if err != nil {
-						return
-					}
-					c, err := s.State.Channel(m.ChannelID)
-					if err != nil {
-						return
-					}
-
-					go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "")
-					return
-				}
-			}
-		}
-
-		return
-	}
-
-	selectedChannel.Messages = append(selectedChannel.Messages, m.Message)
-	util.WriteMessage(
-		messagesWidget,
-		m.Message,
-		session.State.Ready.User.ID,
-	)
-}
-
-func onGuildsWidgetSelected(n *tview.TreeNode) {
-	selectedChannel = nil
-	selectedMessage = nil
-	messagesWidget.
-		Clear().
-		SetTitle("")
-	// Unhighlight the already-highlighted regions.
-	messagesWidget.Highlight()
-
-	switch n.GetLevel() {
-	case 1:
-		if len(n.GetChildren()) != 0 {
-			n.SetExpanded(!n.IsExpanded())
-			return
-		}
-
-		n.ClearChildren()
-
-		gID := n.GetReference().(string)
-		g, err := session.State.Guild(gID)
-		if err != nil {
-			return
-		}
-
-		cs := g.Channels
-		sort.Slice(cs, func(i, j int) bool {
-			return cs[i].Position < cs[j].Position
-		})
-
-		// Top-level channels
-		ui.CreateTopLevelChannelsTreeNodes(session.State, n, cs)
-		// Category channels
-		ui.CreateCategoryChannelsTreeNodes(session.State, n, cs)
-		// Second-level channels
-		ui.CreateSecondLevelChannelsTreeNodes(session.State, guildsWidget, cs)
-	default:
-		cID := n.GetReference().(string)
-		c, err := session.State.Channel(cID)
-		if err != nil {
-			return
-		}
-
-		if c.Type == discordgo.ChannelTypeGuildCategory {
-			n.SetExpanded(!n.IsExpanded())
-		} else if c.Type == discordgo.ChannelTypeGuildNews || c.Type == discordgo.ChannelTypeGuildText {
-			selectedChannel = c
-			app.SetFocus(inputWidget)
-
-			title := "#" + c.Name
-			if c.Topic != "" {
-				title += " - " + c.Topic
-			}
-			messagesWidget.
-				Clear().
-				SetTitle(title)
-
-			go writeMessages(c.ID)
-		}
-	}
-}
-
-func writeMessages(cID string) {
-	ms, err := session.ChannelMessages(cID, config.GetMessagesLimit, "", "", "")
-	if err != nil {
-		return
-	}
-
-	for i := len(ms) - 1; i >= 0; i-- {
-		selectedChannel.Messages = append(selectedChannel.Messages, ms[i])
-
-		util.WriteMessage(
-			messagesWidget,
-			ms[i],
-			session.State.Ready.User.ID,
-		)
-	}
-}
-
-func onLoginFormLoginButtonSelected() {
-	email := loginWidget.GetFormItem(0).(*tview.InputField).GetText()
-	password := loginWidget.GetFormItem(1).(*tview.InputField).GetText()
-	if email == "" || password == "" {
-		return
-	}
-
-	session = newSession()
-	// Login using the email and password
-	lr, err := util.Login(session, email, password)
-	if err != nil {
-		panic(err)
-	}
-
-	if lr.Token != "" && !lr.MFA {
-		app.
-			SetRoot(mainFlex, true).
-			SetFocus(guildsWidget)
-
-		session.Token = lr.Token
-		session.Identify.Token = lr.Token
-		if err = session.Open(); err != nil {
-			panic(err)
-		}
-
-		go keyring.Set("discordo", "token", lr.Token)
-	} else if lr.MFA {
-		// The account has MFA enabled, reattempt login with code and ticket.
-		loginWidget = ui.NewLoginWidget(func() {
-			code := loginWidget.GetFormItem(0).(*tview.InputField).GetText()
-			if code == "" {
-				return
-			}
-
-			lr, err = util.TOTP(session, code, lr.Ticket)
-			if err != nil {
-				panic(err)
-			}
-
-			app.
-				SetRoot(mainFlex, true).
-				SetFocus(guildsWidget)
-
-			session.Token = lr.Token
-			session.Identify.Token = lr.Token
-			if err = session.Open(); err != nil {
-				panic(err)
-			}
-
-			go keyring.Set("discordo", "token", lr.Token)
-		}, true)
-
-		app.SetRoot(loginWidget, true)
-	}
-}

+ 140 - 0
main.go

@@ -0,0 +1,140 @@
+package main
+
+import (
+	"github.com/bwmarrin/discordgo"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+	"github.com/zalando/go-keyring"
+)
+
+var (
+	app               *tview.Application
+	loginForm         *tview.Form
+	guildsTreeView    *tview.TreeView
+	messagesTextView  *tview.TextView
+	messageInputField *tview.InputField
+	mainFlex          *tview.Flex
+
+	conf            *config
+	session         *discordgo.Session
+	selectedChannel *discordgo.Channel
+	selectedMessage *discordgo.Message
+)
+
+func main() {
+	conf = loadConfig()
+	tview.Styles = conf.Theme
+
+	app = tview.NewApplication()
+	app.
+		EnableMouse(conf.Mouse).
+		SetInputCapture(onAppInputCapture)
+
+	guildsTreeView = newGuildsTreeView()
+	messagesTextView = newMessagesTextView()
+	messageInputField = newMessageInputField()
+
+	rightFlex := tview.NewFlex().
+		SetDirection(tview.FlexRow).
+		AddItem(messagesTextView, 0, 1, false).
+		AddItem(messageInputField, 3, 1, false)
+	mainFlex = tview.NewFlex().
+		AddItem(guildsTreeView, 0, 1, false).
+		AddItem(rightFlex, 0, 4, false)
+
+	token := conf.Token
+	if t, _ := keyring.Get("discordo", "token"); t != "" {
+		token = t
+	}
+
+	if token != "" {
+		app.
+			SetRoot(mainFlex, true).
+			SetFocus(guildsTreeView)
+
+		session = newSession()
+		session.Token = token
+		session.Identify.Token = token
+		if err := session.Open(); err != nil {
+			panic(err)
+		}
+	} else {
+		loginForm = newLoginForm(onLoginFormLoginButtonSelected, false)
+		app.SetRoot(loginForm, true)
+	}
+
+	if err := app.Run(); err != nil {
+		panic(err)
+	}
+}
+
+func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey {
+	if e.Modifiers() == tcell.ModAlt {
+		switch e.Rune() {
+		case '1':
+			app.SetFocus(guildsTreeView)
+		case '2':
+			app.SetFocus(messagesTextView)
+		case '3':
+			app.SetFocus(messageInputField)
+		}
+	}
+
+	return e
+}
+
+func onLoginFormLoginButtonSelected() {
+	email := loginForm.GetFormItem(0).(*tview.InputField).GetText()
+	password := loginForm.GetFormItem(1).(*tview.InputField).GetText()
+	if email == "" || password == "" {
+		return
+	}
+
+	session = newSession()
+	// Login using the email and password
+	lr, err := login(session, email, password)
+	if err != nil {
+		panic(err)
+	}
+
+	if lr.Token != "" && !lr.MFA {
+		app.
+			SetRoot(mainFlex, true).
+			SetFocus(guildsTreeView)
+
+		session.Token = lr.Token
+		session.Identify.Token = lr.Token
+		if err = session.Open(); err != nil {
+			panic(err)
+		}
+
+		go keyring.Set("discordo", "token", lr.Token)
+	} else if lr.MFA {
+		// The account has MFA enabled, reattempt login with code and ticket.
+		loginForm = newLoginForm(func() {
+			code := loginForm.GetFormItem(0).(*tview.InputField).GetText()
+			if code == "" {
+				return
+			}
+
+			lr, err = totp(session, code, lr.Ticket)
+			if err != nil {
+				panic(err)
+			}
+
+			app.
+				SetRoot(mainFlex, true).
+				SetFocus(guildsTreeView)
+
+			session.Token = lr.Token
+			session.Identify.Token = lr.Token
+			if err = session.Open(); err != nil {
+				panic(err)
+			}
+
+			go keyring.Set("discordo", "token", lr.Token)
+		}, true)
+
+		app.SetRoot(loginForm, true)
+	}
+}

+ 24 - 83
util/discord.go → renderer.go

@@ -1,16 +1,25 @@
-package util
+package main
 
 import (
-	"encoding/json"
 	"fmt"
 	"strings"
 
 	"github.com/bwmarrin/discordgo"
-	"github.com/rivo/tview"
 )
 
-// WriteMessage parses, renders, and writes a message to the given TextView.
-func WriteMessage(v *tview.TextView, m *discordgo.Message, clientID string) {
+func renderMessages(cID string) {
+	ms, err := session.ChannelMessages(cID, conf.GetMessagesLimit, "", "", "")
+	if err != nil {
+		return
+	}
+
+	for i := len(ms) - 1; i >= 0; i-- {
+		selectedChannel.Messages = append(selectedChannel.Messages, ms[i])
+		renderMessage(ms[i])
+	}
+}
+
+func renderMessage(m *discordgo.Message) {
 	var b strings.Builder
 
 	switch m.Type {
@@ -26,25 +35,21 @@ func WriteMessage(v *tview.TextView, m *discordgo.Message, clientID string) {
 		if rm := m.ReferencedMessage; rm != nil {
 			b.WriteString(" ╭ ")
 			b.WriteString("[::d]")
-			parseAuthor(&b, rm.Author, clientID)
+			parseAuthor(&b, rm.Author)
 
 			if rm.Content != "" {
-				rm.Content = parseMentions(
-					rm.Content,
-					rm.Mentions,
-					clientID,
-				)
+				rm.Content = parseMentions(rm.Content, rm.Mentions)
 				b.WriteString(rm.Content)
 			}
 
 			b.WriteString("[::-]\n")
 		}
 		// Render the author of the message.
-		parseAuthor(&b, m.Author, clientID)
+		parseAuthor(&b, m.Author)
 		// If the message content is not empty, parse the message mentions
 		// (users mentioned in the message) and render the message content.
 		if m.Content != "" {
-			m.Content = parseMentions(m.Content, m.Mentions, clientID)
+			m.Content = parseMentions(m.Content, m.Mentions)
 			b.WriteString(m.Content)
 		}
 		// If the edited timestamp of the message is not empty; it implies that
@@ -68,24 +73,20 @@ func WriteMessage(v *tview.TextView, m *discordgo.Message, clientID string) {
 		// therefore be used to mark the end of a region.
 		b.WriteString("[\"\"]")
 
-		fmt.Fprintln(v, b.String())
+		fmt.Fprintln(messagesTextView, b.String())
 	case discordgo.MessageTypeGuildMemberJoin:
 		b.WriteString("[#5865F2]")
 		b.WriteString(m.Author.Username)
 		b.WriteString("[-] joined the server")
 
-		fmt.Fprintln(v, b.String())
+		fmt.Fprintln(messagesTextView, b.String())
 	}
 }
 
-func parseMentions(
-	content string,
-	mentions []*discordgo.User,
-	clientID string,
-) string {
+func parseMentions(content string, mentions []*discordgo.User) string {
 	for _, mUser := range mentions {
 		var color string
-		if mUser.ID == clientID {
+		if mUser.ID == session.State.User.ID {
 			color = "[:#5865F2]"
 		} else {
 			color = "[#EB459E]"
@@ -104,10 +105,8 @@ func parseMentions(
 	return content
 }
 
-func parseAuthor(b *strings.Builder, u *discordgo.User, clientID string) {
-	// If the message author is the client, modify the text color for
-	// distinction.
-	if u.ID == clientID {
+func parseAuthor(b *strings.Builder, u *discordgo.User) {
+	if u.ID == session.State.User.ID {
 		b.WriteString("[#57F287]")
 	} else {
 		b.WriteString("[#ED4245]")
@@ -121,61 +120,3 @@ func parseAuthor(b *strings.Builder, u *discordgo.User, clientID string) {
 		b.WriteString("[#EB459E]BOT[-] ")
 	}
 }
-
-type loginResponse struct {
-	MFA    bool   `json:"mfa"`
-	SMS    bool   `json:"sms"`
-	Ticket string `json:"ticket"`
-	Token  string `json:"token"`
-}
-
-// Login creates a new request to the `/login` endpoint for essential login
-// information.
-func Login(
-	s *discordgo.Session,
-	email, 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
-}
-
-// TOTP creates a new request to `/mfa/totp` endpoint for time-based one-time
-// passcode for essential login information
-func TOTP(s *discordgo.Session, code, 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
-}

+ 363 - 0
ui.go

@@ -0,0 +1,363 @@
+package main
+
+import (
+	"sort"
+	"strings"
+
+	"github.com/atotto/clipboard"
+	"github.com/bwmarrin/discordgo"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+func newGuildsTreeView() *tview.TreeView {
+	w := tview.NewTreeView()
+	w.
+		SetSelectedFunc(onGuildsTreeViewSelected).
+		SetTopLevel(1).
+		SetRoot(tview.NewTreeNode("")).
+		SetBorder(true).
+		SetBorderPadding(0, 0, 1, 0).
+		SetTitle("Guilds").
+		SetTitleAlign(tview.AlignLeft)
+
+	return w
+}
+
+func onGuildsTreeViewSelected(n *tview.TreeNode) {
+	selectedChannel = nil
+	selectedMessage = nil
+	messagesTextView.
+		Clear().
+		SetTitle("")
+	messageInputField.SetText("")
+	// Unhighlight the already-highlighted regions.
+	messagesTextView.Highlight()
+
+	switch n.GetLevel() {
+	case 1:
+		if len(n.GetChildren()) != 0 {
+			n.SetExpanded(!n.IsExpanded())
+			return
+		}
+
+		n.ClearChildren()
+
+		gID := n.GetReference().(string)
+		g, err := session.State.Guild(gID)
+		if err != nil {
+			return
+		}
+
+		cs := g.Channels
+		sort.Slice(cs, func(i, j int) bool {
+			return cs[i].Position < cs[j].Position
+		})
+
+		// Top-level channels
+		createTopLevelChannelsTreeNodes(n, cs)
+		// Category channels
+		createCategoryChannelsTreeNodes(n, cs)
+		// Second-level channels
+		createSecondLevelChannelsTreeNodes(cs)
+	default:
+		cID := n.GetReference().(string)
+		c, err := session.State.Channel(cID)
+		if err != nil {
+			return
+		}
+
+		if c.Type == discordgo.ChannelTypeGuildCategory {
+			n.SetExpanded(!n.IsExpanded())
+		} else if c.Type == discordgo.ChannelTypeGuildNews || c.Type == discordgo.ChannelTypeGuildText {
+			selectedChannel = c
+			app.SetFocus(messageInputField)
+
+			title := "#" + c.Name
+			if c.Topic != "" {
+				title += " - " + c.Topic
+			}
+			messagesTextView.
+				Clear().
+				SetTitle(title)
+
+			go renderMessages(c.ID)
+		}
+	}
+}
+
+func newTextChannelTreeNode(c *discordgo.Channel) *tview.TreeNode {
+	n := tview.NewTreeNode("[::d]#" + c.Name + "[::-]").
+		SetReference(c.ID)
+
+	return n
+}
+
+func createTopLevelChannelsTreeNodes(
+	n *tview.TreeNode,
+	cs []*discordgo.Channel,
+) {
+	for _, c := range cs {
+		if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) &&
+			(c.ParentID == "") {
+			if p, err := session.State.UserChannelPermissions(session.State.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel {
+				continue
+			}
+
+			cn := newTextChannelTreeNode(c)
+			n.AddChild(cn)
+			continue
+		}
+	}
+}
+
+func createCategoryChannelsTreeNodes(
+	n *tview.TreeNode,
+	cs []*discordgo.Channel,
+) {
+CategoryLoop:
+	for _, c := range cs {
+		if c.Type == discordgo.ChannelTypeGuildCategory {
+			if p, err := session.State.UserChannelPermissions(session.State.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel {
+				continue
+			}
+
+			for _, child := range cs {
+				if child.ParentID == c.ID {
+					cn := tview.NewTreeNode(c.Name).
+						SetReference(c.ID)
+					n.AddChild(cn)
+					continue CategoryLoop
+				}
+			}
+
+			cn := tview.NewTreeNode(c.Name).
+				SetReference(c.ID)
+			n.AddChild(cn)
+		}
+	}
+}
+
+func createSecondLevelChannelsTreeNodes(cs []*discordgo.Channel) {
+	for _, c := range cs {
+		if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) &&
+			(c.ParentID != "") {
+			if p, err := session.State.UserChannelPermissions(session.State.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel {
+				continue
+			}
+
+			if pn := getTreeNodeByReference(c.ParentID); pn != nil {
+				cn := newTextChannelTreeNode(c)
+				pn.AddChild(cn)
+			}
+		}
+	}
+}
+
+func getTreeNodeByReference(r interface{}) (mn *tview.TreeNode) {
+	guildsTreeView.GetRoot().Walk(func(n, _ *tview.TreeNode) bool {
+		if n.GetReference() == r {
+			mn = n
+			return false
+		}
+
+		return true
+	})
+
+	return
+}
+
+func newMessagesTextView() *tview.TextView {
+	w := tview.NewTextView()
+	w.
+		SetRegions(true).
+		SetDynamicColors(true).
+		SetWordWrap(true).
+		ScrollToEnd().
+		SetChangedFunc(func() {
+			app.Draw()
+		}).
+		SetInputCapture(onMessagesTextViewInputCapture).
+		SetBorder(true).
+		SetBorderPadding(0, 0, 1, 0).
+		SetTitleAlign(tview.AlignLeft)
+
+	return w
+}
+
+func findByMessageID(ms []*discordgo.Message, mID string) (int, *discordgo.Message) {
+	for i, m := range ms {
+		if mID == m.ID {
+			return i, m
+		}
+	}
+
+	return -1, nil
+}
+
+func onMessagesTextViewInputCapture(e *tcell.EventKey) *tcell.EventKey {
+	if selectedChannel == nil {
+		return nil
+	}
+
+	switch {
+	case e.Key() == tcell.KeyUp || e.Rune() == 'k': // Up
+		ms := selectedChannel.Messages
+		if len(ms) == 0 {
+			return nil
+		}
+
+		hs := messagesTextView.GetHighlights()
+		if len(hs) == 0 {
+			messagesTextView.
+				Highlight(ms[len(ms)-1].ID).
+				ScrollToHighlight()
+		} else {
+			idx, _ := findByMessageID(ms, hs[0])
+			if idx == -1 || idx == 0 {
+				return nil
+			}
+
+			messagesTextView.
+				Highlight(ms[idx-1].ID).
+				ScrollToHighlight()
+		}
+
+		return nil
+	case e.Key() == tcell.KeyDown || e.Rune() == 'j': // Down
+		ms := selectedChannel.Messages
+		if len(ms) == 0 {
+			return nil
+		}
+
+		hs := messagesTextView.GetHighlights()
+		if len(hs) == 0 {
+			messagesTextView.
+				Highlight(ms[len(ms)-1].ID).
+				ScrollToHighlight()
+		} else {
+			idx, _ := findByMessageID(ms, hs[0])
+			if idx == -1 || idx == len(ms)-1 {
+				return nil
+			}
+
+			messagesTextView.
+				Highlight(ms[idx+1].ID).
+				ScrollToHighlight()
+		}
+
+		return nil
+	case e.Key() == tcell.KeyHome || e.Rune() == 'g': // Top
+		ms := selectedChannel.Messages
+		if len(ms) == 0 {
+			return nil
+		}
+
+		messagesTextView.
+			Highlight(ms[0].ID).
+			ScrollToHighlight()
+	case e.Key() == tcell.KeyEnd || e.Rune() == 'G': // Bottom
+		ms := selectedChannel.Messages
+		if len(ms) == 0 {
+			return nil
+		}
+
+		messagesTextView.
+			Highlight(ms[len(ms)-1].ID).
+			ScrollToHighlight()
+	case e.Rune() == 'r': // Inline reply
+		ms := selectedChannel.Messages
+		if len(ms) == 0 {
+			return nil
+		}
+
+		hs := messagesTextView.GetHighlights()
+		if len(hs) == 0 {
+			return nil
+		}
+
+		_, selectedMessage = findByMessageID(ms, hs[0])
+		messageInputField.SetTitle(
+			"Replying to " + selectedMessage.Author.Username,
+		)
+		app.SetFocus(messageInputField)
+	}
+
+	return e
+}
+
+func newMessageInputField() *tview.InputField {
+	w := tview.NewInputField()
+	w.
+		SetPlaceholder("Message...").
+		SetPlaceholderTextColor(tcell.ColorWhite).
+		SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
+		SetInputCapture(onMessageInputFieldInputCapture).
+		SetBorder(true).
+		SetBorderPadding(0, 0, 1, 0).
+		SetTitleAlign(tview.AlignLeft)
+
+	return w
+}
+
+func onMessageInputFieldInputCapture(e *tcell.EventKey) *tcell.EventKey {
+	// If the "Alt" modifier key is pressed, do not handle the event.
+	if e.Modifiers() == tcell.ModAlt {
+		return nil
+	}
+
+	switch e.Key() {
+	case tcell.KeyEnter:
+		if selectedChannel == nil {
+			return nil
+		}
+
+		t := strings.TrimSpace(messageInputField.GetText())
+		if t == "" {
+			return nil
+		}
+
+		if selectedMessage != nil {
+			messageInputField.SetTitle("")
+			go session.ChannelMessageSendReply(
+				selectedMessage.ChannelID,
+				t,
+				selectedMessage.Reference(),
+			)
+
+			selectedMessage = nil
+		} else {
+			go session.ChannelMessageSend(selectedChannel.ID, t)
+		}
+
+		messageInputField.SetText("")
+	case tcell.KeyCtrlV:
+		text, _ := clipboard.ReadAll()
+		text = messageInputField.GetText() + text
+		messageInputField.SetText(text)
+	case tcell.KeyEscape:
+		messageInputField.SetTitle("")
+		selectedMessage = nil
+	}
+
+	return e
+}
+
+func newLoginForm(onLoginFormLoginButtonSelected func(), mfa bool) *tview.Form {
+	w := tview.NewForm()
+	w.
+		AddButton("Login", onLoginFormLoginButtonSelected).
+		SetButtonsAlign(tview.AlignCenter).
+		SetBorder(true).
+		SetBorderPadding(0, 0, 1, 0)
+
+	if mfa {
+		w.AddPasswordField("Code", "", 0, 0, nil)
+	} else {
+		w.
+			AddInputField("Email", "", 0, nil, nil).
+			AddPasswordField("Password", "", 0, 0, nil)
+	}
+
+	return w
+}

+ 0 - 119
ui/guilds.go

@@ -1,119 +0,0 @@
-package ui
-
-import (
-	"github.com/bwmarrin/discordgo"
-	"github.com/rivo/tview"
-)
-
-// NewGuildsWidget creates and returns a new guilds widget.
-func NewGuildsWidget() *tview.TreeView {
-	w := tview.NewTreeView()
-	w.
-		SetTopLevel(1).
-		SetRoot(tview.NewTreeNode("")).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0).
-		SetTitle("Guilds").
-		SetTitleAlign(tview.AlignLeft)
-
-	return w
-}
-
-// NewTextChannelTreeNode creates and returns a new text channel treenode.
-func NewTextChannelTreeNode(c *discordgo.Channel) *tview.TreeNode {
-	n := tview.NewTreeNode("[::d]#" + c.Name + "[::-]").
-		SetReference(c.ID)
-
-	return n
-}
-
-// GetTreeNodeByReference gets the TreeNode that has reference r from the given
-// treeview.
-func GetTreeNodeByReference(
-	r interface{},
-	treeV *tview.TreeView,
-) (mn *tview.TreeNode) {
-	treeV.GetRoot().Walk(func(n, _ *tview.TreeNode) bool {
-		if n.GetReference() == r {
-			mn = n
-			return false
-		}
-
-		return true
-	})
-
-	return
-}
-
-// CreateTopLevelChannelsTreeNodes creates TreeNodes for the top-level (orphan)
-// channels.
-func CreateTopLevelChannelsTreeNodes(
-	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 p, err := s.UserChannelPermissions(s.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel {
-				continue
-			}
-
-			cn := NewTextChannelTreeNode(c)
-			n.AddChild(cn)
-			continue
-		}
-	}
-}
-
-// CreateCategoryChannelsTreeNodes creates TreeNodes for the category (parent)
-// channels.
-func CreateCategoryChannelsTreeNodes(
-	s *discordgo.State,
-	n *tview.TreeNode,
-	cs []*discordgo.Channel,
-) {
-CategoryLoop:
-	for _, c := range cs {
-		if c.Type == discordgo.ChannelTypeGuildCategory {
-			if p, err := s.UserChannelPermissions(s.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel {
-				continue
-			}
-
-			for _, child := range cs {
-				if child.ParentID == c.ID {
-					cn := tview.NewTreeNode(c.Name).
-						SetReference(c.ID)
-					n.AddChild(cn)
-					continue CategoryLoop
-				}
-			}
-
-			cn := tview.NewTreeNode(c.Name).
-				SetReference(c.ID)
-			n.AddChild(cn)
-		}
-	}
-}
-
-// CreateSecondLevelChannelsTreeNodes creates TreeNodes for the second-level
-// (category children) channels.
-func CreateSecondLevelChannelsTreeNodes(
-	s *discordgo.State,
-	treeV *tview.TreeView,
-	cs []*discordgo.Channel,
-) {
-	for _, c := range cs {
-		if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) &&
-			(c.ParentID != "") {
-			if p, err := s.UserChannelPermissions(s.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel {
-				continue
-			}
-
-			if pn := GetTreeNodeByReference(c.ParentID, treeV); pn != nil {
-				cn := NewTextChannelTreeNode(c)
-				pn.AddChild(cn)
-			}
-		}
-	}
-}

+ 0 - 40
ui/messages.go

@@ -1,40 +0,0 @@
-package ui
-
-import (
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-// NewMessagesWidget creates and returns a new messages widget.
-func NewMessagesWidget(
-	app *tview.Application,
-) *tview.TextView {
-	w := tview.NewTextView()
-	w.
-		SetRegions(true).
-		SetDynamicColors(true).
-		SetWordWrap(true).
-		ScrollToEnd().
-		SetChangedFunc(func() {
-			app.Draw()
-		}).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0).
-		SetTitleAlign(tview.AlignLeft)
-
-	return w
-}
-
-// NewInputWidget creates and returns a new input widget.
-func NewInputWidget() *tview.InputField {
-	w := tview.NewInputField()
-	w.
-		SetPlaceholder("Message...").
-		SetPlaceholderTextColor(tcell.ColorWhite).
-		SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0).
-		SetTitleAlign(tview.AlignLeft)
-
-	return w
-}

+ 0 - 40
ui/misc.go

@@ -1,40 +0,0 @@
-package ui
-
-import "github.com/rivo/tview"
-
-// NewMainFlex creates and returns a new main flex.
-func NewMainFlex(
-	treeV *tview.TreeView,
-	textV *tview.TextView,
-	i *tview.InputField,
-) *tview.Flex {
-	rf := tview.NewFlex().
-		SetDirection(tview.FlexRow).
-		AddItem(textV, 0, 1, false).
-		AddItem(i, 3, 1, false)
-	mf := tview.NewFlex().
-		AddItem(treeV, 0, 1, false).
-		AddItem(rf, 0, 4, false)
-
-	return mf
-}
-
-// NewLoginWidget creates and returns a new login widget.
-func NewLoginWidget(onLoginFormLoginButtonSelected func(), mfa bool) *tview.Form {
-	w := tview.NewForm()
-	w.
-		AddButton("Login", onLoginFormLoginButtonSelected).
-		SetButtonsAlign(tview.AlignCenter).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0)
-
-	if mfa {
-		w.AddPasswordField("Code", "", 0, 0, nil)
-	} else {
-		w.
-			AddInputField("Email", "", 0, nil, nil).
-			AddPasswordField("Password", "", 0, 0, nil)
-	}
-
-	return w
-}