From 91ca816ea011db4b5586cc45b16c04cd5f2ccb79 Mon Sep 17 00:00:00 2001 From: Ayyan Date: Wed, 31 Dec 2025 04:35:00 -0500 Subject: [PATCH] refactor: move app and chat UI out of cmd (#680) --- cmd/chatview.go | 187 ------------ cmd/root.go | 10 +- cmd/state.go | 177 ------------ cmd/application.go => internal/app/app.go | 49 ++-- internal/ui/chat/chatview.go | 316 +++++++++++++++++++++ {cmd => internal/ui/chat}/guilds_tree.go | 52 ++-- {cmd => internal/ui/chat}/message_input.go | 124 ++++---- {cmd => internal/ui/chat}/messages_list.go | 86 +++--- internal/ui/chat/state.go | 73 +++++ internal/{ => ui}/login/form.go | 0 internal/{ => ui}/login/qr.go | 0 11 files changed, 563 insertions(+), 511 deletions(-) delete mode 100644 cmd/chatview.go delete mode 100644 cmd/state.go rename cmd/application.go => internal/app/app.go (60%) create mode 100644 internal/ui/chat/chatview.go rename {cmd => internal/ui/chat}/guilds_tree.go (82%) rename {cmd => internal/ui/chat}/message_input.go (80%) rename {cmd => internal/ui/chat}/messages_list.go (87%) create mode 100644 internal/ui/chat/state.go rename internal/{ => ui}/login/form.go (100%) rename internal/{ => ui}/login/qr.go (100%) diff --git a/cmd/chatview.go b/cmd/chatview.go deleted file mode 100644 index 816eb25..0000000 --- a/cmd/chatview.go +++ /dev/null @@ -1,187 +0,0 @@ -package cmd - -import ( - "log/slog" - - "github.com/ayn2op/discordo/internal/config" - "github.com/ayn2op/discordo/internal/keyring" - "github.com/ayn2op/discordo/internal/ui" - "github.com/ayn2op/tview" - "github.com/diamondburned/arikawa/v3/discord" - "github.com/gdamore/tcell/v3" -) - -const ( - flexPageName = "flex" - mentionsListPageName = "mentionsList" - attachmentsListPageName = "attachmentsList" - confirmModalPageName = "confirmModal" -) - -type chatView struct { - *tview.Pages - - mainFlex *tview.Flex - rightFlex *tview.Flex - - guildsTree *guildsTree - messagesList *messagesList - messageInput *messageInput - - selectedChannel *discord.Channel - - app *tview.Application - cfg *config.Config -} - -func newChatView(app *tview.Application, cfg *config.Config) *chatView { - chatView := &chatView{ - Pages: tview.NewPages(), - - mainFlex: tview.NewFlex(), - rightFlex: tview.NewFlex(), - - guildsTree: newGuildsTree(cfg), - messagesList: newMessagesList(cfg), - messageInput: newMessageInput(cfg), - - app: app, - cfg: cfg, - } - - chatView.SetInputCapture(chatView.onInputCapture) - - chatView.buildLayout() - return chatView -} - -func (cv *chatView) buildLayout() { - cv.Clear() - cv.rightFlex.Clear() - cv.mainFlex.Clear() - - cv.rightFlex. - SetDirection(tview.FlexRow). - AddItem(cv.messagesList, 0, 1, false). - AddItem(cv.messageInput, 3, 1, false) - // The guilds tree is always focused first at start-up. - cv.mainFlex. - AddItem(cv.guildsTree, 0, 1, true). - AddItem(cv.rightFlex, 0, 4, false) - - cv.AddAndSwitchToPage(flexPageName, cv.mainFlex, true) -} - -func (cv *chatView) toggleGuildsTree() { - // The guilds tree is visible if the number of items is two. - if cv.mainFlex.GetItemCount() == 2 { - cv.mainFlex.RemoveItem(cv.guildsTree) - if cv.guildsTree.HasFocus() { - cv.app.SetFocus(cv.mainFlex) - } - } else { - cv.buildLayout() - cv.app.SetFocus(cv.guildsTree) - } -} - -func (cv *chatView) focusGuildsTree() bool { - // The guilds tree is not hidden if the number of items is two. - if cv.mainFlex.GetItemCount() == 2 { - cv.app.SetFocus(cv.guildsTree) - return true - } - - return false -} - -func (cv *chatView) focusMessageInput() bool { - if !cv.messageInput.GetDisabled() { - cv.app.SetFocus(cv.messageInput) - return true - } - - return false -} - -func (cv *chatView) focusPrevious() { - switch cv.app.GetFocus() { - case cv.guildsTree: - cv.focusMessageInput() - case cv.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is). - if ok := cv.focusGuildsTree(); !ok { - cv.app.SetFocus(cv.messageInput) - } - case cv.messageInput: - cv.app.SetFocus(cv.messagesList) - } -} - -func (cv *chatView) focusNext() { - switch cv.app.GetFocus() { - case cv.guildsTree: - cv.app.SetFocus(cv.messagesList) - case cv.messagesList: - cv.focusMessageInput() - case cv.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is). - if ok := cv.focusGuildsTree(); !ok { - cv.app.SetFocus(cv.messagesList) - } - } -} - -func (cv *chatView) onInputCapture(event *tcell.EventKey) *tcell.EventKey { - switch event.Name() { - case cv.cfg.Keys.FocusGuildsTree: - cv.messageInput.removeMentionsList() - cv.focusGuildsTree() - return nil - case cv.cfg.Keys.FocusMessagesList: - cv.messageInput.removeMentionsList() - cv.app.SetFocus(cv.messagesList) - return nil - case cv.cfg.Keys.FocusMessageInput: - cv.focusMessageInput() - return nil - case cv.cfg.Keys.FocusPrevious: - cv.focusPrevious() - return nil - case cv.cfg.Keys.FocusNext: - cv.focusNext() - return nil - case cv.cfg.Keys.Logout: - app.quit() - - if err := keyring.DeleteToken(); err != nil { - slog.Error("failed to delete token from keyring", "err", err) - return nil - } - - return nil - case cv.cfg.Keys.ToggleGuildsTree: - cv.toggleGuildsTree() - return nil - } - - return event -} - -func (cv *chatView) showConfirmModal(prompt string, buttons []string, onDone func(label string)) { - previousFocus := cv.app.GetFocus() - - modal := tview.NewModal(). - SetText(prompt). - AddButtons(buttons). - SetDoneFunc(func(_ int, buttonLabel string) { - cv.RemovePage(confirmModalPageName).SwitchToPage(flexPageName) - cv.app.SetFocus(previousFocus) - - if onDone != nil { - onDone(buttonLabel) - } - }) - - cv. - AddAndSwitchToPage(confirmModalPageName, ui.Centered(modal, 0, 0), true). - ShowPage(flexPageName) -} diff --git a/cmd/root.go b/cmd/root.go index e59bce1..370239c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,20 +6,15 @@ import ( "log/slog" "os" + "github.com/ayn2op/discordo/internal/app" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/keyring" "github.com/ayn2op/discordo/internal/logger" "github.com/diamondburned/arikawa/v3/utils/ws" - "github.com/diamondburned/ningen/v3" "github.com/spf13/cobra" ) -var ( - discordState *ningen.State - app *application -) - var ( token string configPath string @@ -62,8 +57,7 @@ var ( } } - app = newApplication(cfg) - return app.run(token) + return app.New(cfg).Run(token) }, } diff --git a/cmd/state.go b/cmd/state.go deleted file mode 100644 index 5f8ac6e..0000000 --- a/cmd/state.go +++ /dev/null @@ -1,177 +0,0 @@ -package cmd - -import ( - "context" - "log/slog" - - "github.com/ayn2op/discordo/internal/http" - "github.com/ayn2op/discordo/internal/notifications" - "github.com/ayn2op/tview" - "github.com/diamondburned/arikawa/v3/gateway" - "github.com/diamondburned/arikawa/v3/session" - "github.com/diamondburned/arikawa/v3/state" - "github.com/diamondburned/arikawa/v3/state/store/defaultstore" - "github.com/diamondburned/arikawa/v3/utils/handler" - "github.com/diamondburned/arikawa/v3/utils/httputil" - "github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver" - "github.com/diamondburned/arikawa/v3/utils/ws" - "github.com/diamondburned/ningen/v3" - "github.com/diamondburned/ningen/v3/states/read" -) - -func openState(token string) error { - identifyProps := http.IdentifyProperties() - gateway.DefaultIdentity = identifyProps - gateway.DefaultPresence = &gateway.UpdatePresenceCommand{ - Status: app.cfg.Status, - } - - id := gateway.DefaultIdentifier(token) - id.Compress = false - - session := session.NewCustom(id, http.NewClient(token), handler.New()) - state := state.NewFromSession(session, defaultstore.New()) - discordState = ningen.FromState(state) - - // Handlers - discordState.AddHandler(onRaw) - discordState.AddHandler(onReady) - discordState.AddHandler(onMessageCreate) - discordState.AddHandler(onMessageUpdate) - discordState.AddHandler(onMessageDelete) - discordState.AddHandler(onReadUpdate) - - discordState.AddHandler(func(event *gateway.GuildMembersChunkEvent) { - app.chatView.messagesList.setFetchingChunk(false, uint(len(event.Members))) - }) - - discordState.AddHandler(func(event *gateway.GuildMemberRemoveEvent) { - app.chatView.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, discordState.MemberState.SearchLimit) - }) - - discordState.StateLog = func(err error) { - slog.Error("state log", "err", err) - } - - discordState.OnRequest = append(discordState.OnRequest, httputil.WithHeaders(http.Headers()), onRequest) - return discordState.Open(context.TODO()) -} - -func onRequest(r httpdriver.Request) error { - if req, ok := r.(*httpdriver.DefaultRequest); ok { - slog.Debug("new HTTP request", "method", req.Method, "url", req.URL) - } - - return nil -} - -func onRaw(event *ws.RawEvent) { - slog.Debug( - "new raw event", - "code", event.OriginalCode, - "type", event.OriginalType, - // "data", event.Raw, - ) -} - -func onReadUpdate(event *read.UpdateEvent) { - var guildNode *tview.TreeNode - app.chatView.guildsTree. - GetRoot(). - Walk(func(node, parent *tview.TreeNode) bool { - switch node.GetReference() { - case event.GuildID: - node.SetTextStyle(app.chatView.guildsTree.getGuildNodeStyle(event.GuildID)) - guildNode = node - return false - case event.ChannelID: - // private channel - if !event.GuildID.IsValid() { - style := app.chatView.guildsTree.getChannelNodeStyle(event.ChannelID) - node.SetTextStyle(style) - return false - } - } - - return true - }) - - if guildNode != nil { - guildNode.Walk(func(node, parent *tview.TreeNode) bool { - if node.GetReference() == event.ChannelID { - node.SetTextStyle(app.chatView.guildsTree.getChannelNodeStyle(event.ChannelID)) - return false - } - - return true - }) - } - - app.Draw() -} - -func onReady(r *gateway.ReadyEvent) { - dmNode := tview.NewTreeNode("Direct Messages") - root := app.chatView.guildsTree. - GetRoot(). - ClearChildren(). - AddChild(dmNode) - - for _, folder := range r.UserSettings.GuildFolders { - if folder.ID == 0 && len(folder.GuildIDs) == 1 { - guild, err := discordState.Cabinet.Guild(folder.GuildIDs[0]) - if err != nil { - slog.Error( - "failed to get guild from state", - "guild_id", - folder.GuildIDs[0], - "err", - err, - ) - continue - } - - app.chatView.guildsTree.createGuildNode(root, *guild) - } else { - app.chatView.guildsTree.createFolderNode(folder) - } - } - - app.chatView.guildsTree.SetCurrentNode(root) - app.SetFocus(app.chatView.guildsTree) - app.Draw() -} - -func onMessageCreate(message *gateway.MessageCreateEvent) { - if app.chatView.selectedChannel != nil && - app.chatView.selectedChannel.ID == message.ChannelID { - app.chatView.messagesList.drawMessage(app.chatView.messagesList, message.Message) - app.Draw() - } - - if err := notifications.Notify(discordState, message, app.cfg); err != nil { - slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID) - } -} - -func onMessageUpdate(message *gateway.MessageUpdateEvent) { - if app.chatView.selectedChannel != nil && - app.chatView.selectedChannel.ID == message.ChannelID { - onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID}) - } -} - -func onMessageDelete(message *gateway.MessageDeleteEvent) { - if app.chatView.selectedChannel != nil && - app.chatView.selectedChannel.ID == message.ChannelID { - messages, err := discordState.Cabinet.Messages(message.ChannelID) - if err != nil { - slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID) - return - } - - app.chatView.messagesList.reset() - app.chatView.messagesList.drawMessages(messages) - app.Draw() - } -} diff --git a/cmd/application.go b/internal/app/app.go similarity index 60% rename from cmd/application.go rename to internal/app/app.go index 37d3801..a3ea7ed 100644 --- a/cmd/application.go +++ b/internal/app/app.go @@ -1,4 +1,4 @@ -package cmd +package app import ( "fmt" @@ -7,33 +7,34 @@ import ( "github.com/ayn2op/discordo/internal/clipboard" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" - "github.com/ayn2op/discordo/internal/login" + "github.com/ayn2op/discordo/internal/ui/chat" + "github.com/ayn2op/discordo/internal/ui/login" "github.com/ayn2op/tview" "github.com/gdamore/tcell/v3" ) -type application struct { - *tview.Application - chatView *chatView +type App struct { + inner *tview.Application + chatView *chat.ChatView cfg *config.Config } -func newApplication(cfg *config.Config) *application { +func New(cfg *config.Config) *App { tview.Styles = tview.Theme{} - app := &application{ - Application: tview.NewApplication(), - cfg: cfg, + app := &App{ + inner: tview.NewApplication(), + cfg: cfg, } if err := clipboard.Init(); err != nil { slog.Error("failed to init clipboard", "err", err) } - app.SetInputCapture(app.onInputCapture) + app.inner.SetInputCapture(app.onInputCapture) return app } -func (a *application) run(token string) error { +func (a *App) Run(token string) error { screen, err := tcell.NewScreen() if err != nil { return fmt.Errorf("failed to create screen: %w", err) @@ -50,44 +51,44 @@ func (a *application) run(token string) error { screen.SetTitle(consts.Name) screen.EnablePaste() screen.EnableFocus() - a.SetScreen(screen) + a.inner.SetScreen(screen) if token == "" { - loginForm := login.NewForm(a.Application, a.cfg, func(token string) { + loginForm := login.NewForm(a.inner, a.cfg, func(token string) { if err := a.showChatView(token); err != nil { slog.Error("failed to show chat view", "err", err) } }) - a.SetRoot(loginForm) + a.inner.SetRoot(loginForm) } else { if err := a.showChatView(token); err != nil { return err } } - return a.Run() + return a.inner.Run() } -func (a *application) showChatView(token string) error { - a.chatView = newChatView(a.Application, a.cfg) - if err := openState(token); err != nil { +func (a *App) showChatView(token string) error { + a.chatView = chat.NewChatView(a.inner, a.cfg, a.quit) + if err := a.chatView.OpenState(token); err != nil { return err } - a.SetRoot(a.chatView) + a.inner.SetRoot(a.chatView) return nil } -func (a *application) quit() { - if discordState != nil { - if err := discordState.Close(); err != nil { +func (a *App) quit() { + if a.chatView != nil { + if err := a.chatView.CloseState(); err != nil { slog.Error("failed to close the session", "err", err) } } - a.Stop() + a.inner.Stop() } -func (a *application) onInputCapture(event *tcell.EventKey) *tcell.EventKey { +func (a *App) onInputCapture(event *tcell.EventKey) *tcell.EventKey { switch event.Name() { case a.cfg.Keys.Quit: a.quit() diff --git a/internal/ui/chat/chatview.go b/internal/ui/chat/chatview.go new file mode 100644 index 0000000..4009208 --- /dev/null +++ b/internal/ui/chat/chatview.go @@ -0,0 +1,316 @@ +package chat + +import ( + "log/slog" + "sync" + + "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/keyring" + "github.com/ayn2op/discordo/internal/notifications" + "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/tview" + "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/gateway" + "github.com/diamondburned/ningen/v3" + "github.com/diamondburned/ningen/v3/states/read" + "github.com/gdamore/tcell/v3" +) + +const ( + flexPageName = "flex" + mentionsListPageName = "mentionsList" + attachmentsListPageName = "attachmentsList" + confirmModalPageName = "confirmModal" +) + +type ChatView struct { + *tview.Pages + + mainFlex *tview.Flex + rightFlex *tview.Flex + + guildsTree *guildsTree + messagesList *messagesList + messageInput *messageInput + + selectedChannel *discord.Channel + selectedChannelMu sync.RWMutex + + app *tview.Application + cfg *config.Config + state *ningen.State + + onLogout func() +} + +func NewChatView(app *tview.Application, cfg *config.Config, onLogout func()) *ChatView { + chatView := &ChatView{ + Pages: tview.NewPages(), + + mainFlex: tview.NewFlex(), + rightFlex: tview.NewFlex(), + + app: app, + cfg: cfg, + onLogout: onLogout, + } + chatView.guildsTree = newGuildsTree(cfg, chatView) + chatView.messagesList = newMessagesList(cfg, chatView) + chatView.messageInput = newMessageInput(cfg, chatView) + + chatView.SetInputCapture(chatView.onInputCapture) + chatView.buildLayout() + return chatView +} + +func (cv *ChatView) SelectedChannel() *discord.Channel { + cv.selectedChannelMu.RLock() + defer cv.selectedChannelMu.RUnlock() + return cv.selectedChannel +} + +func (cv *ChatView) SetSelectedChannel(channel *discord.Channel) { + cv.selectedChannelMu.Lock() + cv.selectedChannel = channel + cv.selectedChannelMu.Unlock() +} + +func (cv *ChatView) buildLayout() { + cv.Clear() + cv.rightFlex.Clear() + cv.mainFlex.Clear() + + cv.rightFlex. + SetDirection(tview.FlexRow). + AddItem(cv.messagesList, 0, 1, false). + AddItem(cv.messageInput, 3, 1, false) + // The guilds tree is always focused first at start-up. + cv.mainFlex. + AddItem(cv.guildsTree, 0, 1, true). + AddItem(cv.rightFlex, 0, 4, false) + + cv.AddAndSwitchToPage(flexPageName, cv.mainFlex, true) +} + +func (cv *ChatView) toggleGuildsTree() { + // The guilds tree is visible if the number of items is two. + if cv.mainFlex.GetItemCount() == 2 { + cv.mainFlex.RemoveItem(cv.guildsTree) + if cv.guildsTree.HasFocus() { + cv.app.SetFocus(cv.mainFlex) + } + } else { + cv.buildLayout() + cv.app.SetFocus(cv.guildsTree) + } +} + +func (cv *ChatView) focusGuildsTree() bool { + // The guilds tree is not hidden if the number of items is two. + if cv.mainFlex.GetItemCount() == 2 { + cv.app.SetFocus(cv.guildsTree) + return true + } + + return false +} + +func (cv *ChatView) focusMessageInput() bool { + if !cv.messageInput.GetDisabled() { + cv.app.SetFocus(cv.messageInput) + return true + } + + return false +} + +func (cv *ChatView) focusPrevious() { + switch cv.app.GetFocus() { + case cv.guildsTree: + cv.focusMessageInput() + case cv.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is). + if ok := cv.focusGuildsTree(); !ok { + cv.app.SetFocus(cv.messageInput) + } + case cv.messageInput: + cv.app.SetFocus(cv.messagesList) + } +} + +func (cv *ChatView) focusNext() { + switch cv.app.GetFocus() { + case cv.guildsTree: + cv.app.SetFocus(cv.messagesList) + case cv.messagesList: + cv.focusMessageInput() + case cv.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is). + if ok := cv.focusGuildsTree(); !ok { + cv.app.SetFocus(cv.messagesList) + } + } +} + +func (cv *ChatView) onInputCapture(event *tcell.EventKey) *tcell.EventKey { + switch event.Name() { + case cv.cfg.Keys.FocusGuildsTree: + cv.messageInput.removeMentionsList() + cv.focusGuildsTree() + return nil + case cv.cfg.Keys.FocusMessagesList: + cv.messageInput.removeMentionsList() + cv.app.SetFocus(cv.messagesList) + return nil + case cv.cfg.Keys.FocusMessageInput: + cv.focusMessageInput() + return nil + case cv.cfg.Keys.FocusPrevious: + cv.focusPrevious() + return nil + case cv.cfg.Keys.FocusNext: + cv.focusNext() + return nil + case cv.cfg.Keys.Logout: + if cv.onLogout != nil { + cv.onLogout() + } + + if err := keyring.DeleteToken(); err != nil { + slog.Error("failed to delete token from keyring", "err", err) + return nil + } + + return nil + case cv.cfg.Keys.ToggleGuildsTree: + cv.toggleGuildsTree() + return nil + } + + return event +} + +func (cv *ChatView) showConfirmModal(prompt string, buttons []string, onDone func(label string)) { + previousFocus := cv.app.GetFocus() + + modal := tview.NewModal(). + SetText(prompt). + AddButtons(buttons). + SetDoneFunc(func(_ int, buttonLabel string) { + cv.RemovePage(confirmModalPageName).SwitchToPage(flexPageName) + cv.app.SetFocus(previousFocus) + + if onDone != nil { + onDone(buttonLabel) + } + }) + + cv. + AddAndSwitchToPage(confirmModalPageName, ui.Centered(modal, 0, 0), true). + ShowPage(flexPageName) +} + +func (cv *ChatView) onReadUpdate(event *read.UpdateEvent) { + var guildNode *tview.TreeNode + cv.guildsTree. + GetRoot(). + Walk(func(node, parent *tview.TreeNode) bool { + switch node.GetReference() { + case event.GuildID: + node.SetTextStyle(cv.guildsTree.getGuildNodeStyle(event.GuildID)) + guildNode = node + return false + case event.ChannelID: + // private channel + if !event.GuildID.IsValid() { + style := cv.guildsTree.getChannelNodeStyle(event.ChannelID) + node.SetTextStyle(style) + return false + } + } + + return true + }) + + if guildNode != nil { + guildNode.Walk(func(node, parent *tview.TreeNode) bool { + if node.GetReference() == event.ChannelID { + node.SetTextStyle(cv.guildsTree.getChannelNodeStyle(event.ChannelID)) + return false + } + + return true + }) + } + + cv.app.Draw() +} + +func (cv *ChatView) onReady(r *gateway.ReadyEvent) { + dmNode := tview.NewTreeNode("Direct Messages") + root := cv.guildsTree. + GetRoot(). + ClearChildren(). + AddChild(dmNode) + + for _, folder := range r.UserSettings.GuildFolders { + if folder.ID == 0 && len(folder.GuildIDs) == 1 { + guild, err := cv.state.Cabinet.Guild(folder.GuildIDs[0]) + if err != nil { + slog.Error( + "failed to get guild from state", + "guild_id", + folder.GuildIDs[0], + "err", + err, + ) + continue + } + + cv.guildsTree.createGuildNode(root, *guild) + } else { + cv.guildsTree.createFolderNode(folder) + } + } + + cv.guildsTree.SetCurrentNode(root) + cv.app.SetFocus(cv.guildsTree) + cv.app.Draw() +} + +func (cv *ChatView) onMessageCreate(message *gateway.MessageCreateEvent) { + if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { + cv.messagesList.drawMessage(cv.messagesList, message.Message) + cv.app.Draw() + } + + if err := notifications.Notify(cv.state, message, cv.cfg); err != nil { + slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID) + } +} + +func (cv *ChatView) onMessageUpdate(message *gateway.MessageUpdateEvent) { + if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { + cv.onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID}) + } +} + +func (cv *ChatView) onMessageDelete(message *gateway.MessageDeleteEvent) { + if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { + messages, err := cv.state.Cabinet.Messages(message.ChannelID) + if err != nil { + slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID) + return + } + + cv.messagesList.reset() + cv.messagesList.drawMessages(messages) + cv.app.Draw() + } +} + +func (cv *ChatView) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) { + cv.messagesList.setFetchingChunk(false, uint(len(event.Members))) +} + +func (cv *ChatView) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { + cv.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, cv.state.MemberState.SearchLimit) +} diff --git a/cmd/guilds_tree.go b/internal/ui/chat/guilds_tree.go similarity index 82% rename from cmd/guilds_tree.go rename to internal/ui/chat/guilds_tree.go index 452d67b..13a3c2c 100644 --- a/cmd/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -1,4 +1,4 @@ -package cmd +package chat import ( "cmp" @@ -18,13 +18,15 @@ import ( type guildsTree struct { *tview.TreeView - cfg *config.Config + cfg *config.Config + chat *ChatView } -func newGuildsTree(cfg *config.Config) *guildsTree { +func newGuildsTree(cfg *config.Config, chat *ChatView) *guildsTree { gt := &guildsTree{ TreeView: tview.NewTreeView(), cfg: cfg, + chat: chat, } gt.Box = ui.ConfigureBox(gt.Box, &cfg.Theme) @@ -50,7 +52,7 @@ func (gt *guildsTree) createFolderNode(folder gateway.GuildFolder) { gt.GetRoot().AddChild(folderNode) for _, gID := range folder.GuildIDs { - guild, err := discordState.Cabinet.Guild(gID) + guild, err := gt.chat.state.Cabinet.Guild(gID) if err != nil { slog.Error("failed to get guild from state", "guild_id", gID, "err", err) continue @@ -76,12 +78,12 @@ func (gt *guildsTree) unreadStyle(indication ningen.UnreadIndication) tcell.Styl } func (gt *guildsTree) getGuildNodeStyle(guildID discord.GuildID) tcell.Style { - indication := discordState.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}}) + indication := gt.chat.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}}) return gt.unreadStyle(indication) } func (gt *guildsTree) getChannelNodeStyle(channelID discord.ChannelID) tcell.Style { - indication := discordState.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true}) + indication := gt.chat.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true}) return gt.unreadStyle(indication) } @@ -93,7 +95,7 @@ func (gt *guildsTree) createGuildNode(n *tview.TreeNode, guild discord.Guild) { } func (gt *guildsTree) createChannelNode(node *tview.TreeNode, channel discord.Channel) { - if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !discordState.HasPermissions(channel.ID, discord.PermissionViewChannel) { + if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionViewChannel) { return } @@ -149,9 +151,9 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { switch ref := node.GetReference().(type) { case discord.GuildID: - go discordState.MemberState.Subscribe(ref) + go gt.chat.state.MemberState.Subscribe(ref) - channels, err := discordState.Cabinet.Channels(ref) + channels, err := gt.chat.state.Cabinet.Channels(ref) if err != nil { slog.Error("failed to get channels", "err", err, "guild_id", ref) return @@ -163,7 +165,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { gt.createChannelNodes(node, channels) case discord.ChannelID: - channel, err := discordState.Cabinet.Channel(ref) + channel, err := gt.chat.state.Cabinet.Channel(ref) if err != nil { slog.Error("failed to get channel", "channel_id", ref) return @@ -172,7 +174,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { // Handle forum channels differently - they contain threads, not direct messages if channel.Type == discord.GuildForum { // Get all channels from the guild - this includes active threads from GuildCreateEvent - allChannels, err := discordState.Cabinet.Channels(channel.GuildID) + allChannels, err := gt.chat.state.Cabinet.Channels(channel.GuildID) if err != nil { slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID) return @@ -198,38 +200,38 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { return } - go discordState.ReadState.MarkRead(channel.ID, channel.LastMessageID) + go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID) - messages, err := discordState.Messages(channel.ID, uint(gt.cfg.MessagesLimit)) + messages, err := gt.chat.state.Messages(channel.ID, uint(gt.cfg.MessagesLimit)) if err != nil { slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", gt.cfg.MessagesLimit) return } if guildID := channel.GuildID; guildID.IsValid() { - app.chatView.messagesList.requestGuildMembers(guildID, messages) + gt.chat.messagesList.requestGuildMembers(guildID, messages) } - app.chatView.selectedChannel = channel + gt.chat.SetSelectedChannel(channel) - app.chatView.messagesList.reset() - app.chatView.messagesList.setTitle(*channel) - app.chatView.messagesList.drawMessages(messages) - app.chatView.messagesList.ScrollToEnd() + gt.chat.messagesList.reset() + gt.chat.messagesList.setTitle(*channel) + gt.chat.messagesList.drawMessages(messages) + gt.chat.messagesList.ScrollToEnd() - hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !discordState.HasPermissions(channel.ID, discord.PermissionSendMessages) - app.chatView.messageInput.SetDisabled(hasNoPerm) + hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionSendMessages) + gt.chat.messageInput.SetDisabled(hasNoPerm) if hasNoPerm { - app.chatView.messageInput.SetPlaceholder("You do not have permission to send messages in this channel.") + gt.chat.messageInput.SetPlaceholder("You do not have permission to send messages in this channel.") } else { - app.chatView.messageInput.SetPlaceholder("Message...") + gt.chat.messageInput.SetPlaceholder("Message...") if gt.cfg.AutoFocus { - app.SetFocus(app.chatView.messageInput) + gt.chat.app.SetFocus(gt.chat.messageInput) } } case nil: // Direct messages folder - channels, err := discordState.PrivateChannels() + channels, err := gt.chat.state.PrivateChannels() if err != nil { slog.Error("failed to get private channels", "err", err) return diff --git a/cmd/message_input.go b/internal/ui/chat/message_input.go similarity index 80% rename from cmd/message_input.go rename to internal/ui/chat/message_input.go index 0326e7f..e302b37 100644 --- a/cmd/message_input.go +++ b/internal/ui/chat/message_input.go @@ -1,4 +1,4 @@ -package cmd +package chat import ( "bytes" @@ -25,6 +25,7 @@ import ( "github.com/diamondburned/arikawa/v3/state" "github.com/diamondburned/arikawa/v3/utils/json/option" "github.com/diamondburned/arikawa/v3/utils/sendpart" + "github.com/diamondburned/ningen/v3" "github.com/diamondburned/ningen/v3/discordmd" "github.com/gdamore/tcell/v3" "github.com/ncruces/zenity" @@ -38,7 +39,8 @@ var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") type messageInput struct { *tview.TextArea - cfg *config.Config + cfg *config.Config + chat *ChatView edit bool sendMessageData *api.SendMessageData @@ -47,10 +49,11 @@ type messageInput struct { lastSearch time.Time } -func newMessageInput(cfg *config.Config) *messageInput { +func newMessageInput(cfg *config.Config, chat *ChatView) *messageInput { mi := &messageInput{ TextArea: tview.NewTextArea(), cfg: cfg, + chat: chat, sendMessageData: &api.SendMessageData{}, cache: cache.NewCache(), mentionsList: tview.NewList(), @@ -94,7 +97,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { return tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone) case mi.cfg.Keys.MessageInput.Send: - if app.chatView.GetVisibile(mentionsListPageName) { + if mi.chat.GetVisibile(mentionsListPageName) { mi.tabComplete() return nil } @@ -110,7 +113,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { mi.openFilePicker() return nil case mi.cfg.Keys.MessageInput.Cancel: - if app.chatView.GetVisibile(mentionsListPageName) { + if mi.chat.GetVisibile(mentionsListPageName) { mi.stopTabCompletion() } else { mi.reset() @@ -118,12 +121,12 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { return nil case mi.cfg.Keys.MessageInput.TabComplete: - go app.QueueUpdateDraw(func() { mi.tabComplete() }) + go mi.chat.app.QueueUpdateDraw(func() { mi.tabComplete() }) return nil } if mi.cfg.AutocompleteLimit > 0 { - if app.chatView.GetVisibile(mentionsListPageName) { + if mi.chat.GetVisibile(mentionsListPageName) { switch event.Name() { case mi.cfg.Keys.MentionsList.Up: mi.mentionsList.InputHandler()(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil) @@ -134,7 +137,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { } } - go app.QueueUpdateDraw(func() { mi.tabSuggestion() }) + go mi.chat.app.QueueUpdateDraw(func() { mi.tabSuggestion() }) } return event @@ -148,7 +151,8 @@ func (mi *messageInput) paste() { } func (mi *messageInput) send() { - if app.chatView.selectedChannel == nil { + selected := mi.chat.SelectedChannel() + if selected == nil { return } @@ -166,17 +170,17 @@ func (mi *messageInput) send() { } }() - text = processText(app.chatView.selectedChannel, []byte(text)) + text = processText(mi.chat.state, selected, []byte(text)) if mi.edit { - m, err := app.chatView.messagesList.selectedMessage() + m, err := mi.chat.messagesList.selectedMessage() if err != nil { slog.Error("failed to get selected message", "err", err) return } data := api.EditMessageData{Content: option.NewNullableString(text)} - if _, err := discordState.EditMessageComplex(m.ChannelID, m.ID, data); err != nil { + if _, err := mi.chat.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil { slog.Error("failed to edit message", "err", err) } @@ -184,17 +188,17 @@ func (mi *messageInput) send() { } else { data := mi.sendMessageData data.Content = text - if _, err := discordState.SendMessageComplex(app.chatView.selectedChannel.ID, *data); err != nil { - slog.Error("failed to send message in channel", "channel_id", app.chatView.selectedChannel.ID, "err", err) + if _, err := mi.chat.state.SendMessageComplex(selected.ID, *data); err != nil { + slog.Error("failed to send message in channel", "channel_id", selected.ID, "err", err) } } mi.reset() - app.chatView.messagesList.Highlight() - app.chatView.messagesList.ScrollToEnd() + mi.chat.messagesList.Highlight() + mi.chat.messagesList.ScrollToEnd() } -func processText(channel *discord.Channel, src []byte) string { +func processText(state *ningen.State, channel *discord.Channel, src []byte) string { var ( ranges [][2]int canMention = true @@ -218,13 +222,13 @@ func processText(channel *discord.Channel, src []byte) string { }) for _, rng := range ranges { - src = slices.Replace(src, rng[0], rng[1], expandMentions(channel, src[rng[0]:rng[1]])...) + src = slices.Replace(src, rng[0], rng[1], expandMentions(state, channel, src[rng[0]:rng[1]])...) } return string(src) } -func expandMentions(c *discord.Channel, src []byte) []byte { +func expandMentions(state *ningen.State, c *discord.Channel, src []byte) []byte { return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte { output := input name := string(input[1:]) @@ -235,7 +239,7 @@ func expandMentions(c *discord.Channel, src []byte) []byte { } } // self ping - me, err := discordState.Cabinet.Me() + me, err := state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) } else if strings.EqualFold(me.Username, name) { @@ -243,9 +247,9 @@ func expandMentions(c *discord.Channel, src []byte) []byte { } return output } - discordState.MemberStore.Each(c.GuildID, func(m *discord.Member) bool { + state.MemberStore.Each(c.GuildID, func(m *discord.Member) bool { if strings.EqualFold(m.User.Username, name) { - if channelHasUser(c.ID, m.User.ID) { + if channelHasUser(state, c.ID, m.User.ID) { output = []byte(m.User.ID.Mention()) } return true @@ -266,18 +270,22 @@ func (mi *messageInput) tabComplete() { } pos := posEnd - (len(name) + 1) - gID := app.chatView.selectedChannel.GuildID + selected := mi.chat.SelectedChannel() + if selected == nil { + return + } + gID := selected.GuildID if mi.cfg.AutocompleteLimit == 0 { if !gID.IsValid() { - users := app.chatView.selectedChannel.DMRecipients + users := selected.DMRecipients res := fuzzy.FindFrom(name, userList(users)) if len(res) > 0 { mi.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") } } else { mi.searchMember(gID, name) - members, err := discordState.Cabinet.Members(gID) + members, err := mi.chat.state.Cabinet.Members(gID) if err != nil { slog.Error("failed to get members from state", "guild_id", gID, "err", err) return @@ -285,7 +293,7 @@ func (mi *messageInput) tabComplete() { res := fuzzy.FindFrom(name, memberList(members)) for _, r := range res { - if channelHasUser(app.chatView.selectedChannel.ID, members[r.Index].User.ID) { + if channelHasUser(mi.chat.state, selected.ID, members[r.Index].User.ID) { mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") return } @@ -309,8 +317,12 @@ func (mi *messageInput) tabSuggestion() { mi.stopTabCompletion() return } - gID := app.chatView.selectedChannel.GuildID - cID := app.chatView.selectedChannel.ID + selected := mi.chat.SelectedChannel() + if selected == nil { + return + } + gID := selected.GuildID + cID := selected.ID mi.mentionsList.Clear() var shown map[string]struct{} @@ -318,7 +330,7 @@ func (mi *messageInput) tabSuggestion() { if name == "" { shown = make(map[string]struct{}) // Don't show @me in the list of recent authors - me, err := discordState.Cabinet.Me() + me, err := mi.chat.state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) } else { @@ -329,7 +341,7 @@ func (mi *messageInput) tabSuggestion() { // DMs have recipients, not members if !gID.IsValid() { if name == "" { // show recent messages' authors - msgs, err := discordState.Cabinet.Messages(cID) + msgs, err := mi.chat.state.Cabinet.Messages(cID) if err != nil { return } @@ -341,8 +353,8 @@ func (mi *messageInput) tabSuggestion() { mi.addMentionUser(&m.Author) } } else { - users := app.chatView.selectedChannel.DMRecipients - me, err := discordState.Cabinet.Me() + users := selected.DMRecipients + me, err := mi.chat.state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) } else { @@ -354,7 +366,7 @@ func (mi *messageInput) tabSuggestion() { } } } else if name == "" { // show recent messages' authors - msgs, err := discordState.Cabinet.Messages(cID) + msgs, err := mi.chat.state.Cabinet.Messages(cID) if err != nil { return } @@ -363,8 +375,8 @@ func (mi *messageInput) tabSuggestion() { continue } shown[m.Author.Username] = userDone - discordState.MemberState.RequestMember(gID, m.Author.ID) - if mem, err := discordState.Cabinet.Member(gID, m.Author.ID); err == nil { + mi.chat.state.MemberState.RequestMember(gID, m.Author.ID) + if mem, err := mi.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil { if mi.addMentionMember(gID, mem) { break } @@ -372,7 +384,7 @@ func (mi *messageInput) tabSuggestion() { } } else { mi.searchMember(gID, name) - mems, err := discordState.Cabinet.Members(gID) + mems, err := mi.chat.state.Cabinet.Members(gID) if err != nil { slog.Error("fetching members failed", "err", err) return @@ -382,7 +394,7 @@ func (mi *messageInput) tabSuggestion() { res = res[:int(mi.cfg.AutocompleteLimit)] } for _, r := range res { - if channelHasUser(cID, mems[r.Index].User.ID) && + if channelHasUser(mi.chat.state, cID, mems[r.Index].User.ID) && mi.addMentionMember(gID, &mems[r.Index]) { break } @@ -417,8 +429,8 @@ func (ul userList) Len() int { } // channelHasUser checks if a user has permission to view the specified channel -func channelHasUser(channelID discord.ChannelID, userID discord.UserID) bool { - perms, err := discordState.Permissions(channelID, userID) +func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID discord.UserID) bool { + perms, err := state.Permissions(channelID, userID) if err != nil { slog.Error("failed to get permissions", "err", err, "channel", channelID, "user", userID) return false @@ -436,22 +448,22 @@ func (mi *messageInput) searchMember(gID discord.GuildID, name string) { // everything starting with "ab". This will still be true even if a new // member joins because arikawa loads new members into the state. if k := key[:len(key)-1]; mi.cache.Exists(k) { - if c := mi.cache.Get(k); c < discordState.MemberState.SearchLimit { + if c := mi.cache.Get(k); c < mi.chat.state.MemberState.SearchLimit { mi.cache.Create(key, c) return } } // Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect - if mi.lastSearch.Add(discordState.MemberState.SearchFrequency).After(time.Now()) { + if mi.lastSearch.Add(mi.chat.state.MemberState.SearchFrequency).After(time.Now()) { return } mi.lastSearch = time.Now() - app.chatView.messagesList.waitForChunkEvent() - app.chatView.messagesList.setFetchingChunk(true, 0) - discordState.MemberState.SearchMember(gID, name) - mi.cache.Create(key, app.chatView.messagesList.waitForChunkEvent()) + mi.chat.messagesList.waitForChunkEvent() + mi.chat.messagesList.setFetchingChunk(true, 0) + mi.chat.state.MemberState.SearchMember(gID, name) + mi.cache.Create(key, mi.chat.messagesList.waitForChunkEvent()) } func (mi *messageInput) showMentionList() { @@ -462,7 +474,7 @@ func (mi *messageInput) showMentionList() { l := mi.mentionsList x, _, _, _ := mi.GetInnerRect() _, y, _, _ := mi.GetRect() - _, _, maxW, maxH := app.chatView.messagesList.GetInnerRect() + _, _, maxW, maxH := mi.chat.messagesList.GetInnerRect() if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 { maxH = min(maxH, t) } @@ -485,10 +497,10 @@ func (mi *messageInput) showMentionList() { l.SetRect(x, y, w, h) - app.chatView. + mi.chat. AddAndSwitchToPage(mentionsListPageName, l, false). ShowPage(flexPageName) - app.SetFocus(mi) + mi.chat.app.SetFocus(mi) } func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool { @@ -501,16 +513,16 @@ func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) name = m.Nick } - // this is WAY faster than discordState.MemberColor + // This avoids a slower member color lookup path. color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role { - r, _ := discordState.Cabinet.Role(gID, id) + r, _ := mi.chat.state.Cabinet.Role(gID, id) return r }) if ok { name = fmt.Sprintf("[%s]%s[-]", color, name) } - presence, err := discordState.Cabinet.Presence(gID, m.User.ID) + presence, err := mi.chat.state.Cabinet.Presence(gID, m.User.ID) if err != nil { slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err) } else if presence.Status == discord.OfflineStatus { @@ -527,7 +539,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) { } name := user.DisplayOrUsername() - presence, err := discordState.Cabinet.Presence(discord.NullGuildID, user.ID) + presence, err := mi.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID) if err != nil { slog.Info("failed to get presence from state", "user_id", user.ID, "err", err) } else if presence.Status == discord.OfflineStatus { @@ -539,7 +551,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) { // used by chatView func (mi *messageInput) removeMentionsList() { - app.chatView. + mi.chat. RemovePage(mentionsListPageName). SwitchToPage(flexPageName) } @@ -548,7 +560,7 @@ func (mi *messageInput) stopTabCompletion() { if mi.cfg.AutocompleteLimit > 0 { mi.mentionsList.Clear() mi.removeMentionsList() - app.SetFocus(mi) + mi.chat.app.SetFocus(mi) } } @@ -568,7 +580,7 @@ func (mi *messageInput) editor() { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - app.Suspend(func() { + mi.chat.app.Suspend(func() { err := cmd.Run() if err != nil { slog.Error("failed to run command", "args", cmd.Args, "err", err) @@ -586,7 +598,7 @@ func (mi *messageInput) editor() { } func (mi *messageInput) openFilePicker() { - if app.chatView.selectedChannel == nil { + if mi.chat.SelectedChannel() == nil { return } diff --git a/cmd/messages_list.go b/internal/ui/chat/messages_list.go similarity index 87% rename from cmd/messages_list.go rename to internal/ui/chat/messages_list.go index 86aa967..ba10b92 100644 --- a/cmd/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -1,4 +1,4 @@ -package cmd +package chat import ( "context" @@ -36,6 +36,7 @@ import ( type messagesList struct { *tview.TextView cfg *config.Config + chat *ChatView selectedMessageID discord.MessageID renderer *markdown.Renderer @@ -48,10 +49,11 @@ type messagesList struct { } } -func newMessagesList(cfg *config.Config) *messagesList { +func newMessagesList(cfg *config.Config, chat *ChatView) *messagesList { ml := &messagesList{ TextView: tview.NewTextView(), cfg: cfg, + chat: chat, renderer: markdown.NewRenderer(cfg.Theme.MessagesList), } @@ -98,7 +100,7 @@ func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) { fmt.Fprintf(writer, `["%s"]`, message.ID) if ml.cfg.HideBlockedUsers { - isBlocked := discordState.UserIsBlocked(message.Author.ID) + isBlocked := ml.chat.state.UserIsBlocked(message.Author.ID) if isBlocked { io.WriteString(writer, "[:red:b]Blocked message[:-:-]") return @@ -146,7 +148,7 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) { // Webhooks do not have nicknames or roles. if message.GuildID.IsValid() && !message.WebhookID.IsValid() { - member, err := discordState.Cabinet.Member(message.GuildID, message.Author.ID) + member, err := ml.chat.state.Cabinet.Member(message.GuildID, message.Author.ID) if err != nil { slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err) } else { @@ -155,7 +157,7 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) { } color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role { - r, _ := discordState.Cabinet.Role(message.GuildID, id) + r, _ := ml.chat.state.Cabinet.Role(message.GuildID, id) return r }) if ok { @@ -169,8 +171,8 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) { func (ml *messagesList) drawContent(w io.Writer, message discord.Message) { c := []byte(tview.Escape(message.Content)) - if app.cfg.Markdown { - ast := discordmd.ParseWithMessage(c, *discordState.Cabinet, &message, false) + if ml.chat.cfg.Markdown { + ast := discordmd.ParseWithMessage(c, *ml.chat.state.Cabinet, &message, false) ml.renderer.Render(w, c, ast) } else { w.Write(c) // write the content as is @@ -241,7 +243,12 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) { return nil, errors.New("no message is currently selected") } - m, err := discordState.Cabinet.Message(app.chatView.selectedChannel.ID, ml.selectedMessageID) + selected := ml.chat.SelectedChannel() + if selected == nil { + return nil, errors.New("no channel is currently selected") + } + + m, err := ml.chat.state.Cabinet.Message(selected.ID, ml.selectedMessageID) if err != nil { return nil, fmt.Errorf("failed to retrieve selected message: %w", err) } @@ -250,7 +257,12 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) { } func (ml *messagesList) selectedMessageIndex() (int, error) { - ms, err := discordState.Cabinet.Messages(app.chatView.selectedChannel.ID) + selected := ml.chat.SelectedChannel() + if selected == nil { + return -1, errors.New("no channel is currently selected") + } + + ms, err := ml.chat.state.Cabinet.Messages(selected.ID) if err != nil { return -1, err } @@ -296,13 +308,14 @@ func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey { } func (ml *messagesList) _select(name string) { - if app.chatView.selectedChannel == nil { + selected := ml.chat.SelectedChannel() + if selected == nil { return } - ms, err := discordState.Cabinet.Messages(app.chatView.selectedChannel.ID) + ms, err := ml.chat.state.Cabinet.Messages(selected.ID) if err != nil { - slog.Error("failed to get messages", "err", err, "channel_id", app.chatView.selectedChannel.ID) + slog.Error("failed to get messages", "err", err, "channel_id", selected.ID) return } @@ -456,8 +469,8 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord SetHighlightFullLine(true). ShowSecondaryText(false). SetDoneFunc(func() { - app.chatView.RemovePage(attachmentsListPageName).SwitchToPage(flexPageName) - app.SetFocus(ml) + ml.chat.RemovePage(attachmentsListPageName).SwitchToPage(flexPageName) + ml.chat.app.SetFocus(ml) }) list. SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { @@ -492,7 +505,7 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord }) } - app.chatView. + ml.chat. AddAndSwitchToPage(attachmentsListPageName, ui.Centered(list, 0, 0), true). ShowPage(flexPageName) } @@ -545,7 +558,7 @@ func (ml *messagesList) reply(mention bool) { name := msg.Author.DisplayOrUsername() if msg.GuildID.IsValid() { - member, err := discordState.Cabinet.Member(msg.GuildID, msg.Author.ID) + member, err := ml.chat.state.Cabinet.Member(msg.GuildID, msg.Author.ID) if err != nil { slog.Error("failed to get member from state", "guild_id", msg.GuildID, "member_id", msg.Author.ID, "err", err) } else { @@ -555,7 +568,7 @@ func (ml *messagesList) reply(mention bool) { } } - data := app.chatView.messageInput.sendMessageData + data := ml.chat.messageInput.sendMessageData data.Reference = &discord.MessageReference{MessageID: ml.selectedMessageID} data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False} @@ -565,9 +578,9 @@ func (ml *messagesList) reply(mention bool) { title = "[@] " + title } - app.chatView.messageInput.sendMessageData = data - app.chatView.messageInput.SetTitle(title + name) - app.SetFocus(app.chatView.messageInput) + ml.chat.messageInput.sendMessageData = data + ml.chat.messageInput.SetTitle(title + name) + ml.chat.app.SetFocus(ml.chat.messageInput) } func (ml *messagesList) edit() { @@ -577,7 +590,7 @@ func (ml *messagesList) edit() { return } - me, err := discordState.Cabinet.Me() + me, err := ml.chat.state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) return @@ -588,10 +601,10 @@ func (ml *messagesList) edit() { return } - app.chatView.messageInput.SetTitle("Editing") - app.chatView.messageInput.edit = true - app.chatView.messageInput.SetText(message.Content, true) - app.SetFocus(app.chatView.messageInput) + ml.chat.messageInput.SetTitle("Editing") + ml.chat.messageInput.edit = true + ml.chat.messageInput.SetText(message.Content, true) + ml.chat.app.SetFocus(ml.chat.messageInput) } func (ml *messagesList) confirmDelete() { @@ -601,7 +614,7 @@ func (ml *messagesList) confirmDelete() { } } - app.chatView.showConfirmModal( + ml.chat.showConfirmModal( "Are you sure you want to delete this message?", []string{"Yes", "No"}, onChoice, @@ -616,28 +629,33 @@ func (ml *messagesList) delete() { } if msg.GuildID.IsValid() { - me, err := discordState.Cabinet.Me() + me, err := ml.chat.state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) return } - if msg.Author.ID != me.ID && !discordState.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) { + if msg.Author.ID != me.ID && !ml.chat.state.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) { slog.Error("failed to delete message; missing relevant permissions", "channel_id", msg.ChannelID, "message_id", msg.ID) return } } - if err := discordState.DeleteMessage(app.chatView.selectedChannel.ID, msg.ID, ""); err != nil { - slog.Error("failed to delete message", "channel_id", app.chatView.selectedChannel.ID, "message_id", msg.ID, "err", err) + selected := ml.chat.SelectedChannel() + if selected == nil { + return + } + + if err := ml.chat.state.DeleteMessage(selected.ID, msg.ID, ""); err != nil { + slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err) return } ml.selectedMessageID = 0 ml.Highlight() - if err := discordState.MessageRemove(app.chatView.selectedChannel.ID, msg.ID); err != nil { - slog.Error("failed to delete message", "channel_id", app.chatView.selectedChannel.ID, "message_id", msg.ID, "err", err) + if err := ml.chat.state.MessageRemove(selected.ID, msg.ID); err != nil { + slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err) return } @@ -653,13 +671,13 @@ func (ml *messagesList) requestGuildMembers(gID discord.GuildID, ms []discord.Me continue } - if member, _ := discordState.Cabinet.Member(gID, m.Author.ID); member == nil { + if member, _ := ml.chat.state.Cabinet.Member(gID, m.Author.ID); member == nil { usersToFetch = append(usersToFetch, m.Author.ID) } } if len(usersToFetch) > 0 { - err := discordState.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{ + err := ml.chat.state.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{ GuildIDs: []discord.GuildID{gID}, UserIDs: slices.Compact(usersToFetch), }) diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go new file mode 100644 index 0000000..0ef59df --- /dev/null +++ b/internal/ui/chat/state.go @@ -0,0 +1,73 @@ +package chat + +import ( + "context" + "log/slog" + + "github.com/ayn2op/discordo/internal/http" + "github.com/diamondburned/arikawa/v3/gateway" + "github.com/diamondburned/arikawa/v3/session" + "github.com/diamondburned/arikawa/v3/state" + "github.com/diamondburned/arikawa/v3/state/store/defaultstore" + "github.com/diamondburned/arikawa/v3/utils/handler" + "github.com/diamondburned/arikawa/v3/utils/httputil" + "github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver" + "github.com/diamondburned/arikawa/v3/utils/ws" + "github.com/diamondburned/ningen/v3" +) + +func (cv *ChatView) OpenState(token string) error { + identifyProps := http.IdentifyProperties() + gateway.DefaultIdentity = identifyProps + gateway.DefaultPresence = &gateway.UpdatePresenceCommand{ + Status: cv.cfg.Status, + } + + id := gateway.DefaultIdentifier(token) + id.Compress = false + + session := session.NewCustom(id, http.NewClient(token), handler.New()) + state := state.NewFromSession(session, defaultstore.New()) + cv.state = ningen.FromState(state) + + // Handlers + cv.state.AddHandler(cv.onRaw) + cv.state.AddHandler(cv.onReady) + cv.state.AddHandler(cv.onMessageCreate) + cv.state.AddHandler(cv.onMessageUpdate) + cv.state.AddHandler(cv.onMessageDelete) + cv.state.AddHandler(cv.onReadUpdate) + cv.state.AddHandler(cv.onGuildMembersChunk) + cv.state.AddHandler(cv.onGuildMemberRemove) + + cv.state.StateLog = func(err error) { + slog.Error("state log", "err", err) + } + + cv.state.OnRequest = append(cv.state.OnRequest, httputil.WithHeaders(http.Headers()), cv.onRequest) + return cv.state.Open(context.TODO()) +} + +func (cv *ChatView) CloseState() error { + if cv.state == nil { + return nil + } + return cv.state.Close() +} + +func (cv *ChatView) onRequest(r httpdriver.Request) error { + if req, ok := r.(*httpdriver.DefaultRequest); ok { + slog.Debug("new HTTP request", "method", req.Method, "url", req.URL) + } + + return nil +} + +func (cv *ChatView) onRaw(event *ws.RawEvent) { + slog.Debug( + "new raw event", + "code", event.OriginalCode, + "type", event.OriginalType, + // "data", event.Raw, + ) +} diff --git a/internal/login/form.go b/internal/ui/login/form.go similarity index 100% rename from internal/login/form.go rename to internal/ui/login/form.go diff --git a/internal/login/qr.go b/internal/ui/login/qr.go similarity index 100% rename from internal/login/qr.go rename to internal/ui/login/qr.go