fix(message_input): add support for mentions list in (group) dms (#649)

Co-authored-by: Ayyan <ayn2op@gmail.com>
This commit is contained in:
xqrs 2025-12-09 09:34:41 +03:00 • committed by GitHub
parent 9c9815544f
commit 7e8af16e72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 165 additions and 66 deletions

View file

@ -28,8 +28,7 @@ type chatView struct {
messagesList *messagesList
messageInput *messageInput
selectedGuildID discord.GuildID
selectedChannelID discord.ChannelID
selectedChannel *discord.Channel
app *tview.Application
cfg *config.Config

View file

@ -142,8 +142,6 @@ PARENT_CHANNELS:
}
func (gt *guildsTree) onSelected(node *tview.TreeNode) {
app.chatView.messageInput.reset()
if len(node.GetChildren()) != 0 {
node.SetExpanded(!node.IsExpanded())
return
@ -183,6 +181,8 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
app.chatView.messagesList.requestGuildMembers(guildID, messages)
}
app.chatView.selectedChannel = channel
app.chatView.messagesList.reset()
app.chatView.messagesList.setTitle(*channel)
app.chatView.messagesList.drawMessages(messages)
@ -197,9 +197,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
app.SetFocus(app.chatView.messageInput)
}
app.chatView.selectedChannelID = channel.ID
app.chatView.selectedGuildID = channel.GuildID
case nil: // Direct messages
case nil: // Direct messages folder
channels, err := discordState.PrivateChannels()
if err != nil {
slog.Error("failed to get private channels", "err", err)

View file

@ -47,8 +47,6 @@ type messageInput struct {
lastSearch time.Time
}
type memberList []discord.Member
func newMessageInput(cfg *config.Config) *messageInput {
mi := &messageInput{
TextArea: tview.NewTextArea(),
@ -96,7 +94,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
case mi.cfg.Keys.MessageInput.Send:
if app.chatView.GetVisibile(mentionsListPageName) {
mi.tabComplete(false)
mi.tabComplete()
return nil
}
@ -119,7 +117,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
return nil
case mi.cfg.Keys.MessageInput.TabComplete:
go app.QueueUpdateDraw(func() { mi.tabComplete(false) })
go app.QueueUpdateDraw(func() { mi.tabComplete() })
return nil
}
@ -135,7 +133,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
}
}
go app.QueueUpdateDraw(func() { mi.tabComplete(true) })
go app.QueueUpdateDraw(func() { mi.tabSuggestion() })
}
return event
@ -149,7 +147,7 @@ func (mi *messageInput) paste() {
}
func (mi *messageInput) send() {
if !app.chatView.selectedChannelID.IsValid() {
if app.chatView.selectedChannel == nil {
return
}
@ -158,7 +156,7 @@ func (mi *messageInput) send() {
return
}
text = processText(app.chatView.selectedChannelID, []byte(text))
text = processText(app.chatView.selectedChannel, []byte(text))
if mi.edit {
m, err := app.chatView.messagesList.selectedMessage()
@ -176,8 +174,8 @@ func (mi *messageInput) send() {
} else {
data := mi.sendMessageData
data.Content = text
if _, err := discordState.SendMessageComplex(app.chatView.selectedChannelID, *data); err != nil {
slog.Error("failed to send message in channel", "channel_id", app.chatView.selectedChannelID, "err", err)
if _, err := discordState.SendMessageComplex(app.chatView.selectedChannel.ID, *data); err != nil {
slog.Error("failed to send message in channel", "channel_id", app.chatView.selectedChannel.ID, "err", err)
}
}
@ -193,7 +191,7 @@ func (mi *messageInput) send() {
app.chatView.messagesList.ScrollToEnd()
}
func processText(cID discord.ChannelID, src []byte) string {
func processText(channel *discord.Channel, src []byte) string {
var (
ranges [][2]int
canMention = true
@ -217,19 +215,36 @@ func processText(cID discord.ChannelID, src []byte) string {
})
for _, rng := range ranges {
src = slices.Replace(src, rng[0], rng[1], expandMentions(cID, src[rng[0]:rng[1]])...)
src = slices.Replace(src, rng[0], rng[1], expandMentions(channel, src[rng[0]:rng[1]])...)
}
return string(src)
}
func expandMentions(cID discord.ChannelID, src []byte) []byte {
func expandMentions(c *discord.Channel, src []byte) []byte {
return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte {
output := input
name := string(input[1:])
discordState.MemberStore.Each(app.chatView.selectedGuildID, func(m *discord.Member) bool {
if strings.EqualFold(m.User.Username, name) && channelHasUser(cID, m.User.ID) {
output = []byte(m.User.ID.Mention())
if c.Type == discord.DirectMessage || c.Type == discord.GroupDM {
for _, user := range c.DMRecipients {
if strings.EqualFold(user.Username, name) {
return []byte(user.ID.Mention())
}
}
// self ping
me, err := discordState.Cabinet.Me()
if err != nil {
slog.Error("failed to get client user (me)", "err", err)
} else if strings.EqualFold(me.Username, name) {
return []byte(me.ID.Mention())
}
return output
}
discordState.MemberStore.Each(c.GuildID, func(m *discord.Member) bool {
if strings.EqualFold(m.User.Username, name) {
if channelHasUser(c.ID, m.User.ID) {
output = []byte(m.User.ID.Mention())
}
return true
}
return false
@ -238,7 +253,7 @@ func expandMentions(cID discord.ChannelID, src []byte) []byte {
})
}
func (mi *messageInput) tabComplete(isAuto bool) {
func (mi *messageInput) tabComplete() {
posEnd, name, r := mi.GetWordUnderCursor(func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
})
@ -248,13 +263,17 @@ func (mi *messageInput) tabComplete(isAuto bool) {
}
pos := posEnd - (len(name) + 1)
gID := app.chatView.selectedGuildID
cID := app.chatView.selectedChannelID
gID := app.chatView.selectedChannel.GuildID
if !isAuto {
if mi.cfg.AutocompleteLimit == 0 {
if mi.cfg.AutocompleteLimit == 0 {
if !gID.IsValid() {
users := app.chatView.selectedChannel.DMRecipients
res := fuzzy.FindFrom(name, userList(users))
if len(res) > 0 {
mi.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ")
}
} else {
mi.searchMember(gID, name)
members, err := discordState.Cabinet.Members(gID)
if err != nil {
slog.Error("failed to get members from state", "guild_id", gID, "err", err)
@ -263,45 +282,93 @@ func (mi *messageInput) tabComplete(isAuto bool) {
res := fuzzy.FindFrom(name, memberList(members))
for _, r := range res {
if channelHasUser(cID, members[r.Index].User.ID) {
if channelHasUser(app.chatView.selectedChannel.ID, members[r.Index].User.ID) {
mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
return
}
}
return
}
if mi.mentionsList.GetItemCount() == 0 {
return
}
_, name = mi.mentionsList.GetItemText(mi.mentionsList.GetCurrentItem())
mi.Replace(pos, posEnd, "@"+name+" ")
return
}
if mi.mentionsList.GetItemCount() == 0 {
return
}
_, name = mi.mentionsList.GetItemText(mi.mentionsList.GetCurrentItem())
mi.Replace(pos, posEnd, "@"+name+" ")
mi.stopTabCompletion()
}
func (mi *messageInput) tabSuggestion() {
_, name, r := mi.GetWordUnderCursor(func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
})
if r != '@' {
mi.stopTabCompletion()
return
}
gID := app.chatView.selectedChannel.GuildID
cID := app.chatView.selectedChannel.ID
mi.mentionsList.Clear()
// Special case, show recent messages' authors
var shown map[string]struct{}
var userDone struct{}
if name == "" {
shown = make(map[string]struct{})
// Don't show @me in the list of recent authors
me, err := discordState.Cabinet.Me()
if err != nil {
slog.Error("failed to get client user (me)", "err", err)
} else {
shown[me.Username] = userDone
}
}
// DMs have recipients, not members
if !gID.IsValid() {
if name == "" { // show recent messages' authors
msgs, err := discordState.Cabinet.Messages(cID)
if err != nil {
return
}
for _, m := range msgs {
if _, ok := shown[m.Author.Username]; ok {
continue
}
shown[m.Author.Username] = userDone
mi.addMentionUser(&m.Author)
}
} else {
users := app.chatView.selectedChannel.DMRecipients
me, err := discordState.Cabinet.Me()
if err != nil {
slog.Error("failed to get client user (me)", "err", err)
} else {
users = append(users, *me)
}
res := fuzzy.FindFrom(name, userList(users))
for _, r := range res {
mi.addMentionUser(&users[r.Index])
}
}
} else if name == "" { // show recent messages' authors
msgs, err := discordState.Cabinet.Messages(cID)
if err != nil {
return
}
shown := make(map[string]bool)
mi.mentionsList.Clear()
for _, m := range msgs {
if shown[m.Author.Username] {
if _, ok := shown[m.Author.Username]; ok {
continue
}
shown[m.Author.Username] = true
shown[m.Author.Username] = userDone
discordState.MemberState.RequestMember(gID, m.Author.ID)
if mem, err := discordState.Cabinet.Member(gID, m.Author.ID); err == nil {
if mi.addMentionItem(gID, mem) {
if mi.addMentionMember(gID, mem) {
break
}
}
}
} else {
mi.searchMember(gID, name)
mi.mentionsList.Clear()
mems, err := discordState.Cabinet.Members(gID)
if err != nil {
slog.Error("fetching members failed", "err", err)
@ -313,7 +380,7 @@ func (mi *messageInput) tabComplete(isAuto bool) {
}
for _, r := range res {
if channelHasUser(cID, mems[r.Index].User.ID) &&
mi.addMentionItem(gID, &mems[r.Index]) {
mi.addMentionMember(gID, &mems[r.Index]) {
break
}
}
@ -324,12 +391,27 @@ func (mi *messageInput) tabComplete(isAuto bool) {
return
}
_, col, _, _ := mi.GetCursor()
mi.showMentionList(col - 1)
mi.showMentionList()
}
func (m memberList) String(i int) string { return m[i].Nick + m[i].User.DisplayName + m[i].User.Tag() }
func (m memberList) Len() int { return len(m) }
type memberList []discord.Member
type userList []discord.User
func (ml memberList) String(i int) string {
return ml[i].Nick + ml[i].User.DisplayName + ml[i].User.Tag()
}
func (ml memberList) Len() int {
return len(ml)
}
func (ul userList) String(i int) string {
return ul[i].DisplayName + ul[i].Tag()
}
func (ul userList) Len() int {
return len(ul)
}
// channelHasUser checks if a user has permission to view the specified channel
func channelHasUser(channelID discord.ChannelID, userID discord.UserID) bool {
@ -369,7 +451,7 @@ func (mi *messageInput) searchMember(gID discord.GuildID, name string) {
mi.cache.Create(key, app.chatView.messagesList.waitForChunkEvent())
}
func (mi *messageInput) showMentionList(col int) {
func (mi *messageInput) showMentionList() {
borders := 0
if mi.cfg.Theme.Border.Enabled {
borders = 1
@ -394,6 +476,7 @@ func (mi *messageInput) showMentionList(col int) {
}
w = min(w+borders*2, maxW)
_, col, _, _ := mi.GetCursor()
x += min(col, maxW-w)
}
@ -405,7 +488,7 @@ func (mi *messageInput) showMentionList(col int) {
app.SetFocus(mi)
}
func (mi *messageInput) addMentionItem(gID discord.GuildID, m *discord.Member) bool {
func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool {
if m == nil {
return false
}
@ -427,9 +510,7 @@ func (mi *messageInput) addMentionItem(gID discord.GuildID, m *discord.Member) b
presence, err := discordState.Cabinet.Presence(gID, m.User.ID)
if err != nil {
slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err)
}
if presence != nil && presence.Status == discord.OfflineStatus {
} else if presence.Status == discord.OfflineStatus {
name = fmt.Sprintf("[::d]%s[::D]", name)
}
@ -437,6 +518,24 @@ func (mi *messageInput) addMentionItem(gID discord.GuildID, m *discord.Member) b
return mi.mentionsList.GetItemCount() > int(mi.cfg.AutocompleteLimit)
}
func (mi *messageInput) addMentionUser(user *discord.User) {
if user == nil {
return
}
name := user.DisplayOrUsername()
presence, err := discordState.Cabinet.Presence(discord.NullGuildID, user.ID)
if err != nil {
slog.Info("failed to get presence from state", "user_id", user.ID, "err", err)
} else if presence.Status == discord.OfflineStatus {
name = fmt.Sprintf("[::d]%s[::D]", name)
}
mi.mentionsList.AddItem(name, user.Username, 0, nil)
return
}
// used by chatView
func (mi *messageInput) removeMentionsList() {
app.chatView.
RemovePage(mentionsListPageName).
@ -494,7 +593,7 @@ func (mi *messageInput) addTitle(s string) {
}
func (mi *messageInput) openFilePicker() {
if !app.chatView.selectedChannelID.IsValid() {
if app.chatView.selectedChannel == nil {
return
}

View file

@ -240,7 +240,7 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
return nil, errors.New("no message is currently selected")
}
m, err := discordState.Cabinet.Message(app.chatView.selectedChannelID, ml.selectedMessageID)
m, err := discordState.Cabinet.Message(app.chatView.selectedChannel.ID, ml.selectedMessageID)
if err != nil {
return nil, fmt.Errorf("could not retrieve selected message: %w", err)
}
@ -249,7 +249,7 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
}
func (ml *messagesList) selectedMessageIndex() (int, error) {
ms, err := discordState.Cabinet.Messages(app.chatView.selectedChannelID)
ms, err := discordState.Cabinet.Messages(app.chatView.selectedChannel.ID)
if err != nil {
return -1, err
}
@ -295,9 +295,9 @@ func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
}
func (ml *messagesList) _select(name string) {
ms, err := discordState.Cabinet.Messages(app.chatView.selectedChannelID)
ms, err := discordState.Cabinet.Messages(app.chatView.selectedChannel.ID)
if err != nil {
slog.Error("failed to get messages", "err", err, "channel_id", app.chatView.selectedChannelID)
slog.Error("failed to get messages", "err", err, "channel_id", app.chatView.selectedChannel.ID)
return
}
@ -622,16 +622,16 @@ func (ml *messagesList) delete() {
}
}
if err := discordState.DeleteMessage(app.chatView.selectedChannelID, msg.ID, ""); err != nil {
slog.Error("failed to delete message", "channel_id", app.chatView.selectedChannelID, "message_id", msg.ID, "err", err)
if err := discordState.DeleteMessage(app.chatView.selectedChannel.ID, msg.ID, ""); err != nil {
slog.Error("failed to delete message", "channel_id", app.chatView.selectedChannel.ID, "message_id", msg.ID, "err", err)
return
}
ml.selectedMessageID = 0
ml.Highlight()
if err := discordState.MessageRemove(app.chatView.selectedChannelID, msg.ID); err != nil {
slog.Error("failed to delete message", "channel_id", app.chatView.selectedChannelID, "message_id", msg.ID, "err", err)
if err := discordState.MessageRemove(app.chatView.selectedChannel.ID, msg.ID); err != nil {
slog.Error("failed to delete message", "channel_id", app.chatView.selectedChannel.ID, "message_id", msg.ID, "err", err)
return
}

View file

@ -146,7 +146,8 @@ func onReady(r *gateway.ReadyEvent) {
}
func onMessageCreate(message *gateway.MessageCreateEvent) {
if app.chatView.selectedChannelID == message.ChannelID {
if app.chatView.selectedChannel != nil &&
app.chatView.selectedChannel.ID == message.ChannelID {
app.chatView.messagesList.drawMessage(app.chatView.messagesList, message.Message)
app.Draw()
}
@ -157,13 +158,15 @@ func onMessageCreate(message *gateway.MessageCreateEvent) {
}
func onMessageUpdate(message *gateway.MessageUpdateEvent) {
if app.chatView.selectedChannelID == message.ChannelID {
if app.chatView.selectedChannel != nil &&
app.chatView.selectedChannel.ID == message.ChannelID {
onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID})
}
}
func onMessageDelete(message *gateway.MessageDeleteEvent) {
if app.chatView.selectedChannelID == message.ChannelID {
if app.chatView.selectedChannel != nil &&
app.chatView.selectedChannel.ID == message.ChannelID {
messages, err := discordState.Cabinet.Messages(message.ChannelID)
if err != nil {
slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID)

View file

@ -11,8 +11,8 @@ markdown = true
hide_blocked_users = true
show_attachment_links = true
# Use autocomplete_limit = 0 to disable autocompleting mentions
# Note: tab completion will still work, but it won't show any list.
# Max members to be in the mention autocomplete suggestions list
# Note: Use autocomplete_limit = 0 to disable.
autocomplete_limit = 20
# The number of messages to fetch when a text-based channel is selected from guilds tree. The minimum and maximum value is 0 and 100, respectively.