refactor(chat): avoid blocking member chunks

This commit is contained in:
ayn2op 2026-07-03 18:31:49 -04:00
parent bb3ee02edc
commit 6c3727cb7c
5 changed files with 53 additions and 61 deletions

View file

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

View file

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

View file

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

View file

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

View file

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