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

This commit is contained in:
ayn2op 2026-03-19 19:15:14 -04:00 • committed by GitHub
parent c8362d57f2
commit 6e3f340b55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 299 additions and 229 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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)
}

View file

@ -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))
}
}