From 6e3f340b554f500a72c1459fde6d3ea2f3dbec54 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Thu, 19 Mar 2026 19:15:14 -0400 Subject: [PATCH] refactor(ui/chat): unify gateway and ui updates under event commands (#775) --- internal/ui/chat/channels_picker.go | 6 +- internal/ui/chat/events.go | 56 +++++++- internal/ui/chat/guilds_tree.go | 76 +++++------ internal/ui/chat/messages_list.go | 68 +++++----- internal/ui/chat/model.go | 130 ++++++++++++++----- internal/ui/chat/state.go | 192 ++++++++++++---------------- 6 files changed, 299 insertions(+), 229 deletions(-) diff --git a/internal/ui/chat/channels_picker.go b/internal/ui/chat/channels_picker.go index 1cb6472..eac0155 100644 --- a/internal/ui/chat/channels_picker.go +++ b/internal/ui/chat/channels_picker.go @@ -48,12 +48,12 @@ func (cp *channelsPicker) HandleEvent(event tview.Event) tview.Command { cp.chatView.guildsTree.expandPathToNode(node) cp.chatView.guildsTree.SetCurrentNode(node) + var selectCmd tview.Command if channel.Type != discord.GuildCategory { - cp.chatView.guildsTree.onSelected(node) + selectCmd = cp.chatView.guildsTree.onSelected(node) } cp.chatView.closePicker() - cp.chatView.focusMessageInput() - return nil + return selectCmd case *picker.CancelEvent: cp.chatView.closePicker() return nil diff --git a/internal/ui/chat/events.go b/internal/ui/chat/events.go index 77e92c7..6682086 100644 --- a/internal/ui/chat/events.go +++ b/internal/ui/chat/events.go @@ -1,22 +1,25 @@ package chat import ( + "context" "log/slog" "github.com/ayn2op/tview" + "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/gateway" "github.com/gdamore/tcell/v3" ) -type LogoutEvent struct{ tcell.EventTime } - -func (m *Model) logout() tview.Command { +func (m *Model) openState() tview.Command { return func() tview.Event { - return &LogoutEvent{} + if err := m.state.Open(context.Background()); err != nil { + slog.Error("failed to open chat state", "err", err) + return tcell.NewEventError(err) + } + return nil } } -type QuitEvent struct{ tcell.EventTime } - func (m *Model) closeState() tview.Command { return func() tview.Event { if m.state != nil { @@ -29,6 +32,47 @@ func (m *Model) closeState() tview.Command { } } +type gatewayEvent struct { + tcell.EventTime + gateway.Event +} + +func (m *Model) listen() tview.Command { + return func() tview.Event { + return &gatewayEvent{Event: <-m.events} + } +} + +type channelLoadedEvent struct { + tcell.EventTime + Channel discord.Channel + Messages []discord.Message +} + +func newChannelLoadedEvent(channel discord.Channel, messages []discord.Message) *channelLoadedEvent { + return &channelLoadedEvent{Channel: channel, Messages: messages} +} + +type olderMessagesLoadedEvent struct { + tcell.EventTime + ChannelID discord.ChannelID + Older []discord.Message +} + +func newOlderMessagesLoadedEvent(channelID discord.ChannelID, older []discord.Message) *olderMessagesLoadedEvent { + return &olderMessagesLoadedEvent{ChannelID: channelID, Older: older} +} + +type LogoutEvent struct{ tcell.EventTime } + +func (m *Model) logout() tview.Command { + return func() tview.Event { + return &LogoutEvent{} + } +} + +type QuitEvent struct{ tcell.EventTime } + type closeLayerEvent struct { tcell.EventTime name string diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index 0cc3093..a63906e 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -277,10 +277,10 @@ func (gt *guildsTree) createChannelNodes(node *tview.TreeNode, channels []discor } } -func (gt *guildsTree) onSelected(node *tview.TreeNode) { +func (gt *guildsTree) onSelected(node *tview.TreeNode) tview.Command { if len(node.GetChildren()) != 0 { node.SetExpanded(!node.IsExpanded()) - return + return nil } switch ref := node.GetReference().(type) { @@ -290,17 +290,18 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { channels, err := gt.chat.state.Cabinet.Channels(ref) if err != nil { slog.Error("failed to get channels", "err", err, "guild_id", ref) - return + return nil } ui.SortGuildChannels(channels) gt.createChannelNodes(node, channels) node.Expand() + return nil case discord.ChannelID: channel, err := gt.chat.state.Cabinet.Channel(ref) if err != nil { slog.Error("failed to get channel from state", "err", err, "channel_id", ref) - return + return nil } // Handle forum channels differently - they contain threads, not direct messages @@ -309,7 +310,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { 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 + return nil } // Filter for threads that belong to this forum channel @@ -327,14 +328,34 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { gt.createChannelNode(node, thread) } node.Expand() - return + return nil } - limit := gt.cfg.MessagesLimit - messages, err := gt.chat.state.Messages(channel.ID, uint(limit)) + return gt.loadChannel(*channel) + case dmNode: // Direct messages folder + channels, err := gt.chat.state.PrivateChannels() + if err != nil { + slog.Error("failed to get private channels", "err", err) + return nil + } + + ui.SortPrivateChannels(channels) + for _, c := range channels { + gt.createChannelNode(node, c) + } + node.Expand() + return nil + } + return nil +} + +func (gt *guildsTree) loadChannel(channel discord.Channel) tview.Command { + limit := uint(gt.cfg.MessagesLimit) + return func() tview.Event { + messages, err := gt.chat.state.Messages(channel.ID, limit) if err != nil { slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit) - return + return nil } go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID) @@ -343,39 +364,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { gt.chat.messagesList.requestGuildMembers(guildID, messages) } - gt.chat.SetSelectedChannel(channel) - gt.chat.clearTypers() - gt.chat.messageInput.stopTypingTimer() - - gt.chat.messagesList.reset() - gt.chat.messagesList.setTitle(*channel) - gt.chat.messagesList.setMessages(messages) - gt.chat.messagesList.ScrollBottom() - - hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionSendMessages) - gt.chat.messageInput.SetDisabled(hasNoPerm) - var text string - if hasNoPerm { - text = "You do not have permission to send messages in this channel." - } else { - text = "Message..." - if gt.cfg.AutoFocus { - gt.chat.app.SetFocus(gt.chat.messageInput) - } - } - gt.chat.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true)))) - case dmNode: // Direct messages folder - channels, err := gt.chat.state.PrivateChannels() - if err != nil { - slog.Error("failed to get private channels", "err", err) - return - } - - ui.SortPrivateChannels(channels) - for _, c := range channels { - gt.createChannelNode(node, c) - } - node.Expand() + return newChannelLoadedEvent(channel, messages) } } @@ -396,8 +385,7 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) { func (gt *guildsTree) HandleEvent(event tview.Event) tview.Command { switch event := event.(type) { case *tview.TreeViewSelectedEvent: - gt.onSelected(event.Node) - return nil + return gt.onSelected(event.Node) case *tview.KeyEvent: handler := gt.TreeView.HandleEvent diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 2d48e74..e2f6a14 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -852,8 +852,7 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command { ml.clearSelection() return nil case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind): - ml.selectUp() - return nil + return ml.selectUp() case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind): ml.selectDown() return nil @@ -891,14 +890,29 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command { return nil } return ml.Model.HandleEvent(event) + + case *olderMessagesLoadedEvent: + selectedChannel := ml.chatView.SelectedChannel() + if selectedChannel == nil || selectedChannel.ID != event.ChannelID { + return nil + } + + // Defensive invalidation if Discord returns overlapping windows. + for _, message := range event.Older { + delete(ml.itemByID, message.ID) + } + ml.messages = slices.Concat(event.Older, ml.messages) + ml.invalidateRows() + ml.SetCursor(len(event.Older) - 1) + return nil } return ml.Model.HandleEvent(event) } -func (ml *messagesList) selectUp() { +func (ml *messagesList) selectUp() tview.Command { messages := ml.messages if len(messages) == 0 { - return + return nil } cursor := ml.Cursor() @@ -908,14 +922,11 @@ func (ml *messagesList) selectUp() { case cursor > 0: cursor-- case cursor == 0: - added := ml.prependOlderMessages() - if added == 0 { - return - } - cursor = added - 1 + return ml.fetchOlderMessages() } ml.SetCursor(cursor) + return nil } func (ml *messagesList) selectDown() { @@ -970,38 +981,33 @@ func (ml *messagesList) selectReply() { } } -func (ml *messagesList) prependOlderMessages() int { +func (ml *messagesList) fetchOlderMessages() tview.Command { selectedChannel := ml.chatView.SelectedChannel() if selectedChannel == nil { - return 0 + return nil } channelID := selectedChannel.ID before := ml.messages[0].ID limit := uint(ml.cfg.MessagesLimit) - messages, err := ml.chatView.state.MessagesBefore(channelID, before, limit) - if err != nil { - slog.Error("failed to fetch older messages", "err", err) - return 0 - } - if len(messages) == 0 { - return 0 - } + return func() tview.Event { + messages, err := ml.chatView.state.MessagesBefore(channelID, before, limit) + if err != nil { + slog.Error("failed to fetch older messages", "err", err) + return nil + } + if len(messages) == 0 { + return nil + } - if guildID := selectedChannel.GuildID; guildID.IsValid() { - ml.requestGuildMembers(guildID, messages) - } + if guildID := selectedChannel.GuildID; guildID.IsValid() { + ml.requestGuildMembers(guildID, messages) + } - older := slices.Clone(messages) - slices.Reverse(older) - - // Defensive invalidation if Discord returns overlapping windows. - for _, message := range older { - delete(ml.itemByID, message.ID) + older := slices.Clone(messages) + slices.Reverse(older) + return newOlderMessagesLoadedEvent(channelID, older) } - ml.messages = slices.Concat(older, ml.messages) - ml.invalidateRows() - return len(messages) } func (ml *messagesList) yankMessageID() tview.Command { diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index b19fb4b..9c3f47a 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -7,12 +7,20 @@ import ( "time" "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" "github.com/ayn2op/tview/flex" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/layers" "github.com/diamondburned/arikawa/v3/discord" + "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/ws" "github.com/diamondburned/ningen/v3" "github.com/diamondburned/ningen/v3/states/read" "github.com/gdamore/tcell/v3" @@ -44,16 +52,17 @@ type Model struct { selectedChannel *discord.Channel selectedChannelMu sync.RWMutex - typersMu sync.RWMutex - typers map[discord.UserID]*time.Timer - confirmModalDone func(label string) confirmModalPreviousFocus tview.Model - app *tview.Application - cfg *config.Config - state *ningen.State - token string + state *ningen.State + events chan gateway.Event + + typersMu sync.RWMutex + typers map[discord.UserID]*time.Timer + + app *tview.Application + cfg *config.Config } func NewModel(app *tview.Application, cfg *config.Config, token string) *Model { @@ -65,9 +74,8 @@ func NewModel(app *tview.Application, cfg *config.Config, token string) *Model { typers: make(map[discord.UserID]*time.Timer), - app: app, - cfg: cfg, - token: token, + app: app, + cfg: cfg, } m.guildsTree = newGuildsTree(cfg, m) @@ -75,6 +83,26 @@ func NewModel(app *tview.Application, cfg *config.Config, token string) *Model { m.messageInput = newMessageInput(cfg, m) m.channelsPicker = newChannelsPicker(cfg, m) + identifyProps := http.IdentifyProperties() + gateway.DefaultIdentity = identifyProps + gateway.DefaultPresence = &gateway.UpdatePresenceCommand{ + Status: m.cfg.Status, + } + + id := gateway.DefaultIdentifier(token) + id.Compress = false + + session := session.NewCustom(id, http.NewClient(token), handler.New()) + state := state.NewFromSession(session, defaultstore.New()) + m.state = ningen.FromState(state) + + m.events = make(chan gateway.Event) + m.state.AddHandler(m.events) + m.state.StateLog = func(err error) { + slog.Error("state log", "err", err) + } + m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), m.onRequest) + m.SetBackgroundLayerStyle(m.cfg.Theme.Dialog.BackgroundStyle.Style) m.buildLayout() return m @@ -219,13 +247,67 @@ func (m *Model) focusNext() tview.Command { func (m *Model) HandleEvent(event tview.Event) tview.Command { switch event := event.(type) { case *tview.InitEvent: - return func() tview.Event { - if err := m.OpenState(m.token); err != nil { - slog.Error("failed to open chat state", "err", err) - return tcell.NewEventError(err) + return tview.Batch(m.openState(), m.listen()) + case *gatewayEvent: + switch event := event.Event.(type) { + case *ws.RawEvent: + m.onRaw(event) + + case *gateway.ReadyEvent: + m.onReady(event) + + case *gateway.MessageCreateEvent: + m.onMessageCreate(event) + case *gateway.MessageUpdateEvent: + m.onMessageUpdate(event) + case *gateway.MessageDeleteEvent: + m.onMessageDelete(event) + + case *gateway.GuildMembersChunkEvent: + m.onGuildMembersChunk(event) + case *gateway.GuildMemberRemoveEvent: + m.onGuildMemberRemove(event) + + case *gateway.TypingStartEvent: + if m.cfg.TypingIndicator.Receive { + m.onTypingStart(event) } + + case *read.UpdateEvent: + m.onReadUpdate(event) + } + return m.listen() + case *channelLoadedEvent: + node := m.guildsTree.GetCurrentNode() + if node == nil { return nil } + channelID, ok := node.GetReference().(discord.ChannelID) + if !ok || channelID != event.Channel.ID { + return nil + } + + m.SetSelectedChannel(&event.Channel) + m.clearTypers() + m.messageInput.stopTypingTimer() + + m.messagesList.reset() + m.messagesList.setTitle(event.Channel) + m.messagesList.setMessages(event.Messages) + m.messagesList.ScrollBottom() + + hasNoPerm := event.Channel.Type != discord.DirectMessage && event.Channel.Type != discord.GroupDM && !m.state.HasPermissions(event.Channel.ID, discord.PermissionSendMessages) + m.messageInput.SetDisabled(hasNoPerm) + text := "Message..." + + var focusCommand tview.Command + if hasNoPerm { + text = "You do not have permission to send messages in this channel." + } else if m.cfg.AutoFocus { + focusCommand = m.focusMessageInput() + } + m.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true)))) + return focusCommand case *QuitEvent: return tview.Batch( m.closeState(), @@ -298,24 +380,6 @@ func (m *Model) showConfirmModal(prompt string, buttons []string, onDone func(la SendToFront(confirmModalLayerName) } -func (m *Model) onReadUpdate(event *read.UpdateEvent) { - m.app.QueueUpdateDraw(func() { - // Use indexed node lookup to avoid walking the whole tree on every read - // event. This runs frequently while reading/typing across channels. - if event.GuildID.IsValid() { - if guildNode := m.guildsTree.findNodeByReference(event.GuildID); guildNode != nil { - m.guildsTree.setNodeLineStyle(guildNode, m.guildsTree.getGuildNodeStyle(event.GuildID)) - } - } - - // Channel style is always updated for the target channel regardless of - // whether it's in a guild or DM. - if channelNode := m.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil { - m.guildsTree.setNodeLineStyle(channelNode, m.guildsTree.getChannelNodeStyle(event.ChannelID)) - } - }) -} - func (m *Model) clearTypers() { m.typersMu.Lock() for _, timer := range m.typers { @@ -403,5 +467,5 @@ func (m *Model) updateFooter() { } } - go m.app.QueueUpdateDraw(func() { m.messagesList.SetFooter(footer) }) + m.messagesList.SetFooter(footer) } diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 8a02531..29f6952 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -1,66 +1,22 @@ package chat import ( - "context" "log/slog" "slices" - "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/discordo/internal/notifications" "github.com/ayn2op/tview" "github.com/diamondburned/arikawa/v3/discord" "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 (m *Model) OpenState(token string) error { - identifyProps := http.IdentifyProperties() - gateway.DefaultIdentity = identifyProps - gateway.DefaultPresence = &gateway.UpdatePresenceCommand{ - Status: m.cfg.Status, - } - - id := gateway.DefaultIdentifier(token) - id.Compress = false - - session := session.NewCustom(id, http.NewClient(token), handler.New()) - state := state.NewFromSession(session, defaultstore.New()) - m.state = ningen.FromState(state) - - // Handlers - m.state.AddHandler(m.onRaw) - m.state.AddHandler(m.onReady) - m.state.AddHandler(m.onMessageCreate) - m.state.AddHandler(m.onMessageUpdate) - m.state.AddHandler(m.onMessageDelete) - m.state.AddHandler(m.onReadUpdate) - m.state.AddHandler(m.onGuildMembersChunk) - m.state.AddHandler(m.onGuildMemberRemove) - - if m.cfg.TypingIndicator.Receive { - m.state.AddHandler(m.onTypingStart) - } - - m.state.StateLog = func(err error) { - slog.Error("state log", "err", err) - } - - m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), m.onRequest) - return m.state.Open(context.Background()) -} - func (m *Model) 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 } @@ -74,79 +30,76 @@ func (m *Model) onRaw(event *ws.RawEvent) { } func (m *Model) onReady(event *gateway.ReadyEvent) { - m.app.QueueUpdateDraw(func() { - // Rebuild indexes from scratch so reconnects and account switches do not - // retain pointers to detached tree nodes. - m.guildsTree.resetNodeIndex() + // Rebuild indexes from scratch so reconnects and account switches do not + // retain pointers to detached tree nodes. + m.guildsTree.resetNodeIndex() - dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false) - m.guildsTree.dmRootNode = dmNode + dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false) + m.guildsTree.dmRootNode = dmNode - root := m.guildsTree. - GetRoot(). - ClearChildren(). - AddChild(dmNode) + root := m.guildsTree. + GetRoot(). + ClearChildren(). + AddChild(dmNode) - // Track guilds already in folders to find orphans (newly joined guilds may not be synced to GuildFolders yet but always appear in GuildPositions) - guildsInFolders := make(map[discord.GuildID]bool) - for _, folder := range event.UserSettings.GuildFolders { - for _, guildID := range folder.GuildIDs { - guildsInFolders[guildID] = true - } + // Track guilds already in folders to find orphans. + // Newly joined guilds may not be synced to GuildFolders yet but always appear in guild positions. + guildsInFolders := make(map[discord.GuildID]bool) + for _, folder := range event.UserSettings.GuildFolders { + for _, guildID := range folder.GuildIDs { + guildsInFolders[guildID] = true + } + } + + // Build index of all available guilds. + guildsByID := make(map[discord.GuildID]*gateway.GuildCreateEvent, len(event.Guilds)) + for index := range event.Guilds { + guildsByID[event.Guilds[index].ID] = &event.Guilds[index] + } + + // Use GuildPositions for ordering (it's the canonical order). + // Guilds not in any folder are "orphans" - add them directly to root. + positions := event.UserSettings.GuildPositions + // Fallback: GuildPositions shouldn't be nil but handle gracefully + if len(positions) == 0 { + positions = make([]discord.GuildID, 0, len(event.Guilds)) + for _, guildEvent := range event.Guilds { + positions = append(positions, guildEvent.ID) + } + } + + for _, guildID := range positions { + // Already handled in folder processing below + if guildsInFolders[guildID] { + continue } - // Build index of all available guilds. - guildsByID := make(map[discord.GuildID]*gateway.GuildCreateEvent, len(event.Guilds)) - for index := range event.Guilds { - guildsByID[event.Guilds[index].ID] = &event.Guilds[index] + // Orphan guild - add directly to root in order + if guildEvent, ok := guildsByID[guildID]; ok { + m.guildsTree.createGuildNode(root, guildEvent.Guild) } + } - // Use GuildPositions for ordering (it's the canonical order). - // Guilds not in any folder are "orphans" - add them directly to root. - positions := event.UserSettings.GuildPositions - // Fallback: GuildPositions shouldn't be nil but handle gracefully - if len(positions) == 0 { - positions = make([]discord.GuildID, 0, len(event.Guilds)) - for _, guildEvent := range event.Guilds { - positions = append(positions, guildEvent.ID) + // Process folders (real folders and single-guild "folders") + for _, folder := range event.UserSettings.GuildFolders { + if folder.ID == 0 && len(folder.GuildIDs) == 1 { + if guild, ok := guildsByID[folder.GuildIDs[0]]; ok { + m.guildsTree.createGuildNode(root, guild.Guild) } + } else { + m.guildsTree.createFolderNode(folder, guildsByID) } + } - for _, guildID := range positions { - // Already handled in folder processing below - if guildsInFolders[guildID] { - continue - } - - // Orphan guild - add directly to root in order - if guildEvent, ok := guildsByID[guildID]; ok { - m.guildsTree.createGuildNode(root, guildEvent.Guild) - } - } - - // Process folders (real folders and single-guild "folders") - for _, folder := range event.UserSettings.GuildFolders { - if folder.ID == 0 && len(folder.GuildIDs) == 1 { - if guild, ok := guildsByID[folder.GuildIDs[0]]; ok { - m.guildsTree.createGuildNode(root, guild.Guild) - } - } else { - m.guildsTree.createFolderNode(folder, guildsByID) - } - } - - m.guildsTree.SetCurrentNode(root) - m.app.SetFocus(m.guildsTree) - }) + m.guildsTree.SetCurrentNode(root) + m.app.SetFocus(m.guildsTree) } func (m *Model) onMessageCreate(message *gateway.MessageCreateEvent) { selectedChannel := m.SelectedChannel() if selectedChannel != nil && selectedChannel.ID == message.ChannelID { m.removeTyper(message.Author.ID) - m.app.QueueUpdateDraw(func() { - m.messagesList.addMessage(message.Message) - }) + m.messagesList.addMessage(message.Message) } else { if err := notifications.Notify(m.state, message, m.cfg); err != nil { slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID) @@ -163,14 +116,17 @@ func (m *Model) onMessageUpdate(message *gateway.MessageUpdateEvent) { return } - m.app.QueueUpdateDraw(func() { - m.messagesList.setMessage(index, message.Message) - }) + m.messagesList.setMessage(index, message.Message) } } func (m *Model) onMessageDelete(message *gateway.MessageDeleteEvent) { - if selected := m.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { + selectedChannel := m.SelectedChannel() + if selectedChannel == nil { + return + } + + if selectedChannel.ID == message.ChannelID { prevCursor := m.messagesList.Cursor() deletedIndex := slices.IndexFunc(m.messagesList.messages, func(m discord.Message) bool { return m.ID == message.ID @@ -179,9 +135,7 @@ func (m *Model) onMessageDelete(message *gateway.MessageDeleteEvent) { return } - m.app.QueueUpdateDraw(func() { - m.messagesList.deleteMessage(deletedIndex) - }) + m.messagesList.deleteMessage(deletedIndex) // Keep cursor stable when possible after removal. newCursor := prevCursor @@ -201,9 +155,7 @@ func (m *Model) onMessageDelete(message *gateway.MessageDeleteEvent) { } if newCursor != prevCursor { // Avoid redundant cursor updates if nothing changed. - m.app.QueueUpdateDraw(func() { - m.messagesList.SetCursor(newCursor) - }) + m.messagesList.SetCursor(newCursor) } } } @@ -233,3 +185,19 @@ func (m *Model) onTypingStart(event *gateway.TypingStartEvent) { m.addTyper(event.UserID) } + +func (m *Model) onReadUpdate(event *read.UpdateEvent) { + // Use indexed node lookup to avoid walking the whole tree on every read + // event. This runs frequently while reading/typing across channels. + if event.GuildID.IsValid() { + if guildNode := m.guildsTree.findNodeByReference(event.GuildID); guildNode != nil { + m.guildsTree.setNodeLineStyle(guildNode, m.guildsTree.getGuildNodeStyle(event.GuildID)) + } + } + + // Channel style is always updated for the target channel regardless of + // whether it's in a guild or DM. + if channelNode := m.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil { + m.guildsTree.setNodeLineStyle(channelNode, m.guildsTree.getChannelNodeStyle(event.ChannelID)) + } +}