diff --git a/internal/app/app.go b/internal/app/app.go index a3ea7ed..a8fa5c3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,7 +15,7 @@ import ( type App struct { inner *tview.Application - chatView *chat.ChatView + chatView *chat.View cfg *config.Config } @@ -70,7 +70,7 @@ func (a *App) Run(token string) error { } func (a *App) showChatView(token string) error { - a.chatView = chat.NewChatView(a.inner, a.cfg, a.quit) + a.chatView = chat.NewView(a.inner, a.cfg, a.quit) if err := a.chatView.OpenState(token); err != nil { return err } diff --git a/internal/ui/chat/chatview.go b/internal/ui/chat/chatview.go deleted file mode 100644 index 4009208..0000000 --- a/internal/ui/chat/chatview.go +++ /dev/null @@ -1,316 +0,0 @@ -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/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index 13a3c2c..d2cc4e1 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -19,14 +19,14 @@ import ( type guildsTree struct { *tview.TreeView cfg *config.Config - chat *ChatView + chatView *View } -func newGuildsTree(cfg *config.Config, chat *ChatView) *guildsTree { +func newGuildsTree(cfg *config.Config, chatView *View) *guildsTree { gt := &guildsTree{ TreeView: tview.NewTreeView(), cfg: cfg, - chat: chat, + chatView: chatView, } gt.Box = ui.ConfigureBox(gt.Box, &cfg.Theme) @@ -52,7 +52,7 @@ func (gt *guildsTree) createFolderNode(folder gateway.GuildFolder) { gt.GetRoot().AddChild(folderNode) for _, gID := range folder.GuildIDs { - guild, err := gt.chat.state.Cabinet.Guild(gID) + guild, err := gt.chatView.state.Cabinet.Guild(gID) if err != nil { slog.Error("failed to get guild from state", "guild_id", gID, "err", err) continue @@ -78,12 +78,12 @@ func (gt *guildsTree) unreadStyle(indication ningen.UnreadIndication) tcell.Styl } func (gt *guildsTree) getGuildNodeStyle(guildID discord.GuildID) tcell.Style { - indication := gt.chat.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}}) + indication := gt.chatView.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}}) return gt.unreadStyle(indication) } func (gt *guildsTree) getChannelNodeStyle(channelID discord.ChannelID) tcell.Style { - indication := gt.chat.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true}) + indication := gt.chatView.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true}) return gt.unreadStyle(indication) } @@ -95,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 && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionViewChannel) { + if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chatView.state.HasPermissions(channel.ID, discord.PermissionViewChannel) { return } @@ -151,9 +151,9 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { switch ref := node.GetReference().(type) { case discord.GuildID: - go gt.chat.state.MemberState.Subscribe(ref) + go gt.chatView.state.MemberState.Subscribe(ref) - channels, err := gt.chat.state.Cabinet.Channels(ref) + channels, err := gt.chatView.state.Cabinet.Channels(ref) if err != nil { slog.Error("failed to get channels", "err", err, "guild_id", ref) return @@ -165,7 +165,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { gt.createChannelNodes(node, channels) case discord.ChannelID: - channel, err := gt.chat.state.Cabinet.Channel(ref) + channel, err := gt.chatView.state.Cabinet.Channel(ref) if err != nil { slog.Error("failed to get channel", "channel_id", ref) return @@ -174,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 := gt.chat.state.Cabinet.Channels(channel.GuildID) + allChannels, err := gt.chatView.state.Cabinet.Channels(channel.GuildID) if err != nil { slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID) return @@ -200,38 +200,38 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { return } - go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID) + go gt.chatView.state.ReadState.MarkRead(channel.ID, channel.LastMessageID) - messages, err := gt.chat.state.Messages(channel.ID, uint(gt.cfg.MessagesLimit)) + messages, err := gt.chatView.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() { - gt.chat.messagesList.requestGuildMembers(guildID, messages) + gt.chatView.messagesList.requestGuildMembers(guildID, messages) } - gt.chat.SetSelectedChannel(channel) + gt.chatView.SetSelectedChannel(channel) - gt.chat.messagesList.reset() - gt.chat.messagesList.setTitle(*channel) - gt.chat.messagesList.drawMessages(messages) - gt.chat.messagesList.ScrollToEnd() + gt.chatView.messagesList.reset() + gt.chatView.messagesList.setTitle(*channel) + gt.chatView.messagesList.drawMessages(messages) + gt.chatView.messagesList.ScrollToEnd() - hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionSendMessages) - gt.chat.messageInput.SetDisabled(hasNoPerm) + hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chatView.state.HasPermissions(channel.ID, discord.PermissionSendMessages) + gt.chatView.messageInput.SetDisabled(hasNoPerm) if hasNoPerm { - gt.chat.messageInput.SetPlaceholder("You do not have permission to send messages in this channel.") + gt.chatView.messageInput.SetPlaceholder("You do not have permission to send messages in this channel.") } else { - gt.chat.messageInput.SetPlaceholder("Message...") + gt.chatView.messageInput.SetPlaceholder("Message...") if gt.cfg.AutoFocus { - gt.chat.app.SetFocus(gt.chat.messageInput) + gt.chatView.app.SetFocus(gt.chatView.messageInput) } } case nil: // Direct messages folder - channels, err := gt.chat.state.PrivateChannels() + channels, err := gt.chatView.state.PrivateChannels() if err != nil { slog.Error("failed to get private channels", "err", err) return diff --git a/internal/ui/chat/message_input.go b/internal/ui/chat/message_input.go index e2b8e06..9cdbc95 100644 --- a/internal/ui/chat/message_input.go +++ b/internal/ui/chat/message_input.go @@ -40,7 +40,7 @@ var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") type messageInput struct { *tview.TextArea cfg *config.Config - chat *ChatView + chatView *View edit bool sendMessageData *api.SendMessageData @@ -49,11 +49,11 @@ type messageInput struct { lastSearch time.Time } -func newMessageInput(cfg *config.Config, chat *ChatView) *messageInput { +func newMessageInput(cfg *config.Config, chatView *View) *messageInput { mi := &messageInput{ TextArea: tview.NewTextArea(), cfg: cfg, - chat: chat, + chatView: chatView, sendMessageData: &api.SendMessageData{}, cache: cache.NewCache(), mentionsList: tview.NewList(), @@ -96,7 +96,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { return tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone) case mi.cfg.Keys.MessageInput.Send: - if mi.chat.GetVisibile(mentionsListPageName) { + if mi.chatView.GetVisibile(mentionsListPageName) { mi.tabComplete() return nil } @@ -112,7 +112,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { mi.openFilePicker() return nil case mi.cfg.Keys.MessageInput.Cancel: - if mi.chat.GetVisibile(mentionsListPageName) { + if mi.chatView.GetVisibile(mentionsListPageName) { mi.stopTabCompletion() } else { mi.reset() @@ -120,12 +120,12 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { return nil case mi.cfg.Keys.MessageInput.TabComplete: - go mi.chat.app.QueueUpdateDraw(func() { mi.tabComplete() }) + go mi.chatView.app.QueueUpdateDraw(func() { mi.tabComplete() }) return nil } if mi.cfg.AutocompleteLimit > 0 { - if mi.chat.GetVisibile(mentionsListPageName) { + if mi.chatView.GetVisibile(mentionsListPageName) { switch event.Name() { case mi.cfg.Keys.MentionsList.Up: mi.mentionsList.InputHandler()(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil) @@ -136,7 +136,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { } } - go mi.chat.app.QueueUpdateDraw(func() { mi.tabSuggestion() }) + go mi.chatView.app.QueueUpdateDraw(func() { mi.tabSuggestion() }) } return event @@ -150,7 +150,7 @@ func (mi *messageInput) paste() { } func (mi *messageInput) send() { - selected := mi.chat.SelectedChannel() + selected := mi.chatView.SelectedChannel() if selected == nil { return } @@ -169,17 +169,17 @@ func (mi *messageInput) send() { } }() - text = processText(mi.chat.state, selected, []byte(text)) + text = processText(mi.chatView.state, selected, []byte(text)) if mi.edit { - m, err := mi.chat.messagesList.selectedMessage() + m, err := mi.chatView.messagesList.selectedMessage() if err != nil { slog.Error("failed to get selected message", "err", err) return } data := api.EditMessageData{Content: option.NewNullableString(text)} - if _, err := mi.chat.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil { + if _, err := mi.chatView.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil { slog.Error("failed to edit message", "err", err) } @@ -187,14 +187,14 @@ func (mi *messageInput) send() { } else { data := mi.sendMessageData data.Content = text - if _, err := mi.chat.state.SendMessageComplex(selected.ID, *data); err != nil { + if _, err := mi.chatView.state.SendMessageComplex(selected.ID, *data); err != nil { slog.Error("failed to send message in channel", "channel_id", selected.ID, "err", err) } } mi.reset() - mi.chat.messagesList.Highlight() - mi.chat.messagesList.ScrollToEnd() + mi.chatView.messagesList.Highlight() + mi.chatView.messagesList.ScrollToEnd() } func processText(state *ningen.State, channel *discord.Channel, src []byte) string { @@ -269,7 +269,7 @@ func (mi *messageInput) tabComplete() { } pos := posEnd - (len(name) + 1) - selected := mi.chat.SelectedChannel() + selected := mi.chatView.SelectedChannel() if selected == nil { return } @@ -284,7 +284,7 @@ func (mi *messageInput) tabComplete() { } } else { mi.searchMember(gID, name) - members, err := mi.chat.state.Cabinet.Members(gID) + members, err := mi.chatView.state.Cabinet.Members(gID) if err != nil { slog.Error("failed to get members from state", "guild_id", gID, "err", err) return @@ -292,7 +292,7 @@ func (mi *messageInput) tabComplete() { res := fuzzy.FindFrom(name, memberList(members)) for _, r := range res { - if channelHasUser(mi.chat.state, selected.ID, members[r.Index].User.ID) { + if channelHasUser(mi.chatView.state, selected.ID, members[r.Index].User.ID) { mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") return } @@ -316,7 +316,7 @@ func (mi *messageInput) tabSuggestion() { mi.stopTabCompletion() return } - selected := mi.chat.SelectedChannel() + selected := mi.chatView.SelectedChannel() if selected == nil { return } @@ -329,7 +329,7 @@ func (mi *messageInput) tabSuggestion() { if name == "" { shown = make(map[string]struct{}) // Don't show @me in the list of recent authors - me, err := mi.chat.state.Cabinet.Me() + me, err := mi.chatView.state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) } else { @@ -340,7 +340,7 @@ func (mi *messageInput) tabSuggestion() { // DMs have recipients, not members if !gID.IsValid() { if name == "" { // show recent messages' authors - msgs, err := mi.chat.state.Cabinet.Messages(cID) + msgs, err := mi.chatView.state.Cabinet.Messages(cID) if err != nil { return } @@ -353,7 +353,7 @@ func (mi *messageInput) tabSuggestion() { } } else { users := selected.DMRecipients - me, err := mi.chat.state.Cabinet.Me() + me, err := mi.chatView.state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) } else { @@ -365,7 +365,7 @@ func (mi *messageInput) tabSuggestion() { } } } else if name == "" { // show recent messages' authors - msgs, err := mi.chat.state.Cabinet.Messages(cID) + msgs, err := mi.chatView.state.Cabinet.Messages(cID) if err != nil { return } @@ -374,8 +374,8 @@ func (mi *messageInput) tabSuggestion() { continue } shown[m.Author.Username] = userDone - mi.chat.state.MemberState.RequestMember(gID, m.Author.ID) - if mem, err := mi.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil { + mi.chatView.state.MemberState.RequestMember(gID, m.Author.ID) + if mem, err := mi.chatView.state.Cabinet.Member(gID, m.Author.ID); err == nil { if mi.addMentionMember(gID, mem) { break } @@ -383,7 +383,7 @@ func (mi *messageInput) tabSuggestion() { } } else { mi.searchMember(gID, name) - mems, err := mi.chat.state.Cabinet.Members(gID) + mems, err := mi.chatView.state.Cabinet.Members(gID) if err != nil { slog.Error("fetching members failed", "err", err) return @@ -393,7 +393,7 @@ func (mi *messageInput) tabSuggestion() { res = res[:int(mi.cfg.AutocompleteLimit)] } for _, r := range res { - if channelHasUser(mi.chat.state, cID, mems[r.Index].User.ID) && + if channelHasUser(mi.chatView.state, cID, mems[r.Index].User.ID) && mi.addMentionMember(gID, &mems[r.Index]) { break } @@ -447,22 +447,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 < mi.chat.state.MemberState.SearchLimit { + if c := mi.cache.Get(k); c < mi.chatView.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(mi.chat.state.MemberState.SearchFrequency).After(time.Now()) { + if mi.lastSearch.Add(mi.chatView.state.MemberState.SearchFrequency).After(time.Now()) { return } mi.lastSearch = time.Now() - 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()) + mi.chatView.messagesList.waitForChunkEvent() + mi.chatView.messagesList.setFetchingChunk(true, 0) + mi.chatView.state.MemberState.SearchMember(gID, name) + mi.cache.Create(key, mi.chatView.messagesList.waitForChunkEvent()) } func (mi *messageInput) showMentionList() { @@ -473,7 +473,7 @@ func (mi *messageInput) showMentionList() { l := mi.mentionsList x, _, _, _ := mi.GetInnerRect() _, y, _, _ := mi.GetRect() - _, _, maxW, maxH := mi.chat.messagesList.GetInnerRect() + _, _, maxW, maxH := mi.chatView.messagesList.GetInnerRect() if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 { maxH = min(maxH, t) } @@ -496,10 +496,10 @@ func (mi *messageInput) showMentionList() { l.SetRect(x, y, w, h) - mi.chat. + mi.chatView. AddAndSwitchToPage(mentionsListPageName, l, false). ShowPage(flexPageName) - mi.chat.app.SetFocus(mi) + mi.chatView.app.SetFocus(mi) } func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool { @@ -514,14 +514,14 @@ func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) // This avoids a slower member color lookup path. color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role { - r, _ := mi.chat.state.Cabinet.Role(gID, id) + r, _ := mi.chatView.state.Cabinet.Role(gID, id) return r }) if ok { name = fmt.Sprintf("[%s]%s[-]", color, name) } - presence, err := mi.chat.state.Cabinet.Presence(gID, m.User.ID) + presence, err := mi.chatView.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 { @@ -538,7 +538,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) { } name := user.DisplayOrUsername() - presence, err := mi.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID) + presence, err := mi.chatView.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 { @@ -550,7 +550,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) { // used by chatView func (mi *messageInput) removeMentionsList() { - mi.chat. + mi.chatView. RemovePage(mentionsListPageName). SwitchToPage(flexPageName) } @@ -559,7 +559,7 @@ func (mi *messageInput) stopTabCompletion() { if mi.cfg.AutocompleteLimit > 0 { mi.mentionsList.Clear() mi.removeMentionsList() - mi.chat.app.SetFocus(mi) + mi.chatView.app.SetFocus(mi) } } @@ -579,7 +579,7 @@ func (mi *messageInput) editor() { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - mi.chat.app.Suspend(func() { + mi.chatView.app.Suspend(func() { err := cmd.Run() if err != nil { slog.Error("failed to run command", "args", cmd.Args, "err", err) @@ -597,7 +597,7 @@ func (mi *messageInput) editor() { } func (mi *messageInput) openFilePicker() { - if mi.chat.SelectedChannel() == nil { + if mi.chatView.SelectedChannel() == nil { return } diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 657769e..12809bd 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -36,7 +36,7 @@ import ( type messagesList struct { *tview.TextView cfg *config.Config - chat *ChatView + chatView *View selectedMessageID discord.MessageID renderer *markdown.Renderer @@ -49,11 +49,11 @@ type messagesList struct { } } -func newMessagesList(cfg *config.Config, chat *ChatView) *messagesList { +func newMessagesList(cfg *config.Config, chatView *View) *messagesList { ml := &messagesList{ TextView: tview.NewTextView(), cfg: cfg, - chat: chat, + chatView: chatView, renderer: markdown.NewRenderer(cfg.Theme.MessagesList), } @@ -100,7 +100,7 @@ func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) { fmt.Fprintf(writer, `["%s"]`, message.ID) if ml.cfg.HideBlockedUsers { - isBlocked := ml.chat.state.UserIsBlocked(message.Author.ID) + isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID) if isBlocked { io.WriteString(writer, "[:red:b]Blocked message[:-:-]") return @@ -148,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 := ml.chat.state.Cabinet.Member(message.GuildID, message.Author.ID) + member, err := ml.chatView.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 { @@ -157,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, _ := ml.chat.state.Cabinet.Role(message.GuildID, id) + r, _ := ml.chatView.state.Cabinet.Role(message.GuildID, id) return r }) if ok { @@ -171,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 ml.chat.cfg.Markdown { - ast := discordmd.ParseWithMessage(c, *ml.chat.state.Cabinet, &message, false) + if ml.chatView.cfg.Markdown { + ast := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false) ml.renderer.Render(w, c, ast) } else { w.Write(c) // write the content as is @@ -243,12 +243,12 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) { return nil, errors.New("no message is currently selected") } - selected := ml.chat.SelectedChannel() + selected := ml.chatView.SelectedChannel() if selected == nil { return nil, errors.New("no channel is currently selected") } - m, err := ml.chat.state.Cabinet.Message(selected.ID, ml.selectedMessageID) + m, err := ml.chatView.state.Cabinet.Message(selected.ID, ml.selectedMessageID) if err != nil { return nil, fmt.Errorf("failed to retrieve selected message: %w", err) } @@ -288,12 +288,12 @@ func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey { } func (ml *messagesList) _select(name string) { - selectedChannel := ml.chat.SelectedChannel() + selectedChannel := ml.chatView.SelectedChannel() if selectedChannel == nil { return } - messages, err := ml.chat.state.Cabinet.Messages(selectedChannel.ID) + messages, err := ml.chatView.state.Cabinet.Messages(selectedChannel.ID) if err != nil { slog.Error("failed to get messages", "err", err, "channel_id", selectedChannel.ID) return @@ -455,8 +455,8 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord SetHighlightFullLine(true). ShowSecondaryText(false). SetDoneFunc(func() { - ml.chat.RemovePage(attachmentsListPageName).SwitchToPage(flexPageName) - ml.chat.app.SetFocus(ml) + ml.chatView.RemovePage(attachmentsListPageName).SwitchToPage(flexPageName) + ml.chatView.app.SetFocus(ml) }) list. SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { @@ -491,7 +491,7 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord }) } - ml.chat. + ml.chatView. AddAndSwitchToPage(attachmentsListPageName, ui.Centered(list, 0, 0), true). ShowPage(flexPageName) } @@ -544,7 +544,7 @@ func (ml *messagesList) reply(mention bool) { name := msg.Author.DisplayOrUsername() if msg.GuildID.IsValid() { - member, err := ml.chat.state.Cabinet.Member(msg.GuildID, msg.Author.ID) + member, err := ml.chatView.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 { @@ -554,7 +554,7 @@ func (ml *messagesList) reply(mention bool) { } } - data := ml.chat.messageInput.sendMessageData + data := ml.chatView.messageInput.sendMessageData data.Reference = &discord.MessageReference{MessageID: ml.selectedMessageID} data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False} @@ -564,9 +564,9 @@ func (ml *messagesList) reply(mention bool) { title = "[@] " + title } - ml.chat.messageInput.sendMessageData = data - ml.chat.messageInput.SetTitle(title + name) - ml.chat.app.SetFocus(ml.chat.messageInput) + ml.chatView.messageInput.sendMessageData = data + ml.chatView.messageInput.SetTitle(title + name) + ml.chatView.app.SetFocus(ml.chatView.messageInput) } func (ml *messagesList) edit() { @@ -576,7 +576,7 @@ func (ml *messagesList) edit() { return } - me, err := ml.chat.state.Cabinet.Me() + me, err := ml.chatView.state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) return @@ -587,10 +587,10 @@ func (ml *messagesList) edit() { return } - ml.chat.messageInput.SetTitle("Editing") - ml.chat.messageInput.edit = true - ml.chat.messageInput.SetText(message.Content, true) - ml.chat.app.SetFocus(ml.chat.messageInput) + ml.chatView.messageInput.SetTitle("Editing") + ml.chatView.messageInput.edit = true + ml.chatView.messageInput.SetText(message.Content, true) + ml.chatView.app.SetFocus(ml.chatView.messageInput) } func (ml *messagesList) confirmDelete() { @@ -600,7 +600,7 @@ func (ml *messagesList) confirmDelete() { } } - ml.chat.showConfirmModal( + ml.chatView.showConfirmModal( "Are you sure you want to delete this message?", []string{"Yes", "No"}, onChoice, @@ -615,24 +615,24 @@ func (ml *messagesList) delete() { } if msg.GuildID.IsValid() { - me, err := ml.chat.state.Cabinet.Me() + me, err := ml.chatView.state.Cabinet.Me() if err != nil { slog.Error("failed to get client user (me)", "err", err) return } - if msg.Author.ID != me.ID && !ml.chat.state.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) { + if msg.Author.ID != me.ID && !ml.chatView.state.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) { slog.Error("failed to delete message; missing relevant permissions", "channel_id", msg.ChannelID, "message_id", msg.ID) return } } - selected := ml.chat.SelectedChannel() + selected := ml.chatView.SelectedChannel() if selected == nil { return } - if err := ml.chat.state.DeleteMessage(selected.ID, msg.ID, ""); err != nil { + if err := ml.chatView.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 } @@ -640,7 +640,7 @@ func (ml *messagesList) delete() { ml.selectedMessageID = 0 ml.Highlight() - if err := ml.chat.state.MessageRemove(selected.ID, msg.ID); err != nil { + if err := ml.chatView.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 } @@ -657,13 +657,13 @@ func (ml *messagesList) requestGuildMembers(gID discord.GuildID, ms []discord.Me continue } - if member, _ := ml.chat.state.Cabinet.Member(gID, m.Author.ID); member == nil { + if member, _ := ml.chatView.state.Cabinet.Member(gID, m.Author.ID); member == nil { usersToFetch = append(usersToFetch, m.Author.ID) } } if len(usersToFetch) > 0 { - err := ml.chat.state.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{ + err := ml.chatView.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 index 0ef59df..ba757ac 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -16,11 +16,11 @@ import ( "github.com/diamondburned/ningen/v3" ) -func (cv *ChatView) OpenState(token string) error { +func (v *View) OpenState(token string) error { identifyProps := http.IdentifyProperties() gateway.DefaultIdentity = identifyProps gateway.DefaultPresence = &gateway.UpdatePresenceCommand{ - Status: cv.cfg.Status, + Status: v.cfg.Status, } id := gateway.DefaultIdentifier(token) @@ -28,34 +28,34 @@ func (cv *ChatView) OpenState(token string) error { session := session.NewCustom(id, http.NewClient(token), handler.New()) state := state.NewFromSession(session, defaultstore.New()) - cv.state = ningen.FromState(state) + v.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) + v.state.AddHandler(v.onRaw) + v.state.AddHandler(v.onReady) + v.state.AddHandler(v.onMessageCreate) + v.state.AddHandler(v.onMessageUpdate) + v.state.AddHandler(v.onMessageDelete) + v.state.AddHandler(v.onReadUpdate) + v.state.AddHandler(v.onGuildMembersChunk) + v.state.AddHandler(v.onGuildMemberRemove) - cv.state.StateLog = func(err error) { + v.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()) + v.state.OnRequest = append(v.state.OnRequest, httputil.WithHeaders(http.Headers()), v.onRequest) + return v.state.Open(context.TODO()) } -func (cv *ChatView) CloseState() error { - if cv.state == nil { +func (v *View) CloseState() error { + if v.state == nil { return nil } - return cv.state.Close() + return v.state.Close() } -func (cv *ChatView) onRequest(r httpdriver.Request) error { +func (v *View) onRequest(r httpdriver.Request) error { if req, ok := r.(*httpdriver.DefaultRequest); ok { slog.Debug("new HTTP request", "method", req.Method, "url", req.URL) } @@ -63,7 +63,7 @@ func (cv *ChatView) onRequest(r httpdriver.Request) error { return nil } -func (cv *ChatView) onRaw(event *ws.RawEvent) { +func (v *View) onRaw(event *ws.RawEvent) { slog.Debug( "new raw event", "code", event.OriginalCode, diff --git a/internal/ui/chat/view.go b/internal/ui/chat/view.go new file mode 100644 index 0000000..39984c0 --- /dev/null +++ b/internal/ui/chat/view.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 View 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 NewView(app *tview.Application, cfg *config.Config, onLogout func()) *View { + v := &View{ + Pages: tview.NewPages(), + + mainFlex: tview.NewFlex(), + rightFlex: tview.NewFlex(), + + app: app, + cfg: cfg, + onLogout: onLogout, + } + v.guildsTree = newGuildsTree(cfg, v) + v.messagesList = newMessagesList(cfg, v) + v.messageInput = newMessageInput(cfg, v) + + v.SetInputCapture(v.onInputCapture) + v.buildLayout() + return v +} + +func (v *View) SelectedChannel() *discord.Channel { + v.selectedChannelMu.RLock() + defer v.selectedChannelMu.RUnlock() + return v.selectedChannel +} + +func (v *View) SetSelectedChannel(channel *discord.Channel) { + v.selectedChannelMu.Lock() + v.selectedChannel = channel + v.selectedChannelMu.Unlock() +} + +func (v *View) buildLayout() { + v.Clear() + v.rightFlex.Clear() + v.mainFlex.Clear() + + v.rightFlex. + SetDirection(tview.FlexRow). + AddItem(v.messagesList, 0, 1, false). + AddItem(v.messageInput, 3, 1, false) + // The guilds tree is always focused first at start-up. + v.mainFlex. + AddItem(v.guildsTree, 0, 1, true). + AddItem(v.rightFlex, 0, 4, false) + + v.AddAndSwitchToPage(flexPageName, v.mainFlex, true) +} + +func (v *View) toggleGuildsTree() { + // The guilds tree is visible if the number of items is two. + if v.mainFlex.GetItemCount() == 2 { + v.mainFlex.RemoveItem(v.guildsTree) + if v.guildsTree.HasFocus() { + v.app.SetFocus(v.mainFlex) + } + } else { + v.buildLayout() + v.app.SetFocus(v.guildsTree) + } +} + +func (v *View) focusGuildsTree() bool { + // The guilds tree is not hidden if the number of items is two. + if v.mainFlex.GetItemCount() == 2 { + v.app.SetFocus(v.guildsTree) + return true + } + + return false +} + +func (v *View) focusMessageInput() bool { + if !v.messageInput.GetDisabled() { + v.app.SetFocus(v.messageInput) + return true + } + + return false +} + +func (v *View) focusPrevious() { + switch v.app.GetFocus() { + case v.guildsTree: + v.focusMessageInput() + case v.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is). + if ok := v.focusGuildsTree(); !ok { + v.app.SetFocus(v.messageInput) + } + case v.messageInput: + v.app.SetFocus(v.messagesList) + } +} + +func (v *View) focusNext() { + switch v.app.GetFocus() { + case v.guildsTree: + v.app.SetFocus(v.messagesList) + case v.messagesList: + v.focusMessageInput() + case v.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is). + if ok := v.focusGuildsTree(); !ok { + v.app.SetFocus(v.messagesList) + } + } +} + +func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey { + switch event.Name() { + case v.cfg.Keys.FocusGuildsTree: + v.messageInput.removeMentionsList() + v.focusGuildsTree() + return nil + case v.cfg.Keys.FocusMessagesList: + v.messageInput.removeMentionsList() + v.app.SetFocus(v.messagesList) + return nil + case v.cfg.Keys.FocusMessageInput: + v.focusMessageInput() + return nil + case v.cfg.Keys.FocusPrevious: + v.focusPrevious() + return nil + case v.cfg.Keys.FocusNext: + v.focusNext() + return nil + case v.cfg.Keys.Logout: + if v.onLogout != nil { + v.onLogout() + } + + if err := keyring.DeleteToken(); err != nil { + slog.Error("failed to delete token from keyring", "err", err) + return nil + } + + return nil + case v.cfg.Keys.ToggleGuildsTree: + v.toggleGuildsTree() + return nil + } + + return event +} + +func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(label string)) { + previousFocus := v.app.GetFocus() + + modal := tview.NewModal(). + SetText(prompt). + AddButtons(buttons). + SetDoneFunc(func(_ int, buttonLabel string) { + v.RemovePage(confirmModalPageName).SwitchToPage(flexPageName) + v.app.SetFocus(previousFocus) + + if onDone != nil { + onDone(buttonLabel) + } + }) + + v. + AddAndSwitchToPage(confirmModalPageName, ui.Centered(modal, 0, 0), true). + ShowPage(flexPageName) +} + +func (v *View) onReadUpdate(event *read.UpdateEvent) { + var guildNode *tview.TreeNode + v.guildsTree. + GetRoot(). + Walk(func(node, parent *tview.TreeNode) bool { + switch node.GetReference() { + case event.GuildID: + node.SetTextStyle(v.guildsTree.getGuildNodeStyle(event.GuildID)) + guildNode = node + return false + case event.ChannelID: + // private channel + if !event.GuildID.IsValid() { + style := v.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(v.guildsTree.getChannelNodeStyle(event.ChannelID)) + return false + } + + return true + }) + } + + v.app.Draw() +} + +func (v *View) onReady(r *gateway.ReadyEvent) { + dmNode := tview.NewTreeNode("Direct Messages") + root := v.guildsTree. + GetRoot(). + ClearChildren(). + AddChild(dmNode) + + for _, folder := range r.UserSettings.GuildFolders { + if folder.ID == 0 && len(folder.GuildIDs) == 1 { + guild, err := v.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 + } + + v.guildsTree.createGuildNode(root, *guild) + } else { + v.guildsTree.createFolderNode(folder) + } + } + + v.guildsTree.SetCurrentNode(root) + v.app.SetFocus(v.guildsTree) + v.app.Draw() +} + +func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) { + if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { + v.messagesList.drawMessage(v.messagesList, message.Message) + v.app.Draw() + } + + if err := notifications.Notify(v.state, message, v.cfg); err != nil { + slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID) + } +} + +func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) { + if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { + v.onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID}) + } +} + +func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) { + if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { + messages, err := v.state.Cabinet.Messages(message.ChannelID) + if err != nil { + slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID) + return + } + + v.messagesList.reset() + v.messagesList.drawMessages(messages) + v.app.Draw() + } +} + +func (v *View) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) { + v.messagesList.setFetchingChunk(false, uint(len(event.Members))) +} + +func (v *View) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { + v.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, v.state.MemberState.SearchLimit) +}