Sfoglia il codice sorgente

refactor(ui/chat): unify gateway and ui updates under event commands (#775)

ayn2op 1 mese fa
parent
commit
6e3f340b55

+ 3 - 3
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

+ 50 - 6
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

+ 31 - 43
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,48 +328,15 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 				gt.createChannelNode(node, thread)
 			}
 			node.Expand()
-			return
-		}
-
-		limit := gt.cfg.MessagesLimit
-		messages, err := gt.chat.state.Messages(channel.ID, uint(limit))
-		if err != nil {
-			slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit)
-			return
-		}
-
-		go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
-
-		if guildID := channel.GuildID; guildID.IsValid() {
-			gt.chat.messagesList.requestGuildMembers(guildID, messages)
+			return nil
 		}
 
-		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))))
+		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
+			return nil
 		}
 
 		ui.SortPrivateChannels(channels)
@@ -376,6 +344,27 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 			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 nil
+		}
+
+		go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
+
+		if guildID := channel.GuildID; guildID.IsValid() {
+			gt.chat.messagesList.requestGuildMembers(guildID, messages)
+		}
+
+		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
 

+ 37 - 31
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
-	}
-
-	if guildID := selectedChannel.GuildID; guildID.IsValid() {
-		ml.requestGuildMembers(guildID, messages)
-	}
+	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
+		}
 
-	older := slices.Clone(messages)
-	slices.Reverse(older)
+		if guildID := selectedChannel.GuildID; guildID.IsValid() {
+			ml.requestGuildMembers(guildID, messages)
+		}
 
-	// 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 {

+ 97 - 33
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)
 }

+ 79 - 111
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()
-
-		dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false)
-		m.guildsTree.dmRootNode = 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
-			}
+	// 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
+
+	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 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]
-		}
+	// 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)
-			}
+	// 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
-			}
+	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)
-			}
+		// 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)
+	// 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))
+	}
+}