Quellcode durchsuchen

Implement vi interface (#372)

Ayyan vor 2 Jahren
Ursprung
Commit
d465ede6d8

+ 66 - 14
README.md

@@ -4,15 +4,6 @@ Discordo is a lightweight, secure, and feature-rich Discord terminal client. Hea
 
 ![Preview](.github/preview.png)
 
-## Table of Contents
-
-- [Features](#features)
-- [Installation](#installation)
-- [Usage](#usage)
-- [Disclaimer](#disclaimer)
-
-## Features
-
 - Lightweight
 - Secure
 - Configurable
@@ -41,9 +32,6 @@ You can download and install a [prebuilt binary here](https://nightly.link/ayn2o
 git clone https://github.com/ayn2op/discordo
 cd discordo
 go build .
-
-# optional
-sudo mv ./discordo /usr/local/bin
 ```
 
 ### Linux clipboard support
@@ -55,11 +43,75 @@ sudo mv ./discordo /usr/local/bin
 
 1. Run the `discordo` executable with no arguments.
 
-- If you are logging in using an authentication token, provide the `token` command-line flag to the executable (eg: `--token "OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg"`). The token is stored securely in the default OS-specific keyring.
+> If you are logging in using an authentication token, provide the `token` command-line flag to the executable (eg: `--token "OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg"`). The token is stored securely in the default OS-specific keyring.
 
 2. Enter your email and password and click on the "Login" button to continue.
 
-- Most of the Discord third-party clients store the token in a configuration file unencrypted. Discordo securely stores the token in the default OS-specific keyring.
+## Configuration
+
+The configuration file allows you to configure and customize the behavior, keybindings, and theme of the application.
+
+- Unix: `$XDG_CONFIG_HOME/discordo/config.yml` or `$HOME/.config/discordo/config.yml`
+- Darwin: `$HOME/Library/Application Support/discordo/config.yml`
+- Windows: `%AppData%/discordo/config.yml`
+
+```toml
+mouse = true
+timestamps = false
+timestamps_before_author = false
+messages_limit = 50
+editor = "default"
+
+[keys.normal]
+  insert_mode = "Rune[i]"
+  focus_guilds_tree = "Ctrl+G"
+  focus_messages_text = "Ctrl+T"
+  toggle_guild_tree = "Ctrl+B"
+
+  guilds_tree = {
+    select_current = "Enter"
+    select_previous = "Rune[k]"
+    select_next = "Rune[j]"
+    select_first = "Rune[g]"
+    select_last = "Rune[G]"
+  }
+
+  messages_text = {
+    select_previous = "Rune[k]"
+    select_next = "Rune[j]"
+    select_first = "Rune[g]"
+    select_last = "Rune[G]"
+    select_reply = "Rune[s]"
+    reply = "Rune[r]"
+    reply_mention = "Rune[R]"
+    delete = "Rune[d]"
+    yank = "Rune[y]"
+    open = "Rune[o]"
+  }
+
+[keys.insert]
+  normal_mode = "Esc"
+  
+  message_input = {
+    send = "Enter"
+    editor = "Ctrl+E"
+  }
+
+[theme]
+  border = true
+  border_color = "default"
+  border_padding = [0, 0, 1, 1]
+  title_color = "default"
+  background_color = "default"
+
+[theme.guilds_tree]
+  auto_expand_folders = true
+  graphics = true
+
+[theme.messages_text]
+  author_color = "aqua"
+  reply_indicator = "╭ "
+```
 
 ## Documentation
 

+ 0 - 57
cmd/attachment_image.go

@@ -1,57 +0,0 @@
-package cmd
-
-import (
-	"image"
-	_ "image/jpeg"
-	_ "image/png"
-	"net/http"
-
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-type AttachmentImage struct {
-	*tview.Image
-}
-
-func newAttachmentImage(a discord.Attachment) (*AttachmentImage, error) {
-	ai := &AttachmentImage{
-		Image: tview.NewImage(),
-	}
-
-	ai.SetInputCapture(ai.onInputCapture)
-	ai.SetBackgroundColor(tcell.GetColor(cfg.Theme.BackgroundColor))
-	ai.SetTitleColor(tcell.GetColor(cfg.Theme.TitleColor))
-	ai.SetTitleAlign(tview.AlignLeft)
-
-	p := cfg.Theme.BorderPadding
-	ai.SetBorder(cfg.Theme.Border)
-	ai.SetBorderColor(tcell.GetColor(cfg.Theme.BorderColor))
-	ai.SetBorderPadding(p[0], p[1], p[2], p[3])
-
-	resp, err := http.Get(a.URL)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	i, _, err := image.Decode(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	ai.SetTitle(a.Filename)
-	ai.SetImage(i)
-	return ai, nil
-}
-
-func (ai *AttachmentImage) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
-	if event.Name() == cfg.Keys.Cancel {
-		app.SetRoot(mainFlex, true)
-		app.SetFocus(mainFlex.messagesText)
-		return nil
-	}
-
-	return event
-}

+ 25 - 1
cmd/guilds_tree.go

@@ -41,6 +41,7 @@ func newGuildsTree() *GuildsTree {
 	gt.SetBorderColor(tcell.GetColor(cfg.Theme.BorderColor))
 	gt.SetBorderPadding(p[0], p[1], p[2], p[3])
 
+	gt.SetInputCapture(gt.onInputCapture)
 	return gt
 }
 
@@ -59,7 +60,7 @@ func (gt *GuildsTree) createGuildFolderNode(parent *tview.TreeNode, gf gateway.G
 	for _, gid := range gf.GuildIDs {
 		g, err := discordState.Cabinet.Guild(gid)
 		if err != nil {
-			log.Println(err)
+			log.Printf("guild %v not found in state: %v", gid, err)
 			continue
 		}
 
@@ -216,3 +217,26 @@ func (gt *GuildsTree) onSelected(n *tview.TreeNode) {
 		}
 	}
 }
+
+func (gt *GuildsTree) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+	switch mainFlex.mode {
+	case ModeNormal:
+		switch event.Name() {
+		case cfg.Keys.Normal.GuildsTree.SelectCurrent:
+			return tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)
+		case cfg.Keys.Normal.GuildsTree.SelectPrevious:
+			return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)
+		case cfg.Keys.Normal.GuildsTree.SelectNext:
+			return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
+		case cfg.Keys.Normal.GuildsTree.SelectFirst:
+			return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)
+		case cfg.Keys.Normal.GuildsTree.SelectLast:
+			return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)
+		}
+
+		// do not propagate event to the children in normal mode.
+		return nil
+	}
+
+	return event
+}

+ 59 - 23
cmd/main_flex.go

@@ -5,9 +5,17 @@ import (
 	"github.com/rivo/tview"
 )
 
+type Mode uint
+
+const (
+	ModeNormal Mode = iota
+	ModeInsert
+)
+
 type MainFlex struct {
 	*tview.Flex
 
+	mode         Mode
 	guildsTree   *GuildsTree
 	messagesText *MessagesText
 	messageInput *MessageInput
@@ -17,14 +25,25 @@ func newMainFlex() *MainFlex {
 	mf := &MainFlex{
 		Flex: tview.NewFlex(),
 
+		mode:         ModeNormal,
 		guildsTree:   newGuildsTree(),
 		messagesText: newMessagesText(),
 		messageInput: newMessageInput(),
 	}
 
+	app.SetBeforeDrawFunc(func(screen tcell.Screen) bool {
+		switch mf.mode {
+		case ModeNormal:
+			mf.messageInput.SetBorderAttributes(tcell.AttrNone)
+		case ModeInsert:
+			mf.messageInput.SetBorderAttributes(tcell.AttrDim)
+		}
+
+		return false
+	})
+
 	mf.init()
 	mf.SetInputCapture(mf.onInputCapture)
-
 	return mf
 }
 
@@ -41,32 +60,49 @@ func (mf *MainFlex) init() {
 }
 
 func (mf *MainFlex) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
-	switch event.Name() {
-	case cfg.Keys.GuildsTree.Toggle:
-		// The guilds tree is visible if the numbers of items is two.
-		if mf.GetItemCount() == 2 {
-			mf.RemoveItem(mf.guildsTree)
-
-			if mf.guildsTree.HasFocus() {
-				app.SetFocus(mf)
-			}
-		} else {
-			mf.init()
+	switch mf.mode {
+	case ModeNormal:
+		switch event.Name() {
+		case cfg.Keys.Normal.InsertMode:
+			mf.mode = ModeInsert
+			app.SetFocus(mf.messageInput)
+			return nil
+
+		case cfg.Keys.Normal.FocusGuildsTree:
 			app.SetFocus(mf.guildsTree)
+			return nil
+		case cfg.Keys.Normal.FocusMessagesText:
+			app.SetFocus(mf.messagesText)
+			return nil
+		case cfg.Keys.Normal.ToggleGuildsTree:
+			// The guilds tree is visible if the numbers of items is two.
+			if mf.GetItemCount() == 2 {
+				mf.RemoveItem(mf.guildsTree)
+				if mf.guildsTree.HasFocus() {
+					app.SetFocus(mf)
+				}
+			} else {
+				mf.init()
+				app.SetFocus(mf.guildsTree)
+			}
+
+			return nil
 		}
 
-		return nil
-	case cfg.Keys.GuildsTree.Focus:
-		if mf.GetItemCount() == 2 {
-			app.SetFocus(mf.guildsTree)
+		// do not propagate event to the children if the message input is focused in normal mode.
+		if mf.messageInput.HasFocus() {
+			return nil
+		}
+	case ModeInsert:
+		switch event.Name() {
+		case cfg.Keys.Insert.NormalMode:
+			mf.mode = ModeNormal
+			return nil
+		}
+
+		if !mf.messageInput.HasFocus() {
+			return nil
 		}
-		return nil
-	case cfg.Keys.MessagesText.Focus:
-		app.SetFocus(mf.messagesText)
-		return nil
-	case cfg.Keys.MessageInput.Focus:
-		app.SetFocus(mf.messageInput)
-		return nil
 	}
 
 	return event

+ 86 - 89
cmd/message_input.go

@@ -22,7 +22,7 @@ type MessageInput struct {
 
 func newMessageInput() *MessageInput {
 	mi := &MessageInput{
-		TextArea: tview.NewTextArea(),
+		TextArea:        tview.NewTextArea(),
 		replyMessageIdx: -1,
 	}
 
@@ -54,95 +54,92 @@ func (mi *MessageInput) reset() {
 }
 
 func (mi *MessageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
-	switch event.Name() {
-	case cfg.Keys.MessageInput.Send:
-		mi.sendAction()
-		return nil
-	case "Alt+Enter":
-		return tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)
-	case cfg.Keys.MessageInput.LaunchEditor:
-		mainFlex.messageInput.launchEditorAction()
-		return nil
-	case cfg.Keys.Cancel:
-		mi.replyMessageIdx = -1
-		mi.reset()
-		return nil
-	}
-
-	return event
-}
-
-func (mi *MessageInput) sendAction() {
-	if !mainFlex.guildsTree.selectedChannelID.IsValid() {
-		return
-	}
-
-	text := strings.TrimSpace(mi.GetText())
-	if text == "" {
-		return
-	}
-
-	if mi.replyMessageIdx != -1 {
-		ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-		if err != nil {
-			log.Println(err)
-			return
-		}
-
-		data := api.SendMessageData{
-			Content:         text,
-			Reference:       &discord.MessageReference{MessageID: ms[mi.replyMessageIdx].ID},
-			AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
-		}
-
-		if strings.HasPrefix(mi.GetTitle(), "[@]") {
-			data.AllowedMentions.RepliedUser = option.True
-		}
-
-		go discordState.SendMessageComplex(mainFlex.guildsTree.selectedChannelID, data)
-	} else {
-		go discordState.SendMessage(mainFlex.guildsTree.selectedChannelID, text)
-	}
-
-	mi.replyMessageIdx = -1
-	mainFlex.messagesText.Highlight()
-	mi.reset()
-}
-
-func (mi *MessageInput) launchEditorAction() {
-	e := cfg.Editor
-	if e == "default" {
-		e = os.Getenv("EDITOR")
-	}
-
-	f, err := os.CreateTemp("", constants.TmpFilePattern)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-	f.WriteString(mi.GetText())
-	f.Close()
-
-	defer os.Remove(f.Name())
-
-	cmd := exec.Command(e, f.Name())
-	cmd.Stdin = os.Stdin
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-
-	app.Suspend(func() {
-		err := cmd.Run()
-		if err != nil {
-			log.Println(err)
-			return
+	switch mainFlex.mode {
+	case ModeInsert:
+		switch event.Name() {
+		case cfg.Keys.Insert.MessageInput.Send:
+			if !mainFlex.guildsTree.selectedChannelID.IsValid() {
+				return nil
+			}
+
+			text := strings.TrimSpace(mi.GetText())
+			if text == "" {
+				return nil
+			}
+
+			if mi.replyMessageIdx != -1 {
+				ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
+				if err != nil {
+					log.Println(err)
+					return nil
+				}
+
+				data := api.SendMessageData{
+					Content:         text,
+					Reference:       &discord.MessageReference{MessageID: ms[mi.replyMessageIdx].ID},
+					AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
+				}
+
+				if strings.HasPrefix(mi.GetTitle(), "[@]") {
+					data.AllowedMentions.RepliedUser = option.True
+				}
+
+				go func() {
+					if _, err := discordState.SendMessageComplex(mainFlex.guildsTree.selectedChannelID, data); err != nil {
+						log.Println("failed to send message:", err)
+					}
+				}()
+			} else {
+				go func() {
+					if _, err := discordState.SendMessage(mainFlex.guildsTree.selectedChannelID, text); err != nil {
+						log.Println("failed to send message:", err)
+					}
+				}()
+			}
+
+			mi.replyMessageIdx = -1
+			mainFlex.messagesText.Highlight()
+			mi.reset()
+			return nil
+		case cfg.Keys.Insert.MessageInput.Editor:
+			e := cfg.Editor
+			if e == "default" {
+				e = os.Getenv("EDITOR")
+			}
+
+			f, err := os.CreateTemp("", constants.TmpFilePattern)
+			if err != nil {
+				log.Println(err)
+				return nil
+			}
+			_, _ = f.WriteString(mi.GetText())
+			f.Close()
+
+			defer os.Remove(f.Name())
+
+			cmd := exec.Command(e, f.Name())
+			cmd.Stdin = os.Stdin
+			cmd.Stdout = os.Stdout
+			cmd.Stderr = os.Stderr
+
+			app.Suspend(func() {
+				err := cmd.Run()
+				if err != nil {
+					log.Println(err)
+					return
+				}
+			})
+
+			msg, err := os.ReadFile(f.Name())
+			if err != nil {
+				log.Println(err)
+				return nil
+			}
+
+			mi.SetText(strings.TrimSpace(string(msg)), true)
+			return nil
 		}
-	})
-
-	msg, err := os.ReadFile(f.Name())
-	if err != nil {
-		log.Println(err)
-		return
 	}
 
-	mi.SetText(strings.TrimSpace(string(msg)), true)
+	return event
 }

+ 153 - 208
cmd/messages_text.go

@@ -1,10 +1,10 @@
 package cmd
 
 import (
-	"strings"
 	"fmt"
 	"io"
 	"log"
+	"strings"
 	"time"
 
 	"github.com/atotto/clipboard"
@@ -12,6 +12,7 @@ import (
 	"github.com/diamondburned/arikawa/v3/discord"
 	"github.com/gdamore/tcell/v2"
 	"github.com/rivo/tview"
+	"github.com/skratchdot/open-golang/open"
 )
 
 type MessagesText struct {
@@ -132,9 +133,13 @@ func (mt *MessagesText) createBody(w io.Writer, m discord.Message, isReply bool)
 		body = m.Content
 	}
 
-	if isReply { fmt.Fprint(w, "[::d]") }
+	if isReply {
+		fmt.Fprint(w, "[::d]")
+	}
 	fmt.Fprint(w, markdown.Parse(tview.Escape(body)))
-	if isReply { fmt.Fprint(w, "[::-]") }
+	if isReply {
+		fmt.Fprint(w, "[::-]")
+	}
 }
 
 func (mt *MessagesText) createFooter(w io.Writer, m discord.Message) {
@@ -145,238 +150,178 @@ func (mt *MessagesText) createFooter(w io.Writer, m discord.Message) {
 }
 
 func (mt *MessagesText) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
-	switch event.Name() {
-	case cfg.Keys.MessagesText.CopyContent:
-		mt.copyContentAction()
-		return nil
-	case cfg.Keys.MessagesText.Reply:
-		mt.replyAction(false)
-		return nil
-	case cfg.Keys.MessagesText.ReplyMention:
-		mt.replyAction(true)
-		return nil
-	case cfg.Keys.MessagesText.SelectPrevious:
-		mt.selectPreviousAction()
-		return nil
-	case cfg.Keys.MessagesText.SelectNext:
-		mt.selectNextAction()
-		return nil
-	case cfg.Keys.MessagesText.SelectFirst:
-		mt.selectFirstAction()
-		return nil
-	case cfg.Keys.MessagesText.SelectLast:
-		mt.selectLastAction()
-		return nil
-	case cfg.Keys.MessagesText.SelectReply:
-		mt.selectReplyAction()
-		return nil
-	case cfg.Keys.MessagesText.ShowImage:
-		mt.showImageAction()
-		return nil
-	case cfg.Keys.MessagesText.Delete:
-		mt.deleteAction()
-		return nil
-	case cfg.Keys.Cancel:
-		mainFlex.guildsTree.selectedChannelID = 0
-
-		mainFlex.messagesText.reset()
-		mainFlex.messageInput.reset()
-		return nil
-	}
-
-	return event
-}
-
-func (mt *MessagesText) replyAction(mention bool) {
-	if mt.selectedMessage == -1 {
-		return
-	}
-
-	var title string
-	if mention {
-		title += "[@] Replying to "
-	} else {
-		title += "Replying to "
-	}
-
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-
-	title += ms[mt.selectedMessage].Author.Tag()
-	mainFlex.messageInput.SetTitle(title)
-	mainFlex.messageInput.replyMessageIdx = mt.selectedMessage
-
-	app.SetFocus(mainFlex.messageInput)
-}
-
-func (mt *MessagesText) selectPreviousAction() {
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-
-	// If no message is currently selected, select the latest message.
-	if len(mt.GetHighlights()) == 0 {
-		mt.selectedMessage = 0
-	} else {
-		if mt.selectedMessage < len(ms)-1 {
-			mt.selectedMessage++
-		} else {
-			return
-		}
-	}
+	switch mainFlex.mode {
+	case ModeNormal:
+		switch event.Name() {
+		case cfg.Keys.Normal.MessagesText.Yank:
+			if mt.selectedMessage == -1 {
+				return nil
+			}
 
-	mt.Highlight(ms[mt.selectedMessage].ID.String())
-	mt.ScrollToHighlight()
-}
+			ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
+			if err != nil {
+				log.Println(err)
+				return nil
+			}
 
-func (mt *MessagesText) selectNextAction() {
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
+			err = clipboard.WriteAll(ms[mt.selectedMessage].Content)
+			if err != nil {
+				log.Println("failed to write to clipboard:", err)
+				return nil
+			}
 
-	// If no message is currently selected, do nothing
-	if mt.selectedMessage == -1 { return }
+			return nil
 
-	// Otherwise select the next message. This causes the desired
-	// behaviour of unselecting messages after the last one.
-	mt.selectedMessage--
+		case cfg.Keys.Normal.MessagesText.SelectFirst, cfg.Keys.Normal.MessagesText.SelectLast, cfg.Keys.Normal.MessagesText.SelectPrevious, cfg.Keys.Normal.MessagesText.SelectNext, cfg.Keys.Normal.MessagesText.SelectReply:
+			ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
+			if err != nil {
+				log.Println(err)
+				return nil
+			}
 
-	// If a message is selected, highlight it and scroll to it, otherwise remove the highlight
-	if mt.selectedMessage >= 0 {
-		mt.Highlight(ms[mt.selectedMessage].ID.String())
-	        mt.ScrollToHighlight()
-	} else {
-		mt.Highlight()
-	}
-}
+			switch event.Name() {
+			case cfg.Keys.Normal.MessagesText.SelectPrevious:
+				// If no message is currently selected, select the latest message.
+				if len(mt.GetHighlights()) == 0 {
+					mt.selectedMessage = 0
+				} else {
+					if mt.selectedMessage < len(ms)-1 {
+						mt.selectedMessage++
+					} else {
+						return nil
+					}
+				}
+			case cfg.Keys.Normal.MessagesText.SelectNext:
+				// If no message is currently selected, select the latest message.
+				if len(mt.GetHighlights()) == 0 {
+					mt.selectedMessage = 0
+				} else {
+					if mt.selectedMessage > 0 {
+						mt.selectedMessage--
+					} else {
+						return nil
+					}
+				}
+			case cfg.Keys.Normal.MessagesText.SelectFirst:
+				mt.selectedMessage = len(ms) - 1
+			case cfg.Keys.Normal.MessagesText.SelectLast:
+				mt.selectedMessage = 0
+			case cfg.Keys.Normal.MessagesText.SelectReply:
+				if mt.selectedMessage == -1 {
+					return nil
+				}
+
+				if ref := ms[mt.selectedMessage].ReferencedMessage; ref != nil {
+					for i, m := range ms {
+						if ref.ID == m.ID {
+							mt.selectedMessage = i
+						}
+					}
+				}
+			}
 
-func (mt *MessagesText) selectFirstAction() {
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
+			mt.Highlight(ms[mt.selectedMessage].ID.String())
+			mt.ScrollToHighlight()
+			return nil
+		case cfg.Keys.Normal.MessagesText.Open:
+			if mt.selectedMessage == -1 {
+				return nil
+			}
 
-	mt.selectedMessage = len(ms) - 1
-	mt.Highlight(ms[mt.selectedMessage].ID.String())
-	mt.ScrollToHighlight()
-}
+			ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
+			if err != nil {
+				log.Println(err)
+				return nil
+			}
 
-func (mt *MessagesText) selectLastAction() {
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
+			attachments := ms[mt.selectedMessage].Attachments
+			if len(attachments) == 0 {
+				return nil
+			}
 
-	mt.selectedMessage = 0
-	mt.Highlight(ms[mt.selectedMessage].ID.String())
-	mt.ScrollToHighlight()
-}
+			for _, a := range attachments {
+				go func() {
+					if err := open.Start(a.URL); err != nil {
+						log.Println(err)
+					}
+				}()
+			}
 
-func (mt *MessagesText) selectReplyAction() {
-	if mt.selectedMessage == -1 {
-		return
-	}
+			return nil
+		case cfg.Keys.Normal.MessagesText.Reply, cfg.Keys.Normal.MessagesText.ReplyMention:
+			if mt.selectedMessage == -1 {
+				return nil
+			}
 
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
+			var title string
+			if event.Name() == cfg.Keys.Normal.MessagesText.ReplyMention {
+				title += "[@] Replying to "
+			} else {
+				title += "Replying to "
+			}
 
-	ref := ms[mt.selectedMessage].ReferencedMessage
-	if ref != nil {
-		for i, m := range ms {
-			if ref.ID == m.ID {
-				mt.selectedMessage = i
+			ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
+			if err != nil {
+				log.Println(err)
+				return nil
 			}
-		}
 
-		mt.Highlight(ms[mt.selectedMessage].ID.String())
-		mt.ScrollToHighlight()
-	}
-}
+			title += ms[mt.selectedMessage].Author.Tag()
+			mainFlex.messageInput.SetTitle(title)
+			mainFlex.messageInput.replyMessageIdx = mt.selectedMessage
 
-func (mt *MessagesText) copyContentAction() {
-	if mt.selectedMessage == -1 {
-		return
-	}
+			app.SetFocus(mainFlex.messageInput)
+			return nil
+		case cfg.Keys.Normal.MessagesText.Delete:
+			if mt.selectedMessage == -1 {
+				return nil
+			}
 
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
+			ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
+			if err != nil {
+				log.Println(err)
+				return nil
+			}
 
-	err = clipboard.WriteAll(ms[mt.selectedMessage].Content)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-}
+			m := ms[mt.selectedMessage]
+			clientID := discordState.Ready().User.ID
 
-func (mt *MessagesText) showImageAction() {
-	if mt.selectedMessage == -1 {
-		return
-	}
+			ps, err := discordState.Permissions(mainFlex.guildsTree.selectedChannelID, discordState.Ready().User.ID)
+			if err != nil {
+				return nil
+			}
 
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
+			if m.Author.ID != clientID && !ps.Has(discord.PermissionManageMessages) {
+				return nil
+			}
 
-	as := ms[mt.selectedMessage].Attachments
-	if len(as) == 0 {
-		return
-	}
+			if err := discordState.DeleteMessage(mainFlex.guildsTree.selectedChannelID, m.ID, ""); err != nil {
+				log.Println(err)
+			}
 
-	ai, err := newAttachmentImage(as[0])
-	if err != nil {
-		log.Println(err)
-		return
-	}
+			if err := discordState.MessageRemove(mainFlex.guildsTree.selectedChannelID, m.ID); err != nil {
+				log.Println(err)
+			}
 
-	app.SetRoot(ai, true)
-}
+			ms, err = discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
+			if err != nil {
+				log.Println(err)
+				return nil
+			}
 
-func (mt *MessagesText) deleteAction() {
-	if mt.selectedMessage == -1 {
-		return
-	}
+			mt.Clear()
 
-	ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
+			for i := len(ms) - 1; i >= 0; i-- {
+				mainFlex.messagesText.createMessage(ms[i])
+			}
 
-	m := ms[mt.selectedMessage]
-	if err := discordState.DeleteMessage(mainFlex.guildsTree.selectedChannelID, m.ID, ""); err != nil {
-		log.Println(err)
-	}
+			return nil
+		}
 
-	if err := discordState.MessageRemove(mainFlex.guildsTree.selectedChannelID, m.ID); err != nil {
-		log.Println(err)
-	}
+		// do not propagate event to the children in normal mode.
+		return nil
 
-	ms, err = discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID)
-	if err != nil {
-		log.Println(err)
-		return
 	}
 
-	mt.Clear()
-
-	for i := len(ms) - 1; i >= 0; i-- {
-		mainFlex.messagesText.createMessage(ms[i])
-	}
+	return event
 }

+ 1 - 1
cmd/state.go

@@ -43,7 +43,7 @@ func openState(token string) error {
 }
 
 func (s *State) onLog(err error) {
-	log.Printf("%s\n", err)
+	log.Println(err.Error())
 }
 
 func (s *State) onRequest(r httpdriver.Request) error {

+ 0 - 41
docs/README.md

@@ -1,41 +0,0 @@
-# Discordo
-
-Discordo is a lightweight, secure, and feature-rich Discord terminal client.
-
-## Table of Contents
-
-- [FAQ](./faq.md)
-- [Configuration](./configuration.md)
-
-## Warning
-
-Automated user accounts or "self-bots" are against Discord's Terms of Service. I am not responsible for any loss caused by using "self-bots" or Discordo.
-
-## Authentication
-
-There are two ways to login:
-
-> In both cases, the authentication token is stored securely in the default OS-specific keyring.
-
-### Email login
-
-1. Run `discordo` without arguments.
-2. Enter your email and password then click on the "Login" button to continue.
-
-### Token login
-
-Use the `--token` flag:
-
-```
-discordo --token "OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg"
-```
-
-## Logs
-
-The log file is created on first start-up at the following location:
-
-| Operating System | Log File Location                        |
-| ---------------- | ---------------------------------------- |
-| Unix             | `$HOME/.cache/discordo/logs.txt`         |
-| Darwin           | `$HOME/Library/Caches/discordo/logs.txt` |
-| Windows          | `%LocalAppData%/discordo/logs.txt`       |

+ 0 - 7
docs/configuration.md

@@ -1,7 +0,0 @@
-# Configuration
-
-The configuration file allows you to configure and customize the behavior, keybindings, and theme of the application. It is automatically created on first start-up at the following location:
-
-- Unix: `$XDG_CONFIG_HOME/discordo/config.yml` or `$HOME/.config/discordo/config.yml`
-- Darwin: `$HOME/Library/Application Support/discordo/config.yml`
-- Windows: `%AppData%/discordo/config.yml`

+ 0 - 7
docs/faq.md

@@ -1,7 +0,0 @@
-# FAQ
-
-## How to log out from an account?
-
-1. Open the OS-specific keyring application. (Keychain for macOS, Credential Manager for Windows).
-2. Select the `login` collection.
-3. Select and delete the `discordo` service in the `login` collection.

+ 2 - 1
go.mod

@@ -3,12 +3,13 @@ module github.com/ayn2op/discordo
 go 1.22.1
 
 require (
+	github.com/BurntSushi/toml v1.3.2
 	github.com/atotto/clipboard v0.1.4
 	github.com/diamondburned/arikawa/v3 v3.3.5
 	github.com/gdamore/tcell/v2 v2.7.4
 	github.com/rivo/tview v0.0.0-20240307173318-e804876934a1
+	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
 	github.com/zalando/go-keyring v0.2.4
-	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (

+ 6 - 17
go.sum

@@ -1,18 +1,15 @@
-github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
-github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0=
 github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
-github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
 github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs=
 github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/diamondburned/arikawa/v3 v3.3.5 h1:Z6BwetBMzPxTBLY2Ixxic2kdJJe0JhNvVrdbJ0gRcWg=
 github.com/diamondburned/arikawa/v3 v3.3.5/go.mod h1:KPkkWr40xmEithhd15XD2dbkVY8A5+MCmZO0gRXk3qc=
-github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
 github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
 github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
 github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
@@ -20,7 +17,6 @@ github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAY
 github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
 github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
 github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
 github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM=
 github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
@@ -39,13 +35,13 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
 github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
 github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
-github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
 github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
 github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -58,8 +54,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
-golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
 golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
 golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -73,7 +67,6 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
 golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -82,7 +75,6 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
-golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
 golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
@@ -93,7 +85,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
@@ -102,7 +93,5 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 26 - 79
internal/config/config.go

@@ -1,109 +1,55 @@
 package config
 
 import (
-	"bytes"
-	_ "embed"
-	"io"
+	"log"
 	"os"
 	"path/filepath"
 
+	"github.com/BurntSushi/toml"
 	"github.com/ayn2op/discordo/internal/constants"
-	"gopkg.in/yaml.v3"
 )
 
-//go:embed config.yml
-var defaultConfig []byte
-
 type Config struct {
-	Mouse bool `yaml:"mouse"`
-
-	Timestamps             bool `yaml:"timestamps"`
-	TimestampsBeforeAuthor bool `yaml:"timestamps_before_author"`
-
-	MessagesLimit uint8 `yaml:"messages_limit"`
-
-	Editor string `yaml:"editor"`
-
-	Keys struct {
-		Cancel string `yaml:"cancel"`
-
-		GuildsTree struct {
-			Focus  string `yaml:"focus"`
-			Toggle string `yaml:"toggle"`
-		} `yaml:"guilds_tree"`
-
-		MessagesText struct {
-			Focus string `yaml:"focus"`
-
-			ShowImage   string `yaml:"show_image"`
-			CopyContent string `yaml:"copy_content"`
-
-			Reply        string `yaml:"reply"`
-			ReplyMention string `yaml:"reply_mention"`
+	Mouse bool `toml:"mouse"`
 
-			Delete string `yaml:"delete"`
+	Timestamps             bool `toml:"timestamps"`
+	TimestampsBeforeAuthor bool `toml:"timestamps_before_author"`
 
-			SelectPrevious string `yaml:"select_previous"`
-			SelectNext     string `yaml:"select_next"`
-			SelectFirst    string `yaml:"select_first"`
-			SelectLast     string `yaml:"select_last"`
-			SelectReply    string `yaml:"select_reply"`
-		} `yaml:"messages_text"`
+	MessagesLimit uint8 `toml:"messages_limit"`
 
-		MessageInput struct {
-			Focus string `yaml:"focus"`
+	Editor string `toml:"editor"`
 
-			Send         string `yaml:"send"`
-			LaunchEditor string `yaml:"launch_editor"`
-		} `yaml:"message_input"`
-	} `yaml:"keys"`
-	Theme struct {
-		Border        bool   `yaml:"border"`
-		BorderColor   string `yaml:"border_color"`
-		BorderPadding [4]int `yaml:"border_padding,flow"`
+	Keys  Keys  `toml:"keys"`
+	Theme Theme `toml:"theme"`
+}
 
-		TitleColor      string `yaml:"title_color"`
-		BackgroundColor string `yaml:"background_color"`
+func defaultConfig() Config {
+	return Config{
+		Mouse: true,
 
-		GuildsTree struct {
-			AutoExpandFolders bool `yaml:"auto_expand_folders"`
-			Graphics          bool `yaml:"graphics"`
-		} `yaml:"guilds_tree"`
+		Timestamps:             false,
+		TimestampsBeforeAuthor: false,
 
-		MessagesText struct {
-			AuthorColor    string `yaml:"author_color"`
-			ReplyIndicator string `yaml:"reply_indicator"`
-		} `yaml:"messages_text"`
-	} `yaml:"theme"`
-}
+		MessagesLimit: 50,
+		Editor:        "default",
 
-// Recursively creates the configuration directory if it does not exist already and returns the path to the configuration file.
-func initialize() (string, error) {
-	path, err := os.UserConfigDir()
-	if err != nil {
-		return "", err
+		Keys:  defaultKeys(),
+		Theme: defaultTheme(),
 	}
-
-	path = filepath.Join(path, constants.Name)
-	if err := os.MkdirAll(path, os.ModePerm); err != nil {
-		return "", err
-	}
-
-	return filepath.Join(path, "config.yml"), nil
 }
 
 // Reads the configuration file and parses it.
 func Load() (*Config, error) {
-	path, err := initialize()
+	path, err := os.UserConfigDir()
 	if err != nil {
 		return nil, err
 	}
 
+	cfg := defaultConfig()
+	path = filepath.Join(path, constants.Name, "config.toml")
 	f, err := os.Open(path)
-	reader := io.Reader(f)
 	if os.IsNotExist(err) {
-		err = os.WriteFile(path, defaultConfig, os.ModePerm)
-		reader = bytes.NewReader(defaultConfig)
+		return &cfg, nil
 	}
 
 	if err != nil {
@@ -111,10 +57,11 @@ func Load() (*Config, error) {
 	}
 	defer f.Close()
 
-	var cfg Config
-	if err := yaml.NewDecoder(reader).Decode(&cfg); err != nil {
+	if _, err := toml.NewDecoder(f).Decode(&cfg); err != nil {
 		return nil, err
 	}
 
+	log.Println(cfg.Theme.MessagesText.ReplyIndicator)
+
 	return &cfg, nil
 }

+ 0 - 57
internal/config/config.yml

@@ -1,57 +0,0 @@
-# Whether the mouse is usable or not.
-mouse: true
-
-# Whether to draw the timestamps of the corresponding message in front of it.
-timestamps: false
-# Whether to draw the timestamps before the name of author of the message or not. 
-timestamps_before_author: false
-
-# The number of messages to fetch when a text-based channel is selected. The value must be >0 and <=100.
-messages_limit: 50
-
-# The name of the program to launch when the launch_editor key is pressed. If the value of the field is set to "default", the `$EDITOR` environment variable is used instead.
-editor: default
-
-keys:
-  cancel: Esc
-  
-  guilds_tree:
-    focus: Ctrl+G
-    toggle: Ctrl+B
-  
-  messages_text:
-    focus: Ctrl+T
-    show_image: Rune[i]
-    copy_content: Rune[c]
-    delete: Rune[d]
-
-    reply: Rune[r]
-    reply_mention: Rune[R]
-
-    select_previous: Up
-    select_next: Down
-    select_first: Home
-    select_last: End
-    select_reply: Rune[s]
-
-  message_input:
-    focus: Ctrl+P
-
-    send: Enter
-    launch_editor: Ctrl+E
-
-theme:
-  border: true
-  border_color: default
-  border_padding: [0, 0, 1, 1]
-
-  title_color: default
-  background_color: default
-
-  guilds_tree:
-    auto_expand_folders: true
-    graphics: true
-
-  messages_text:
-    author_color: aqua
-    reply_indicator: ╭

+ 93 - 0
internal/config/keys.go

@@ -0,0 +1,93 @@
+package config
+
+type (
+	Keys struct {
+		Normal NormalModeKeys `toml:"normal"`
+		Insert InsertModeKeys `toml:"insert"`
+	}
+
+	NormalModeKeys struct {
+		InsertMode        string `toml:"insert_mode"`
+		FocusGuildsTree   string `toml:"focus_guilds_tree"`
+		FocusMessagesText string `toml:"focus_messages_text"`
+		ToggleGuildsTree  string `toml:"toggle_guild_tree"`
+
+		GuildsTree   GuildsTreeNormalModeKeys   `toml:"guilds_tree"`
+		MessagesText MessagesTextNormalModeKeys `toml:"messages_text"`
+	}
+
+	GuildsTreeNormalModeKeys struct {
+		SelectCurrent  string `toml:"select_current"`
+		SelectPrevious string `toml:"select_previous"`
+		SelectNext     string `toml:"select_next"`
+		SelectFirst    string `toml:"select_first"`
+		SelectLast     string `toml:"select_last"`
+	}
+
+	MessagesTextNormalModeKeys struct {
+		SelectPrevious string `toml:"select_previous"`
+		SelectNext     string `toml:"select_next"`
+		SelectFirst    string `toml:"select_first"`
+		SelectLast     string `toml:"select_last"`
+		SelectReply    string `toml:"select_reply"`
+
+		Reply        string `toml:"reply"`
+		ReplyMention string `toml:"reply_mention"`
+
+		Delete string `toml:"delete"`
+		Yank   string `toml:"yank"`
+		Open   string `toml:"open"`
+	}
+
+	InsertModeKeys struct {
+		NormalMode string `toml:"normal_mode"`
+
+		MessageInput MessageInputInsertModeKeys `toml:"message_input"`
+	}
+
+	MessageInputInsertModeKeys struct {
+		Send   string `toml:"send"`
+		Editor string `toml:"editor"`
+	}
+)
+
+func defaultKeys() Keys {
+	return Keys{
+		Normal: NormalModeKeys{
+			InsertMode: "Rune[i]",
+
+			FocusGuildsTree:   "Ctrl+G",
+			FocusMessagesText: "Ctrl+T",
+			ToggleGuildsTree:  "Ctrl+B",
+
+			GuildsTree: GuildsTreeNormalModeKeys{
+				SelectCurrent:  "Enter",
+				SelectPrevious: "Rune[k]",
+				SelectNext:     "Rune[j]",
+				SelectFirst:    "Rune[g]",
+				SelectLast:     "Rune[G]",
+			},
+			MessagesText: MessagesTextNormalModeKeys{
+				SelectPrevious: "Rune[k]",
+				SelectNext:     "Rune[j]",
+				SelectFirst:    "Rune[g]",
+				SelectLast:     "Rune[G]",
+				SelectReply:    "Rune[s]",
+
+				Reply:        "Rune[r]",
+				ReplyMention: "Rune[R]",
+
+				Delete: "Rune[d]",
+				Yank:   "Rune[y]",
+				Open:   "Rune[o]",
+			},
+		},
+		Insert: InsertModeKeys{
+			NormalMode: "Esc",
+			MessageInput: MessageInputInsertModeKeys{
+				Send:   "Enter",
+				Editor: "Ctrl+E",
+			},
+		},
+	}
+}

+ 47 - 0
internal/config/theme.go

@@ -0,0 +1,47 @@
+package config
+
+import "github.com/rivo/tview"
+
+type (
+	Theme struct {
+		Border        bool   `toml:"border"`
+		BorderColor   string `toml:"border_color"`
+		BorderPadding [4]int `toml:"border_padding,flow"`
+
+		TitleColor      string `toml:"title_color"`
+		BackgroundColor string `toml:"background_color"`
+
+		GuildsTree   GuildsTreeTheme   `toml:"guilds_tree"`
+		MessagesText MessagesTextTheme `toml:"messages_text"`
+	}
+
+	GuildsTreeTheme struct {
+		AutoExpandFolders bool `toml:"auto_expand_folders"`
+		Graphics          bool `toml:"graphics"`
+	}
+
+	MessagesTextTheme struct {
+		AuthorColor    string `toml:"author_color"`
+		ReplyIndicator string `toml:"reply_indicator"`
+	}
+)
+
+func defaultTheme() Theme {
+	return Theme{
+		Border:        true,
+		BorderColor:   "default",
+		BorderPadding: [...]int{0, 0, 1, 1},
+
+		BackgroundColor: "default",
+		TitleColor:      "default",
+
+		GuildsTree: GuildsTreeTheme{
+			AutoExpandFolders: true,
+			Graphics:          true,
+		},
+		MessagesText: MessagesTextTheme{
+			AuthorColor:    "aqua",
+			ReplyIndicator: string(tview.BoxDrawingsLightArcDownAndRight) + " ",
+		},
+	}
+}

+ 6 - 1
internal/ui/login_form.go

@@ -2,6 +2,7 @@ package ui
 
 import (
 	"errors"
+	"log"
 
 	"github.com/ayn2op/discordo/internal/config"
 	"github.com/ayn2op/discordo/internal/constants"
@@ -83,7 +84,11 @@ func (lf *LoginForm) onLoginButtonSelected() {
 
 	rememberMe := lf.GetFormItem(3).(*tview.Checkbox).IsChecked()
 	if rememberMe {
-		go keyring.Set(constants.Name, "token", lr.Token)
+		go func() {
+			if err := keyring.Set(constants.Name, "token", lr.Token); err != nil {
+				log.Println(err)
+			}
+		}()
 	}
 
 	lf.Token <- Token{Value: lr.Token}

+ 2 - 2
main.go

@@ -11,14 +11,14 @@ import (
 
 func main() {
 	// Declare and parse all flags first
-	token := flag.String("token", "", "The authentication token.")
+	token := flag.String("token", "", "authentication token")
 	flag.Parse()
 
 	// If no token was provided, look it up in the keyring
 	if *token == "" {
 		t, err := keyring.Get(constants.Name, "token")
 		if err != nil {
-			log.Println("Authentication token not found in keyring:", err)
+			log.Println("failed to get token from keyring:", err)
 		} else {
 			*token = t
 		}