From 6c3727cb7c94a98305917c1d8c8913ba956b4a1a Mon Sep 17 00:00:00 2001 From: ayn2op Date: Fri, 3 Jul 2026 18:31:49 -0400 Subject: [PATCH] refactor(chat): avoid blocking member chunks --- internal/ui/chat/composer.go | 38 ++++++++++++++++--- internal/ui/chat/guilds_tree.go | 3 -- internal/ui/chat/messages_list.go | 63 +++++++------------------------ internal/ui/chat/model.go | 5 ++- internal/ui/chat/state.go | 5 ++- 5 files changed, 53 insertions(+), 61 deletions(-) diff --git a/internal/ui/chat/composer.go b/internal/ui/chat/composer.go index 89158f0..0e21d4b 100644 --- a/internal/ui/chat/composer.go +++ b/internal/ui/chat/composer.go @@ -2,6 +2,7 @@ package chat import ( "bytes" + "context" "io" "log/slog" "os" @@ -15,6 +16,7 @@ import ( "github.com/ayn2op/arikawa/v3/api" "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/arikawa/v3/state" "github.com/ayn2op/arikawa/v3/utils/json/option" "github.com/ayn2op/arikawa/v3/utils/sendpart" @@ -37,6 +39,7 @@ import ( const ( tmpFilePattern = consts.Name + "_*.md" imageAttachmentName = "clipboard.png" + memberSearchNonce = "autocomplete:" ) var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") @@ -619,6 +622,10 @@ func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID dis // searchMember performs member discovery in a command goroutine. // It emits a follow-up suggestion message once results are loaded. func (c *composer) searchMember(gID discord.GuildID, name string) tview.Cmd { + if name == "" { + return nil + } + key := gID.String() + " " + name if _, ok := c.cache.Get(key); ok { return nil @@ -634,17 +641,36 @@ func (c *composer) searchMember(gID discord.GuildID, name string) tview.Cmd { } } + now := time.Now() // 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 c.lastSearch.Add(c.chat.state.MemberState.SearchFrequency).After(time.Now()) { + if c.lastSearch.Add(c.chat.state.MemberState.SearchFrequency).After(now) { return nil } - c.lastSearch = time.Now() + c.lastSearch = now + nonce := memberSearchNonce + key + return func() tview.Msg { + if err := c.chat.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ + GuildIDs: []discord.GuildID{gID}, + Query: option.NewString(name), + Presences: c.chat.state.MemberState.RequestPresences, + Limit: c.chat.state.MemberState.SearchLimit, + Nonce: nonce, + }); err != nil { + slog.Error("failed to search guild members", "err", err, "guild_id", gID, "query", name) + } + return nil + } +} + +func (c *composer) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tview.Cmd { + key, ok := strings.CutPrefix(event.Nonce, memberSearchNonce) + if !ok { + return nil + } + + c.cache.Create(key, uint(len(event.Members))) return func() tview.Msg { - c.chat.messagesList.waitForChunkEvent() - c.chat.messagesList.setFetchingChunk(true, 0) - c.chat.state.MemberState.SearchMember(gID, name) - c.cache.Create(key, c.chat.messagesList.waitForChunkEvent()) return tabSuggestMsg{} } } diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index e4ce389..dfada33 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -309,9 +309,6 @@ func (gt *guildsTree) loadChannel(channel discord.Channel) tview.Cmd { go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID) - if guildID := channel.GuildID; guildID.IsValid() { - gt.chat.messagesList.requestGuildMembers(guildID, messages) - } return channelLoadedMsg{Channel: channel, Messages: messages} } } diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index c8f4ce7..40f9c06 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -10,7 +10,6 @@ import ( "path/filepath" "slices" "strings" - "sync" "time" "unicode/utf8" @@ -55,13 +54,6 @@ type messagesList struct { itemByID map[discord.MessageID]*tview.TextView attachmentsPicker *attachmentsPicker - - fetchingMembers struct { - mu sync.Mutex - value bool - count uint - done chan struct{} - } } var _ help.KeyMap = (*messagesList)(nil) @@ -920,6 +912,9 @@ func (ml *messagesList) Update(msg tview.Msg) tview.Cmd { default: ml.SetCursor(prevCursor) } + if selectedChannel.GuildID.IsValid() { + return ml.requestGuildMembers(selectedChannel.GuildID, msg.Older) + } return nil } return ml.Model.Update(msg) @@ -1016,10 +1011,6 @@ func (ml *messagesList) fetchOlderMessages() tview.Cmd { return nil } - if guildID := selectedChannel.GuildID; guildID.IsValid() { - ml.requestGuildMembers(guildID, messages) - } - older := slices.Clone(messages) slices.Reverse(older) return olderMessagesLoadedMsg{ChannelID: channelID, Older: older} @@ -1327,7 +1318,7 @@ func (ml *messagesList) deleteSelectedMessage() tview.Cmd { } } -func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) { +func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) tview.Cmd { usersToFetch := make([]discord.UserID, 0, len(messages)) seen := make(map[discord.UserID]struct{}, len(messages)) @@ -1346,49 +1337,23 @@ func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages [] } } - if len(usersToFetch) > 0 { - err := ml.chat.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ + if len(usersToFetch) == 0 { + return nil + } + + return func() tview.Msg { + if err := ml.chat.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ GuildIDs: []discord.GuildID{guildID}, UserIDs: usersToFetch, - }) - if err != nil { + }); err != nil { slog.Error("failed to request guild members", "guild_id", guildID, "err", err) - return } - - ml.setFetchingChunk(true, 0) - ml.waitForChunkEvent() + return nil } } -func (ml *messagesList) setFetchingChunk(value bool, count uint) { - ml.fetchingMembers.mu.Lock() - defer ml.fetchingMembers.mu.Unlock() - - if ml.fetchingMembers.value == value { - return - } - - ml.fetchingMembers.value = value - - if value { - ml.fetchingMembers.done = make(chan struct{}) - } else { - ml.fetchingMembers.count = count - close(ml.fetchingMembers.done) - } -} - -func (ml *messagesList) waitForChunkEvent() uint { - ml.fetchingMembers.mu.Lock() - if !ml.fetchingMembers.value { - ml.fetchingMembers.mu.Unlock() - return 0 - } - ml.fetchingMembers.mu.Unlock() - - <-ml.fetchingMembers.done - return ml.fetchingMembers.count +func (ml *messagesList) invalidateRenderedMessages() { + clear(ml.itemByID) } func (ml *messagesList) ShortHelp() []keybind.Keybind { diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index b2a80dd..d4b89d7 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -267,7 +267,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { m.onMessageDelete(eventMsg) case *gateway.GuildMembersChunkEvent: - m.onGuildMembersChunk(eventMsg) + return tview.Batch(m.onGuildMembersChunk(eventMsg), m.listen()) case *gateway.GuildMemberRemoveEvent: m.onGuildMemberRemove(eventMsg) @@ -312,6 +312,9 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { focusCmd = m.focusComposer() } m.composer.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true)))) + if msg.Channel.GuildID.IsValid() { + return tview.Batch(focusCmd, m.messagesList.requestGuildMembers(msg.Channel.GuildID, msg.Messages)) + } return focusCmd case QuitMsg: return m.closeState() diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 5c6fdb7..63c0472 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -167,8 +167,9 @@ func cursorAfterDelete(prevCursor, deletedIndex, remaining int) int { } } -func (m *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) { - m.messagesList.setFetchingChunk(false, uint(len(event.Members))) +func (m *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tview.Cmd { + m.messagesList.invalidateRenderedMessages() + return m.composer.onGuildMembersChunk(event) } func (m *Model) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {