diff --git a/cmd/chatview.go b/cmd/chatview.go index fa49478..f2fbbec 100644 --- a/cmd/chatview.go +++ b/cmd/chatview.go @@ -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 diff --git a/cmd/guilds_tree.go b/cmd/guilds_tree.go index 0977855..b0b1362 100644 --- a/cmd/guilds_tree.go +++ b/cmd/guilds_tree.go @@ -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) diff --git a/cmd/message_input.go b/cmd/message_input.go index 2475443..7058f85 100644 --- a/cmd/message_input.go +++ b/cmd/message_input.go @@ -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 } diff --git a/cmd/messages_list.go b/cmd/messages_list.go index e4d558d..cea1e8e 100644 --- a/cmd/messages_list.go +++ b/cmd/messages_list.go @@ -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 } diff --git a/cmd/state.go b/cmd/state.go index 5b1f793..1274f98 100644 --- a/cmd/state.go +++ b/cmd/state.go @@ -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) diff --git a/internal/config/config.toml b/internal/config/config.toml index 6bebc92..db8d447 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -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.