diff --git a/main.go b/main.go index 5a361d3..7191743 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,6 @@ const keyringServiceName = "discordo" func main() { app := ui.NewApp() - app.EnableMouse(app.Config.General.Mouse) token := os.Getenv("DISCORDO_TOKEN") if token == "" { @@ -26,9 +25,8 @@ func main() { panic(err) } - app. - SetRoot(ui.NewMainFlex(app), true). - SetFocus(app.GuildsList) + app.DrawMainFlex() + app.SetFocus(app.GuildsList) } else { loginForm := ui.NewLoginForm(false) loginForm.AddButton("Login", func() { @@ -50,9 +48,8 @@ func main() { panic(err) } - app. - SetRoot(ui.NewMainFlex(app), true). - SetFocus(app.GuildsList) + app.DrawMainFlex() + app.SetFocus(app.GuildsList) go keyring.Set("discordo", "token", lr.Token) } else { @@ -74,9 +71,8 @@ func main() { panic(err) } - app. - SetRoot(ui.NewMainFlex(app), true). - SetFocus(app.GuildsList) + app.DrawMainFlex() + app.SetFocus(app.GuildsList) go keyring.Set(keyringServiceName, "token", lr.Token) }) diff --git a/ui/app.go b/ui/app.go index 73644f9..d547eaf 100644 --- a/ui/app.go +++ b/ui/app.go @@ -6,16 +6,17 @@ import ( "github.com/ayntgl/discordgo" "github.com/ayntgl/discordo/config" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type App struct { *tview.Application MainFlex *tview.Flex - GuildsList *tview.List - ChannelsTreeView *tview.TreeView - MessagesTextView *tview.TextView - MessageInputField *tview.InputField + GuildsList *GuildsList + ChannelsTreeView *ChannelsTreeView + MessagesTextView *MessagesTextView + MessageInputField *MessageInputField Session *discordgo.Session SelectedChannel *discordgo.Channel Config *config.Config @@ -23,20 +24,24 @@ type App struct { } func NewApp() *App { - s, _ := discordgo.New() - - return &App{ - Application: tview.NewApplication(), - MainFlex: tview.NewFlex(), - GuildsList: tview.NewList(), - ChannelsTreeView: tview.NewTreeView(), - MessagesTextView: tview.NewTextView(), - MessageInputField: tview.NewInputField(), - - Session: s, - Config: config.NewConfig(), + app := &App{ + MainFlex: tview.NewFlex(), SelectedMessage: -1, } + + app.GuildsList = NewGuildsList(app) + app.ChannelsTreeView = NewChannelsTreeView(app) + app.MessagesTextView = NewMessagesTextView(app) + app.MessageInputField = NewMessageInputField(app) + + app.Session, _ = discordgo.New() + app.Config = config.NewConfig() + + app.Application = tview.NewApplication() + app.EnableMouse(app.Config.General.Mouse) + app.SetInputCapture(app.onInputCapture) + + return app } func (app *App) Connect(token string) error { @@ -59,12 +64,51 @@ func (app *App) Connect(token string) error { app.Session.Token = token app.Session.Identify.Token = token - app.Session.AddHandler(app.onGuildCreate) + app.Session.AddHandler(app.onSessionGuildCreate) app.Session.AddHandler(app.onSessionMessageCreate) return app.Session.Open() } +func (app *App) onInputCapture(e *tcell.EventKey) *tcell.EventKey { + if app.MessageInputField.HasFocus() { + return e + } + + switch e.Name() { + case app.Config.Keybindings.ToggleGuildsList: + app.SetFocus(app.GuildsList) + return nil + case app.Config.Keybindings.ToggleChannelsTreeView: + app.SetFocus(app.ChannelsTreeView) + return nil + case app.Config.Keybindings.ToggleMessagesTextView: + app.SetFocus(app.MessagesTextView) + return nil + case app.Config.Keybindings.ToggleMessageInputField: + app.SetFocus(app.MessageInputField) + return nil + } + + return e +} + +func (app *App) DrawMainFlex() { + 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) + + app.SetRoot(app.MainFlex, true) +} + func (app *App) onSessionReady(_ *discordgo.Session, r *discordgo.Ready) { app.GuildsList.AddItem("Direct Messages", "", 0, nil) @@ -87,7 +131,7 @@ func (app *App) onSessionReady(_ *discordgo.Session, r *discordgo.Ready) { } } -func (app *App) onGuildCreate(_ *discordgo.Session, g *discordgo.GuildCreate) { +func (app *App) onSessionGuildCreate(_ *discordgo.Session, g *discordgo.GuildCreate) { app.GuildsList.AddItem(g.Name, "", 0, nil) } diff --git a/ui/channels.go b/ui/channels.go new file mode 100644 index 0000000..6f923da --- /dev/null +++ b/ui/channels.go @@ -0,0 +1,65 @@ +package ui + +import ( + "github.com/ayntgl/discordgo" + "github.com/rivo/tview" +) + +type ChannelsTreeView struct { + *tview.TreeView + app *App +} + +func NewChannelsTreeView(app *App) *ChannelsTreeView { + ctv := &ChannelsTreeView{ + TreeView: tview.NewTreeView(), + app: app, + } + + ctv.SetTopLevel(1) + ctv.SetRoot(tview.NewTreeNode("")) + ctv.SetTitle("Channels") + ctv.SetTitleAlign(tview.AlignLeft) + ctv.SetBorder(true) + ctv.SetBorderPadding(0, 0, 1, 1) + ctv.SetSelectedFunc(ctv.onSelected) + return ctv +} + +func (ctv *ChannelsTreeView) onSelected(n *tview.TreeNode) { + ctv.app.SelectedMessage = -1 + ctv.app.MessagesTextView. + Highlight(). + Clear() + ctv.app.MessageInputField.SetText("") + + c, err := ctv.app.Session.State.Channel(n.GetReference().(string)) + if err != nil { + return + } + + if c.Type == discordgo.ChannelTypeGuildCategory { + n.SetExpanded(!n.IsExpanded()) + return + } + + ctv.app.SelectedChannel = c + ctv.app.SetFocus(ctv.app.MessageInputField) + + go func() { + ms, err := ctv.app.Session.ChannelMessages(c.ID, ctv.app.Config.General.FetchMessagesLimit, "", "", "") + if err != nil { + return + } + + for i := len(ms) - 1; i >= 0; i-- { + ctv.app.SelectedChannel.Messages = append(ctv.app.SelectedChannel.Messages, ms[i]) + _, err = ctv.app.MessagesTextView.Write(buildMessage(ctv.app, ms[i])) + if err != nil { + return + } + } + + ctv.app.MessagesTextView.ScrollToEnd() + }() +} diff --git a/ui/guilds.go b/ui/guilds.go new file mode 100644 index 0000000..9475830 --- /dev/null +++ b/ui/guilds.go @@ -0,0 +1,111 @@ +package ui + +import ( + "sort" + + "github.com/ayntgl/discordgo" + "github.com/ayntgl/discordo/discord" + "github.com/rivo/tview" +) + +type GuildsList struct { + *tview.List + app *App +} + +func NewGuildsList(app *App) *GuildsList { + gl := &GuildsList{ + List: tview.NewList(), + app: app, + } + + gl.ShowSecondaryText(false) + gl.SetTitle("Guilds") + gl.SetTitleAlign(tview.AlignLeft) + gl.SetBorder(true) + gl.SetBorderPadding(0, 0, 1, 1) + gl.SetSelectedFunc(gl.onSelected) + return gl +} + +func (gl *GuildsList) onSelected(idx int, mainText string, secondaryText string, shortcut rune) { + rootTreeNode := gl.app.ChannelsTreeView.GetRoot() + rootTreeNode.ClearChildren() + gl.app.SelectedMessage = -1 + gl.app.MessagesTextView. + Highlight(). + Clear() + gl.app.MessageInputField.SetText("") + + // If the user is a bot account, the direct messages item does not exist in the guilds list. + if gl.app.Session.State.User.Bot && idx == 0 { + idx = 1 + } + + if idx == 0 { // Direct Messages + cs := gl.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(discord.ChannelToString(c)). + SetReference(c.ID) + rootTreeNode.AddChild(channelTreeNode) + } + } else { // Guild + cs := gl.app.Session.State.Guilds[idx-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(discord.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(discord.ChannelToString(c)). + SetReference(c.ID) + parentTreeNode.AddChild(channelTreeNode) + } + } + } + } + + gl.app.ChannelsTreeView.SetCurrentNode(rootTreeNode) + gl.app.SetFocus(gl.app.ChannelsTreeView) +} diff --git a/ui/handlers.go b/ui/handlers.go deleted file mode 100644 index f218fe7..0000000 --- a/ui/handlers.go +++ /dev/null @@ -1,392 +0,0 @@ -package ui - -import ( - "io" - "os" - "os/exec" - "sort" - "strings" - - "github.com/atotto/clipboard" - "github.com/ayntgl/discordgo" - "github.com/ayntgl/discordo/discord" - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func onAppInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey { - if app.MessageInputField.HasFocus() { - return e - } - - switch e.Name() { - case app.Config.Keybindings.ToggleGuildsList: - app.SetFocus(app.GuildsList) - return nil - case app.Config.Keybindings.ToggleChannelsTreeView: - app.SetFocus(app.ChannelsTreeView) - return nil - case app.Config.Keybindings.ToggleMessagesTextView: - app.SetFocus(app.MessagesTextView) - return nil - case app.Config.Keybindings.ToggleMessageInputField: - app.SetFocus(app.MessageInputField) - return nil - } - - return e -} - -func onGuildsListSelected(app *App, guildIdx int) { - rootTreeNode := app.ChannelsTreeView.GetRoot() - rootTreeNode.ClearChildren() - app.SelectedMessage = -1 - app.MessagesTextView. - Highlight(). - Clear() - app.MessageInputField.SetText("") - - // If the user is a bot account, the direct messages item does not exist in the guilds list. - if app.Session.State.User.Bot && guildIdx == 0 { - guildIdx = 1 - } - - 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(discord.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(discord.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(discord.ChannelToString(c)). - SetReference(c.ID) - parentTreeNode.AddChild(channelTreeNode) - } - } - } - } - - app.ChannelsTreeView.SetCurrentNode(rootTreeNode) - app.SetFocus(app.ChannelsTreeView) -} - -func onChannelsTreeViewSelected(app *App, n *tview.TreeNode) { - app.SelectedMessage = -1 - app.MessagesTextView. - Highlight(). - Clear() - app.MessageInputField.SetText("") - - 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.SetFocus(app.MessageInputField) - - go func() { - ms, err := app.Session.ChannelMessages(c.ID, app.Config.General.FetchMessagesLimit, "", "", "") - if err != nil { - return - } - - for i := len(ms) - 1; i >= 0; i-- { - app.SelectedChannel.Messages = append(app.SelectedChannel.Messages, ms[i]) - _, err = app.MessagesTextView.Write(buildMessage(app, ms[i])) - if err != nil { - return - } - } - - 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 - } - - switch e.Name() { - case app.Config.Keybindings.SelectPreviousMessage: - 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 - case app.Config.Keybindings.SelectNextMessage: - 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 - case app.Config.Keybindings.SelectFirstMessage: - app.SelectedMessage = 0 - app.MessagesTextView. - Highlight(ms[app.SelectedMessage].ID). - ScrollToHighlight() - - return nil - case app.Config.Keybindings.SelectLastMessage: - app.SelectedMessage = len(ms) - 1 - app.MessagesTextView. - Highlight(ms[app.SelectedMessage].ID). - ScrollToHighlight() - - return nil - case app.Config.Keybindings.ToggleMessageActionsList: - messageActionsList := tview.NewList() - - hs := app.MessagesTextView.GetHighlights() - if len(hs) == 0 { - return nil - } - - _, m := discord.FindMessageByID(app.SelectedChannel.Messages, hs[0]) - if m == nil { - return nil - } - - if discord.HasPermission(app.Session.State, app.SelectedChannel.ID, discordgo.PermissionSendMessages) { - messageActionsList. - AddItem("Reply", "", 'r', nil). - AddItem("Mention Reply", "", 'R', nil) - } - - if m.ReferencedMessage != nil { - messageActionsList.AddItem("Select Reply", "", 'm', nil) - } - - messageActionsList. - ShowSecondaryText(false). - AddItem("Copy Content", "", 'c', nil). - AddItem("Copy ID", "", 'i', nil). - SetDoneFunc(func() { - app. - SetRoot(app.MainFlex, true). - SetFocus(app.MessagesTextView) - }). - SetSelectedFunc(func(_ int, mainText string, _ string, _ rune) { - onMessageActionsListSelected(app, mainText, m) - }). - SetTitle("Press the Escape key to close"). - SetBorder(true) - - app.SetRoot(messageActionsList, true) - - return nil - case "Esc": - app.SelectedMessage = -1 - app.SetFocus(app.MainFlex) - app.MessagesTextView. - Clear(). - Highlight() - - return nil - } - - return e -} - -func onMessageActionsListSelected(app *App, mainText string, m *discordgo.Message) { - switch mainText { - case "Copy Content": - if err := clipboard.WriteAll(m.Content); err != nil { - return - } - - app.SetRoot(app.MainFlex, false) - case "Copy ID": - if err := clipboard.WriteAll(m.ID); err != nil { - return - } - - app.SetRoot(app.MainFlex, false) - case "Reply": - app.MessageInputField.SetTitle("Replying to " + m.Author.String()) - app. - SetRoot(app.MainFlex, false). - SetFocus(app.MessageInputField) - case "Mention Reply": - app.MessageInputField.SetTitle("[@] Replying to " + m.Author.String()) - app. - SetRoot(app.MainFlex, false). - SetFocus(app.MessageInputField) - case "Select Reply": - app.SelectedMessage, _ = discord.FindMessageByID(app.SelectedChannel.Messages, m.ReferencedMessage.ID) - app.MessagesTextView. - Highlight(m.ReferencedMessage.ID). - ScrollToHighlight() - app. - SetRoot(app.MainFlex, false). - SetFocus(app.MessagesTextView) - } -} - -func onMessageInputFieldInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey { - switch e.Name() { - case "Enter": - if app.SelectedChannel == nil { - return nil - } - - t := strings.TrimSpace(app.MessageInputField.GetText()) - if t == "" { - return nil - } - - if len(app.MessagesTextView.GetHighlights()) != 0 { - _, m := discord.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 "Ctrl+V": - text, _ := clipboard.ReadAll() - text = app.MessageInputField.GetText() + text - app.MessageInputField.SetText(text) - - return nil - case "Esc": - app.MessageInputField. - SetText(""). - SetTitle("") - app.SetFocus(app.MainFlex) - - app.SelectedMessage = -1 - app.MessagesTextView.Highlight() - - return nil - case app.Config.Keybindings.ToggleExternalEditor: - e := os.Getenv("EDITOR") - if e == "" { - return nil - } - - f, err := os.CreateTemp(os.TempDir(), "discordo-*.md") - if err != nil { - return nil - } - defer os.Remove(f.Name()) - - cmd := exec.Command(e, f.Name()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - - app.Suspend(func() { - err = cmd.Run() - if err != nil { - return - } - }) - - b, err := io.ReadAll(f) - if err != nil { - return nil - } - - app.MessageInputField.SetText(string(b)) - - return nil - } - - return e -} diff --git a/ui/login.go b/ui/login.go new file mode 100644 index 0000000..add99ae --- /dev/null +++ b/ui/login.go @@ -0,0 +1,28 @@ +package ui + +import "github.com/rivo/tview" + +type LoginForm struct { + *tview.Form +} + +func NewLoginForm(mfa bool) *LoginForm { + lf := &LoginForm{ + Form: tview.NewForm(), + } + + if mfa { + lf.AddPasswordField("MFA Code (optional)", "", 0, 0, nil) + } else { + lf. + AddInputField("Email", "", 0, nil, nil). + AddPasswordField("Password", "", 0, 0, nil) + } + + lf.SetButtonsAlign(tview.AlignCenter) + lf.SetTitle("Login") + lf.SetTitleAlign(tview.AlignLeft) + lf.SetBorder(true) + lf.SetBorderPadding(0, 0, 1, 1) + return lf +} diff --git a/ui/messages.go b/ui/messages.go new file mode 100644 index 0000000..b171097 --- /dev/null +++ b/ui/messages.go @@ -0,0 +1,292 @@ +package ui + +import ( + "io" + "os" + "os/exec" + "strings" + + "github.com/atotto/clipboard" + "github.com/ayntgl/discordgo" + "github.com/ayntgl/discordo/discord" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type MessagesTextView struct { + *tview.TextView + app *App +} + +func NewMessagesTextView(app *App) *MessagesTextView { + mtv := &MessagesTextView{ + TextView: tview.NewTextView(), + app: app, + } + + mtv.SetDynamicColors(true) + mtv.SetRegions(true) + mtv.SetWordWrap(true) + mtv.SetChangedFunc(func() { + mtv.app.Draw() + }) + mtv.SetBorder(true) + mtv.SetBorderPadding(0, 0, 1, 1) + mtv.SetInputCapture(mtv.onInputCapture) + return mtv +} + +func (mtv *MessagesTextView) onInputCapture(e *tcell.EventKey) *tcell.EventKey { + if mtv.app.SelectedChannel == nil { + return nil + } + + ms := mtv.app.SelectedChannel.Messages + if len(ms) == 0 { + return nil + } + + switch e.Name() { + case mtv.app.Config.Keybindings.SelectPreviousMessage: + if len(mtv.app.MessagesTextView.GetHighlights()) == 0 { + mtv.app.SelectedMessage = len(ms) - 1 + } else { + mtv.app.SelectedMessage-- + if mtv.app.SelectedMessage < 0 { + mtv.app.SelectedMessage = 0 + } + } + + mtv.app.MessagesTextView. + Highlight(ms[mtv.app.SelectedMessage].ID). + ScrollToHighlight() + + return nil + case mtv.app.Config.Keybindings.SelectNextMessage: + if len(mtv.app.MessagesTextView.GetHighlights()) == 0 { + mtv.app.SelectedMessage = len(ms) - 1 + } else { + mtv.app.SelectedMessage++ + if mtv.app.SelectedMessage >= len(ms) { + mtv.app.SelectedMessage = len(ms) - 1 + } + } + + mtv.app.MessagesTextView. + Highlight(ms[mtv.app.SelectedMessage].ID). + ScrollToHighlight() + + return nil + case mtv.app.Config.Keybindings.SelectFirstMessage: + mtv.app.SelectedMessage = 0 + mtv.app.MessagesTextView. + Highlight(ms[mtv.app.SelectedMessage].ID). + ScrollToHighlight() + + return nil + case mtv.app.Config.Keybindings.SelectLastMessage: + mtv.app.SelectedMessage = len(ms) - 1 + mtv.app.MessagesTextView. + Highlight(ms[mtv.app.SelectedMessage].ID). + ScrollToHighlight() + + return nil + case mtv.app.Config.Keybindings.ToggleMessageActionsList: + messageActionsList := tview.NewList() + + hs := mtv.app.MessagesTextView.GetHighlights() + if len(hs) == 0 { + return nil + } + + _, m := discord.FindMessageByID(mtv.app.SelectedChannel.Messages, hs[0]) + if m == nil { + return nil + } + + if discord.HasPermission(mtv.app.Session.State, mtv.app.SelectedChannel.ID, discordgo.PermissionSendMessages) { + messageActionsList. + AddItem("Reply", "", 'r', nil). + AddItem("Mention Reply", "", 'R', nil) + } + + if m.ReferencedMessage != nil { + messageActionsList.AddItem("Select Reply", "", 'm', nil) + } + + messageActionsList. + ShowSecondaryText(false). + AddItem("Copy Content", "", 'c', nil). + AddItem("Copy ID", "", 'i', nil). + SetDoneFunc(func() { + mtv.app. + SetRoot(mtv.app.MainFlex, true). + SetFocus(mtv.app.MessagesTextView) + }). + SetSelectedFunc(func(_ int, mainText string, _ string, _ rune) { + onMessageActionsListSelected(mtv.app, mainText, m) + }). + SetTitle("Press the Escape key to close"). + SetBorder(true) + + mtv.app.SetRoot(messageActionsList, true) + + return nil + case "Esc": + mtv.app.SelectedMessage = -1 + mtv.app.SetFocus(mtv.app.MainFlex) + mtv.app.MessagesTextView. + Clear(). + Highlight() + + return nil + } + + return e +} + +func onMessageActionsListSelected(app *App, mainText string, m *discordgo.Message) { + switch mainText { + case "Copy Content": + if err := clipboard.WriteAll(m.Content); err != nil { + return + } + + app.SetRoot(app.MainFlex, false) + case "Copy ID": + if err := clipboard.WriteAll(m.ID); err != nil { + return + } + + app.SetRoot(app.MainFlex, false) + case "Reply": + app.MessageInputField.SetTitle("Replying to " + m.Author.String()) + app. + SetRoot(app.MainFlex, false). + SetFocus(app.MessageInputField) + case "Mention Reply": + app.MessageInputField.SetTitle("[@] Replying to " + m.Author.String()) + app. + SetRoot(app.MainFlex, false). + SetFocus(app.MessageInputField) + case "Select Reply": + app.SelectedMessage, _ = discord.FindMessageByID(app.SelectedChannel.Messages, m.ReferencedMessage.ID) + app.MessagesTextView. + Highlight(m.ReferencedMessage.ID). + ScrollToHighlight() + app. + SetRoot(app.MainFlex, false). + SetFocus(app.MessagesTextView) + } +} + +type MessageInputField struct { + *tview.InputField + app *App +} + +func NewMessageInputField(app *App) *MessageInputField { + mi := &MessageInputField{ + InputField: tview.NewInputField(), + app: app, + } + + mi.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor) + mi.SetPlaceholder("Message...") + mi.SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor)) + mi.SetTitleAlign(tview.AlignLeft) + mi.SetBorder(true) + mi.SetBorderPadding(0, 0, 1, 1) + mi.SetInputCapture(mi.onInputCapture) + return mi +} + +func (mi *MessageInputField) onInputCapture(e *tcell.EventKey) *tcell.EventKey { + switch e.Name() { + case "Enter": + if mi.app.SelectedChannel == nil { + return nil + } + + t := strings.TrimSpace(mi.app.MessageInputField.GetText()) + if t == "" { + return nil + } + + if len(mi.app.MessagesTextView.GetHighlights()) != 0 { + _, m := discord.FindMessageByID(mi.app.SelectedChannel.Messages, mi.app.MessagesTextView.GetHighlights()[0]) + d := &discordgo.MessageSend{ + Content: t, + Reference: m.Reference(), + AllowedMentions: &discordgo.MessageAllowedMentions{RepliedUser: false}, + } + if strings.HasPrefix(mi.app.MessageInputField.GetTitle(), "[@]") { + d.AllowedMentions.RepliedUser = true + } else { + d.AllowedMentions.RepliedUser = false + } + + go mi.app.Session.ChannelMessageSendComplex(m.ChannelID, d) + + mi.app.SelectedMessage = -1 + mi.app.MessagesTextView.Highlight() + + mi.app.MessageInputField.SetTitle("") + } else { + go mi.app.Session.ChannelMessageSend(mi.app.SelectedChannel.ID, t) + } + + mi.app.MessageInputField.SetText("") + + return nil + case "Ctrl+V": + text, _ := clipboard.ReadAll() + text = mi.app.MessageInputField.GetText() + text + mi.app.MessageInputField.SetText(text) + + return nil + case "Esc": + mi.app.MessageInputField. + SetText(""). + SetTitle("") + mi.app.SetFocus(mi.app.MainFlex) + + mi.app.SelectedMessage = -1 + mi.app.MessagesTextView.Highlight() + + return nil + case mi.app.Config.Keybindings.ToggleExternalEditor: + e := os.Getenv("EDITOR") + if e == "" { + return nil + } + + f, err := os.CreateTemp(os.TempDir(), "discordo-*.md") + if err != nil { + return nil + } + defer os.Remove(f.Name()) + + cmd := exec.Command(e, f.Name()) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + + mi.app.Suspend(func() { + err = cmd.Run() + if err != nil { + return + } + }) + + b, err := io.ReadAll(f) + if err != nil { + return nil + } + + mi.app.MessageInputField.SetText(string(b)) + + return nil + } + + return e +} diff --git a/ui/widgets.go b/ui/widgets.go deleted file mode 100644 index f5ae6b6..0000000 --- a/ui/widgets.go +++ /dev/null @@ -1,87 +0,0 @@ -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func NewMainFlex(app *App) *tview.Flex { - app.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { - return onAppInputCapture(app, e) - }) - - app.GuildsList. - ShowSecondaryText(false). - 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) - }). - SetBorder(true). - SetBorderPadding(0, 0, 1, 1) - - app.MessageInputField. - SetPlaceholder("Message..."). - SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor). - SetPlaceholderStyle(tcell.StyleDefault.Background(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) - - return app.MainFlex -} - -func NewLoginForm(mfa bool) *tview.Form { - loginForm := tview.NewForm() - loginForm. - SetButtonsAlign(tview.AlignCenter). - SetBorder(true). - SetBorderPadding(0, 0, 1, 0) - - if mfa { - loginForm.AddPasswordField("MFA Code (optional)", "", 0, 0, nil) - } else { - loginForm. - AddInputField("Email", "", 0, nil, nil). - AddPasswordField("Password", "", 0, 0, nil) - } - - return loginForm -}