From b7e095a50b13d7832bfdf9a8fd03af4da332efd9 Mon Sep 17 00:00:00 2001 From: xqrs <3bd3bdr@gmail.com> Date: Mon, 16 Jun 2025 00:19:13 +0300 Subject: [PATCH] feat: add mention support (#555) Co-authored-by: ayn2op --- cmd/application.go | 22 +-- cmd/guilds_tree.go | 2 + cmd/message_input.go | 329 ++++++++++++++++++++++++++++++++-- cmd/messages_text.go | 57 +++--- cmd/root.go | 3 +- cmd/state.go | 45 +++-- go.mod | 7 +- go.sum | 10 ++ internal/cache/cache.go | 46 +++++ internal/config/config.go | 3 + internal/config/config.toml | 29 ++- internal/config/keys.go | 13 +- internal/config/theme.go | 9 + internal/login/form.go | 12 +- internal/markdown/renderer.go | 19 +- 15 files changed, 514 insertions(+), 92 deletions(-) create mode 100644 internal/cache/cache.go diff --git a/cmd/application.go b/cmd/application.go index e965e36..666cb06 100644 --- a/cmd/application.go +++ b/cmd/application.go @@ -22,6 +22,9 @@ type application struct { guildsTree *guildsTree messagesText *messagesText messageInput *messageInput + + flexPage tview.Page + autocompletePage tview.Page } func newApplication(cfg *config.Config) *application { @@ -77,14 +80,8 @@ func (a *application) run(token string) error { return nil } -func (a *application) clearPages() { - for _, name := range a.pages.GetPageNames(false) { - a.pages.RemovePage(name) - } -} - func (a *application) init() { - a.clearPages() + a.pages.Clear() a.flex.Clear() right := tview.NewFlex() @@ -94,7 +91,8 @@ func (a *application) init() { // The guilds tree is always focused first at start-up. a.flex.AddItem(a.guildsTree, 0, 1, true) a.flex.AddItem(right, 0, 4, false) - a.pages.AddAndSwitchToPage("flex", a.flex, true) + a.flexPage = a.pages.AddAndSwitchToPage(a.flex, true) + a.autocompletePage = a.pages.AddPage(a.messageInput.autocomplete, false, false) } func (a *application) onInputCapture(event *tcell.EventKey) *tcell.EventKey { @@ -118,13 +116,15 @@ func (a *application) onInputCapture(event *tcell.EventKey) *tcell.EventKey { func (a *application) onFlexInputCapture(event *tcell.EventKey) *tcell.EventKey { switch event.Name() { case a.cfg.Keys.FocusGuildsTree: - a.SetFocus(a.guildsTree) + a.pages.HidePage(a.autocompletePage) + a.SetFocus(app.guildsTree) return nil case a.cfg.Keys.FocusMessagesText: - a.SetFocus(a.messagesText) + a.pages.HidePage(a.autocompletePage) + a.SetFocus(app.messagesText) return nil case a.cfg.Keys.FocusMessageInput: - a.SetFocus(a.messageInput) + a.SetFocus(app.messageInput) return nil case a.cfg.Keys.Logout: a.Stop() diff --git a/cmd/guilds_tree.go b/cmd/guilds_tree.go index ebec17b..129bcc1 100644 --- a/cmd/guilds_tree.go +++ b/cmd/guilds_tree.go @@ -19,6 +19,7 @@ type guildsTree struct { *tview.TreeView cfg *config.Config selectedChannelID discord.ChannelID + selectedGuildID discord.GuildID } func newGuildsTree(cfg *config.Config) *guildsTree { @@ -201,6 +202,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { app.messagesText.SetTitle(gt.channelToString(*channel)) gt.selectedChannelID = channel.ID + gt.selectedGuildID = channel.GuildID app.SetFocus(app.messageInput) case nil: // Direct messages channels, err := discordState.PrivateChannels() diff --git a/cmd/message_input.go b/cmd/message_input.go index 530cb3f..2b8c094 100644 --- a/cmd/message_input.go +++ b/cmd/message_input.go @@ -5,30 +5,46 @@ import ( "os" "os/exec" "strings" + "regexp" + "slices" + "time" + "github.com/sahilm/fuzzy" "github.com/atotto/clipboard" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/discordo/internal/cache" "github.com/ayn2op/tview" "github.com/diamondburned/arikawa/v3/api" "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/state" "github.com/diamondburned/arikawa/v3/utils/json/option" + "github.com/diamondburned/ningen/v3/discordmd" "github.com/gdamore/tcell/v2" + "github.com/yuin/goldmark/ast" ) const tmpFilePattern = consts.Name + "_*.md" +var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") type messageInput struct { *tview.TextArea - cfg *config.Config - replyMessageID discord.MessageID + cfg *config.Config + cache *cache.Cache + autocomplete *tview.List + replyMessageID discord.MessageID + lastSearch time.Time } +type memberList []discord.Member + func newMessageInput(cfg *config.Config) *messageInput { mi := &messageInput{ - TextArea: tview.NewTextArea(), - cfg: cfg, + TextArea: tview.NewTextArea(), + cfg: cfg, + cache: cache.NewCache(), + autocomplete: tview.NewList(), } mi.Box = ui.NewConfiguredBox(mi.Box, &cfg.Theme) @@ -43,6 +59,18 @@ func newMessageInput(cfg *config.Config) *messageInput { }). SetInputCapture(mi.onInputCapture) + mi.autocomplete.Box = ui.NewConfiguredBox(mi.autocomplete.Box, &mi.cfg.Theme) + mi.autocomplete.SetTitle("Mention") + mi.autocomplete. + ShowSecondaryText(false). + SetSelectedStyle(tcell.StyleDefault. + Background(tcell.ColorWhite). + Foreground(tcell.ColorBlack)) + mi.autocomplete.SetRect(0, 0, 0, 0) + b := mi.autocomplete.GetBorderSet() + b.BottomLeft = b.BottomT + b.BottomRight = b.BottomT + mi.autocomplete.SetBorderSet(b) return mi } @@ -55,16 +83,55 @@ func (mi *messageInput) reset() { func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { switch event.Name() { case mi.cfg.Keys.MessageInput.Send: + if app.pages.GetVisible(app.autocompletePage) { + mi.tabComplete(false) + return nil + } mi.send() return nil case mi.cfg.Keys.MessageInput.Editor: + mi.stopTabCompletion() mi.editor() return nil case mi.cfg.Keys.MessageInput.Cancel: - mi.reset() + if app.pages.GetVisible(app.autocompletePage) { + mi.stopTabCompletion() + } else { + mi.reset() + } + return nil + case mi.cfg.Keys.MessageInput.TabComplete: + go app.QueueUpdateDraw(func(){ mi.tabComplete(false) }) return nil } + if app.pages.GetVisible(app.autocompletePage) && mi.cfg.AutocompleteLimit > 0 { + count := mi.autocomplete.GetItemCount() + cur := mi.autocomplete.GetCurrentItem() + n := event.Name() + switch n { + case mi.cfg.Keys.Autocomplete.Down: + if cur == count-1 { + mi.autocomplete.SetCurrentItem(0) + } else { + mi.autocomplete.SetCurrentItem(cur+1) + } + return nil + case mi.cfg.Keys.Autocomplete.Up: + if cur == 0 { + mi.autocomplete.SetCurrentItem(count-1) + } else { + mi.autocomplete.SetCurrentItem(cur-1) + } + return nil + } + } + + if mi.cfg.AutocompleteLimit > 0 { + go app.QueueUpdateDraw(func(){ mi.tabComplete(true) }) + } else { + go app.QueueUpdate(func(){ mi.tabComplete(true) }) + } return event } @@ -78,8 +145,10 @@ func (mi *messageInput) send() { return } + // Process mentions (there's no shortcut, just parse the entire message + // as markdown and then expand non-code mentions) data := api.SendMessageData{ - Content: text, + Content: processText(app.guildsTree.selectedChannelID, []byte(text)), } if mi.replyMessageID != 0 { data.Reference = &discord.MessageReference{MessageID: mi.replyMessageID} @@ -103,12 +172,252 @@ func (mi *messageInput) send() { app.messagesText.ScrollToEnd() } -func (mi *messageInput) editor() { - editor := mi.cfg.Editor - if editor == "" { +func processText(cID discord.ChannelID, src []byte) string { + // ranges we can expandMentions in them + var rngs [][2]int + canMention := true + n := discordmd.Parse(src) + ast.Walk(n, func(n ast.Node, enter bool) (ast.WalkStatus, error) { + switch n := n.(type) { + case *ast.CodeBlock: + canMention = !enter + case *discordmd.Inline: + if (n.Attr & discordmd.AttrMonospace) != 0 { + canMention = !enter + } + case *ast.Text: + if canMention { + rngs = append(rngs, [2]int{ n.Segment.Start, + n.Segment.Stop }) + } + } + return ast.WalkContinue, nil + }) + for _, rng := range rngs { + src = slices.Replace(src, rng[0], rng[1], + expandMentions(cID, src[rng[0]:rng[1]])...) + } + return string(src) +} + +func expandMentions(cID discord.ChannelID, src []byte) []byte { + return mentionRegex.ReplaceAllFunc(src, func(in []byte) (out []byte) { + out = in + name := strings.ToLower(string(in[1:])) + discordState.MemberStore.Each(app.guildsTree.selectedGuildID, func (m *discord.Member) bool { + if strings.ToLower(m.User.Username) == name { + if channelHasUser(cID , m.User.ID) { + out = []byte(m.User.ID.Mention()) + } + return true + } + return false + }) + return + }) +} + +func (mi *messageInput) tabComplete(isAuto bool) { + posEnd, name, r := mi.GetWordUnderCursor(isValidUserRune) + if r != '@' { + mi.stopTabCompletion() + return + } + pos := posEnd - (len(name)+1) + + if !isAuto && mi.autocomplete.GetItemCount() != 0 { + _, name = mi.autocomplete.GetItemText(mi.autocomplete.GetCurrentItem()) + mi.Replace(pos, posEnd, "@" + name + " ") + mi.stopTabCompletion() return } + gID := app.guildsTree.selectedGuildID + cID := app.guildsTree.selectedChannelID + + // Special case, show recent messages' authors + if name == "" { + msgs, err := discordState.Cabinet.Messages(cID) + if err != nil { + return + } + shown := make(map[string]bool) + mi.autocomplete.Clear() + for _, m := range msgs { + if shown[m.Author.Username] { + continue + } + shown[m.Author.Username] = true + discordState.MemberState.RequestMember(gID, m.Author.ID) + if mem, err := discordState.Cabinet.Member(gID, m.Author.ID); err == nil { + if mi.addAutocompleteItem(gID, mem) { + break + } + } + } + } else { + mi.searchMember(gID, name) + mi.autocomplete.Clear() + mems, err := discordState.Cabinet.Members(gID) + if err != nil { + slog.Error("fetching members failed", "err", err) + return + } + res := fuzzy.FindFrom(name, memberList(mems)) + if mi.cfg.AutocompleteLimit != 0 && + len(res) > int(mi.cfg.AutocompleteLimit) { + res = res[:int(mi.cfg.AutocompleteLimit)] + } + for _, r := range res { + if channelHasUser(cID, mems[r.Index].User.ID) && + mi.addAutocompleteItem(gID, &mems[r.Index]) { + break + } + } + } + + if mi.autocomplete.GetItemCount() == 0 { + mi.stopTabCompletion() + return + } + + if mi.cfg.AutocompleteLimit == 0 { + return + } + + _, col, _, _ := mi.GetCursor() + mi.showMentionList(col-1) +} + +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) } + +func channelHasUser(cID discord.ChannelID, id discord.UserID) bool { + perms, err := discordState.Permissions(cID, id) + if err != nil { + slog.Error("can't get permissions", "channel", cID, "user", id) + return false + } + return perms.Has(discord.PermissionViewChannel) +} + +func (mi *messageInput) searchMember(gID discord.GuildID, name string) { + key := gID.String() + " " + name + if mi.cache.Exists(key) { + return + } + // If searching for "ab" returns less than SearchLimit, + // then "abc" would not return anything new because we already searched + // everything starting with "ab". This will still be true even if a new + // member joins because arikawa loads new members into the state. + if k := key[:len(key)-1]; mi.cache.Exists(k) { + if c := mi.cache.Get(k); + c < discordState.MemberState.SearchLimit { + mi.cache.Create(key, c) + return + } + } + // Rate limit on our side because we can't distinguish between a + // successful search and SearchMember not doing anything becuase of its + // internal rate limit that we can't detect + if mi.lastSearch.Add(discordState.MemberState.SearchFrequency).After(time.Now()) { + return + } + mi.lastSearch = time.Now() + app.messagesText.waitForChunkEvent() + app.messagesText.setFetchingChunk(true, 0) + discordState.MemberState.SearchMember(gID, name) + mi.cache.Create(key, app.messagesText.waitForChunkEvent()) +} + + +func isValidUserRune(x rune) bool { + return (x >= 'a' && x <= 'z') || + (x >= 'A' && x <= 'Z') || + (x >= '0' && x <= '9') || + x == '_' || x == '.' +} + +func (mi *messageInput) showMentionList(col int) { + borders := 0 + if mi.cfg.Theme.Border.Enabled { + borders = 1 + } + l := mi.autocomplete + x, _, _, _ := mi.GetInnerRect() + _, y, _, _ := mi.GetRect() + _, _, maxW, maxH := app.messagesText.GetInnerRect() + if t := int(mi.cfg.Theme.Autocomplete.MaxHeight); t != 0 { + maxH = min(maxH, t) + } + count := l.GetItemCount() + borders + h := min(count, maxH) + borders + mi.cfg.Theme.Border.Padding[1] + y -= h + w := int(mi.cfg.Theme.Autocomplete.MinWidth) + if w == 0 { + w = maxW + } else { + for i := 0; i < count-1; i++ { + t, _ := mi.autocomplete.GetItemText(i) + w = max(w, tview.TaggedStringWidth(t)) + } + w = min(w + borders*2, maxW) + x += min(col, maxW - w) + } + l.SetRect(x, y, w, h) + app.pages.ShowPage(app.autocompletePage) + app.SetFocus(mi) +} + +func (mi *messageInput) addAutocompleteItem(gID discord.GuildID, m *discord.Member) bool { + username := m.User.Username + if username == "" { + return false + } + var dname string + if mi.cfg.Theme.Autocomplete.ShowNicknames && m.Nick != "" { + dname = m.Nick + } else { + dname = m.User.DisplayName + } + if dname != "" { + dname = tview.Escape(dname) + } + // this is WAY faster than discordState.MemberColor + if mi.cfg.Theme.Autocomplete.ShowUsernameColors { + if c, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role { + r, _ := discordState.Cabinet.Role(gID, id) + return r + }); ok { + if dname != "" { + dname = "[" + c.String() + "]" + dname + "[-]" + } else { + username = "[" + c.String() + "]" + username + "[-]" + } + } + } + // The username overwrite in the case of dname == "" is intended + if presence, _ := discordState.Cabinet.Presence(gID, m.User.ID); + presence == nil || presence.Status == discord.OfflineStatus { + username = "[::d]" + username + "[::D]" + } + if dname != "" { + mi.autocomplete.AddItem(dname + " (" + username + ")", m.User.Username, 0, nil) + } else { + mi.autocomplete.AddItem(username, m.User.Username, 0, nil) + } + return mi.autocomplete.GetItemCount() > int(mi.cfg.AutocompleteLimit) +} + +func (mi *messageInput) stopTabCompletion() { + if mi.cfg.AutocompleteLimit > 0 { + app.pages.HidePage(app.autocompletePage) + mi.autocomplete.Clear() + app.SetFocus(mi) + } +} + +func (mi *messageInput) editor() { file, err := os.CreateTemp("", tmpFilePattern) if err != nil { slog.Error("failed to create tmp file", "err", err) @@ -119,7 +428,7 @@ func (mi *messageInput) editor() { _, _ = file.WriteString(mi.GetText()) - cmd := exec.Command(editor, file.Name()) + cmd := exec.Command(mi.cfg.Editor, file.Name()) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/cmd/messages_text.go b/cmd/messages_text.go index 3735ccd..c024f9e 100644 --- a/cmd/messages_text.go +++ b/cmd/messages_text.go @@ -34,8 +34,11 @@ type messagesText struct { fetchingMembers struct { mu sync.Mutex value bool + count uint done chan struct{} } + + urlListPage tview.Page } func newMessagesText(cfg *config.Config) *messagesText { @@ -76,8 +79,10 @@ func (mt *messagesText) drawMsgs(cID discord.ChannelID) { } } + mt.Clear() + for _, m := range slices.Backward(msgs) { - app.messagesText.createMsg(m) + mt.createMsg(m) } } @@ -445,8 +450,8 @@ func extractURLs(content string) []string { func (mt *messagesText) showUrlSelector(urls []string, attachments []discord.Attachment) { done := func() { - app.pages.RemovePage("list").SwitchToPage("flex") - app.SetFocus(app.messagesText) + app.pages.RemovePage(mt.urlListPage).SwitchToPage(app.flexPage) + app.SetFocus(mt) } list := tview.NewList(). @@ -473,24 +478,19 @@ func (mt *messagesText) showUrlSelector(urls []string, attachments []discord.Att }) for i, a := range attachments { - attachment := a list.AddItem(a.Filename, "", rune('a'+i), func() { - go openURL(attachment.URL) - done() + go openURL(a.URL) }) } for i, u := range urls { - url := u list.AddItem(u, "", rune('1'+i), func() { - go openURL(url) - done() + go openURL(u) }) } - app.pages. - AddAndSwitchToPage("list", ui.Centered(list, 0, 0), true). - ShowPage("flex") + mt.urlListPage = app.pages.AddAndSwitchToPage(ui.Centered(list, 0, 0), true) + app.pages.ShowPage(app.flexPage) } func openURL(url string) { @@ -547,22 +547,17 @@ func (mt *messagesText) delete() { return } + mt.selectedMessageID = 0 + app.messageInput.replyMessageID = 0 + mt.Highlight() + if err := discordState.MessageRemove(app.guildsTree.selectedChannelID, msg.ID); err != nil { slog.Error("failed to delete message", "err", err, "channel_id", app.guildsTree.selectedChannelID, "message_id", msg.ID) return } - ms, err := discordState.Cabinet.Messages(app.guildsTree.selectedChannelID) - if err != nil { - slog.Error("failed to delete message", "err", err, "channel_id", app.guildsTree.selectedChannelID) - return - } - - mt.Clear() - - for _, m := range slices.Backward(ms) { - app.messagesText.createMsg(m) - } + // No need to redraw messages after deletion, onMessageDelete will do + // its work after the event returns } func (mt *messagesText) requestGuildMembers(gID discord.GuildID, ms []discord.Message) { @@ -583,12 +578,12 @@ func (mt *messagesText) requestGuildMembers(gID discord.GuildID, ms []discord.Me return } - mt.setFetchingChunk(true) + mt.setFetchingChunk(true, 0) mt.waitForChunkEvent() } } -func (mt *messagesText) setFetchingChunk(value bool) { +func (mt *messagesText) setFetchingChunk(value bool, count uint) { mt.fetchingMembers.mu.Lock() defer mt.fetchingMembers.mu.Unlock() @@ -601,21 +596,19 @@ func (mt *messagesText) setFetchingChunk(value bool) { if value { mt.fetchingMembers.done = make(chan struct{}) } else { + mt.fetchingMembers.count = count close(mt.fetchingMembers.done) } } -func (mt *messagesText) waitForChunkEvent() { +func (mt *messagesText) waitForChunkEvent() uint { mt.fetchingMembers.mu.Lock() if !mt.fetchingMembers.value { mt.fetchingMembers.mu.Unlock() - return + return 0 } mt.fetchingMembers.mu.Unlock() - select { - case <-mt.fetchingMembers.done: - default: - <-mt.fetchingMembers.done - } + <-mt.fetchingMembers.done + return mt.fetchingMembers.count } diff --git a/cmd/root.go b/cmd/root.go index 3b1b7e0..825d557 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,12 +9,13 @@ import ( "github.com/ayn2op/discordo/internal/logger" "github.com/ayn2op/tview" "github.com/diamondburned/arikawa/v3/utils/ws" + "github.com/diamondburned/ningen/v3" "github.com/gdamore/tcell/v2" "github.com/zalando/go-keyring" ) var ( - discordState *state + discordState *ningen.State app *application ) diff --git a/cmd/state.go b/cmd/state.go index 6807dfa..00e5516 100644 --- a/cmd/state.go +++ b/cmd/state.go @@ -15,10 +15,6 @@ import ( "github.com/gdamore/tcell/v2" ) -type state struct { - *ningen.State -} - func openState(token string) error { api.UserAgent = app.cfg.Identify.UserAgent gateway.DefaultIdentity = gateway.IdentifyProperties{ @@ -34,17 +30,19 @@ func openState(token string) error { Status: app.cfg.Identify.Status, } - discordState = &state{ - State: ningen.New(token), - } + discordState = ningen.New(token) // Handlers - discordState.AddHandler(discordState.onReady) - discordState.AddHandler(discordState.onMessageCreate) - discordState.AddHandler(discordState.onMessageDelete) + discordState.AddHandler(onReady) + discordState.AddHandler(onMessageCreate) + discordState.AddHandler(onMessageDelete) - discordState.AddHandler(func(_ *gateway.GuildMembersChunkEvent) { - app.messagesText.setFetchingChunk(false) + discordState.AddHandler(func(event *gateway.GuildMembersChunkEvent) { + app.messagesText.setFetchingChunk(false, uint(len(event.Members))) + }) + + discordState.AddHandler(func(event *gateway.GuildMemberRemoveEvent) { + app.messageInput.cache.Invalidate(event.GuildID.String() + " " + event.User.Username, discordState.MemberState.SearchLimit) }) discordState.AddHandler(func(event *ws.RawEvent) { @@ -63,12 +61,12 @@ func openState(token string) error { slog.Error("state log", "err", err) } - discordState.OnRequest = append(discordState.OnRequest, discordState.onRequest) + discordState.OnRequest = append(discordState.OnRequest, onRequest) return discordState.Open(context.TODO()) } -func (s *state) onRequest(r httpdriver.Request) error { +func onRequest(r httpdriver.Request) error { req, ok := r.(*httpdriver.DefaultRequest) if ok { slog.Debug("new HTTP request", "method", req.Method, "url", req.URL) @@ -77,7 +75,7 @@ func (s *state) onRequest(r httpdriver.Request) error { return nil } -func (s *state) onReady(ready *gateway.ReadyEvent) { +func onReady(r *gateway.ReadyEvent) { root := app.guildsTree.GetRoot() root.ClearChildren() @@ -85,7 +83,7 @@ func (s *state) onReady(ready *gateway.ReadyEvent) { dmNode.SetColor(tcell.GetColor(app.cfg.Theme.GuildsTree.PrivateChannelColor)) root.AddChild(dmNode) - for _, folder := range ready.UserSettings.GuildFolders { + for _, folder := range r.UserSettings.GuildFolders { if folder.ID == 0 && len(folder.GuildIDs) == 1 { guild, err := discordState.Cabinet.Guild(folder.GuildIDs[0]) if err != nil { @@ -110,23 +108,22 @@ func (s *state) onReady(ready *gateway.ReadyEvent) { app.SetFocus(app.guildsTree) } -func (s *state) onMessageCreate(msg *gateway.MessageCreateEvent) { +func onMessageCreate(m *gateway.MessageCreateEvent) { if app.guildsTree.selectedChannelID.IsValid() && - app.guildsTree.selectedChannelID == msg.ChannelID { - app.messagesText.createMsg(msg.Message) + app.guildsTree.selectedChannelID == m.ChannelID { + app.messagesText.createMsg(m.Message) } - if err := notifications.HandleIncomingMessage(s.State, msg, app.cfg); err != nil { + if err := notifications.HandleIncomingMessage(discordState, m, app.cfg); err != nil { slog.Error("Notification failed", "err", err) } } -func (s *state) onMessageDelete(msg *gateway.MessageDeleteEvent) { - if app.guildsTree.selectedChannelID == msg.ChannelID { +func onMessageDelete(m *gateway.MessageDeleteEvent) { + if app.guildsTree.selectedChannelID == m.ChannelID { app.messagesText.selectedMessageID = 0 app.messagesText.Highlight() app.messagesText.Clear() - - app.messagesText.drawMsgs(msg.ChannelID) + app.messagesText.drawMsgs(m.ChannelID) } } diff --git a/go.mod b/go.mod index f495c80..de05f97 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,13 @@ go 1.24.3 require ( github.com/BurntSushi/toml v1.5.0 github.com/atotto/clipboard v0.1.4 - github.com/ayn2op/tview v0.0.0-20250604214506-7f855bbbeea6 + github.com/ayn2op/tview v0.0.0-20250615210231-7feb9ae5f9b1 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb github.com/diamondburned/arikawa/v3 v3.5.0 github.com/diamondburned/ningen/v3 v3.0.1-0.20250607192146-d6b46a4689a5 github.com/gdamore/tcell/v2 v2.8.1 - github.com/gen2brain/beeep v0.10.0 + github.com/gen2brain/beeep v0.11.0 + github.com/sahilm/fuzzy v0.1.1 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/yuin/goldmark v1.7.12 github.com/zalando/go-keyring v0.2.6 @@ -26,8 +27,10 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sergeymakinen/go-bmp v1.0.0 // indirect diff --git a/go.sum b/go.sum index 7a69d44..764d1b5 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/ayn2op/tview v0.0.0-20250604214506-7f855bbbeea6 h1:W+W8iOKrt1mwNk1NJKg8D2y7kG9c+aA+RjrCiWytNaQ= github.com/ayn2op/tview v0.0.0-20250604214506-7f855bbbeea6/go.mod h1:PuMMP3J7SfW0jgAc1fdABOHJkoGbQIw3jejRAkl0API= +github.com/ayn2op/tview v0.0.0-20250615210231-7feb9ae5f9b1 h1:yJcq6PmnNKMZTr7y5mWylp3+XY20vNTNHwxRIPGL9YU= +github.com/ayn2op/tview v0.0.0-20250615210231-7feb9ae5f9b1/go.mod h1:PuMMP3J7SfW0jgAc1fdABOHJkoGbQIw3jejRAkl0API= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -53,6 +55,8 @@ github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997N github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/gen2brain/beeep v0.10.0 h1:sR/rgmJjHVOVABgpbuICvw7SVtI13RRNnQPv+wiaoMg= github.com/gen2brain/beeep v0.10.0/go.mod h1:UzRwrHPeN99aobEPCjiuBossVv32YViFiytGwaA1EO0= +github.com/gen2brain/beeep v0.11.0 h1:ScADjlllAwqUIdEgosHXRmS0OEBTDtfGvIuLj2G/mlU= +github.com/gen2brain/beeep v0.11.0/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -94,6 +98,8 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= +github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -106,6 +112,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -117,6 +125,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..580480d --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,46 @@ +// Used by MessageInput.searchMember to not overflow the gateway with redundent +// search requests. +package cache + +import ( + "sync" +) + +type Cache struct { + items sync.Map +} + +func NewCache() *Cache { + return &Cache{ items: sync.Map{} } +} + +func (c *Cache) Create(query string, value uint) { + c.items.Store(query, value) +} + +func (c *Cache) Exists(query string) (ok bool) { + _, ok = c.items.Load(query) + return +} + +func (c *Cache) Get(query string) uint { + i, _ := c.items.Load(query) + return i.(uint) +} + +// Invalidate is only needed when a member leaves and the search query reaches +// the search limit. +// "aa", "ab", "ac", ..., "ay" // where length is longer than the limit +// if "ay" leaves, then "az" would not be loaded becaue it would not be +// returned by the search results because of the search limit +func (c *Cache) Invalidate(name string, limit uint) { + for name != "" { + if c.Exists(name) && c.Get(name) >= limit { + for name != "" { + c.items.Delete(name) + name = name[:len(name)-1] + } + } + name = name[:len(name)-1] + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a1b8061..80977a2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,9 @@ type ( Markdown bool `toml:"markdown"` HideBlockedUsers bool `toml:"hide_blocked_users"` ShowAttachmentLinks bool `toml:"show_attachment_links"` + + // Use 0 to disable + AutocompleteLimit uint8 `toml:"autocomplete_limit"` MessagesLimit uint8 `toml:"messages_limit"` Timestamps Timestamps `toml:"timestamps"` diff --git a/internal/config/config.toml b/internal/config/config.toml index 841afbc..b7993a7 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -7,6 +7,11 @@ editor = "default" 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. +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. messages_limit = 50 @@ -82,12 +87,19 @@ yank_id = "Rune[i]" # Only while typing a message # Alt+Enter: Insert a new line to the current text. [keys.message_input] -# Send the message. send = "Enter" # Open message input in your editor. editor = "Ctrl+E" # Remove existing text or cancel reply. cancel = "Esc" +# Complete usernames when mentioning +tab_complete = "Tab" + +# When typing and there's an autocomplete list visible, +# pressing these keys will focus on the list instead of the message input. +[keys.autocomplete] +up = "Up" +down = "Down" # Applies to all [theme] @@ -122,10 +134,25 @@ channel_color = "white" # Set to false to show messages with usernames instead of nicknames show_user_nicks = true show_user_colors = true + reply_indicator = ">" forwarded_indicator = "<" + author_style = { foreground = "aqua" } mention_style = { foreground = "blue" } emoji_style = { foreground = "green" } url_style = { foreground = "blue" } attachment_style = { foreground = "yellow" } + +[theme.autocomplete] +show_user_nicks = true +show_user_colors = true + +# Note: width and height are capped to the avaliable space +# Minimum width +# 0 = make the list as wide as possible +min_width = 20 + +# Maximum height +# 0 = make the list as tall as needed +max_height = 0 diff --git a/internal/config/keys.go b/internal/config/keys.go index 1ffabcb..80e8d32 100644 --- a/internal/config/keys.go +++ b/internal/config/keys.go @@ -17,6 +17,7 @@ type ( GuildsTree GuildsTreeKeys `toml:"guilds_tree"` MessagesText MessagesTextKeys `toml:"messages_text"` MessageInput MessageInputKeys `toml:"message_input"` + Autocomplete AutocompleteKeys `toml:"autocomplete"` Logout string `toml:"logout"` Quit string `toml:"quit"` @@ -47,8 +48,14 @@ type ( } MessageInputKeys struct { - Send string `toml:"send"` - Editor string `toml:"editor"` - Cancel string `toml:"cancel"` + Send string `toml:"send"` + Editor string `toml:"editor"` + Cancel string `toml:"cancel"` + TabComplete string `toml:"tab_complete"` + } + + AutocompleteKeys struct { + Up string `toml:"up"` + Down string `toml:"down"` } ) diff --git a/internal/config/theme.go b/internal/config/theme.go index 6150c08..9c20d94 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -87,6 +87,7 @@ type ( Border BorderTheme `toml:"border"` GuildsTree GuildsTreeTheme `toml:"guilds_tree"` MessagesText MessagesTextTheme `toml:"messages_text"` + Autocomplete AutocompleteTheme `toml:"autocomplete"` } GuildsTreeTheme struct { @@ -113,4 +114,12 @@ type ( URLStyle StyleWrapper `toml:"url_style"` AttachmentStyle StyleWrapper `toml:"attachment_style"` } + + AutocompleteTheme struct { + ShowNicknames bool `toml:"show_user_nicks"` + ShowUsernameColors bool `toml:"show_user_colors"` + + MinWidth uint `toml:"min_width"` + MaxHeight uint `toml:"max_height"` + } ) diff --git a/internal/login/form.go b/internal/login/form.go index 9587999..82c3afc 100644 --- a/internal/login/form.go +++ b/internal/login/form.go @@ -19,6 +19,9 @@ type Form struct { cfg *config.Config form *tview.Form done DoneFn + + formPage tview.Page + errPage tview.Page } func NewForm(cfg *config.Config, done DoneFn) *Form { @@ -34,7 +37,7 @@ func NewForm(cfg *config.Config, done DoneFn) *Form { AddPasswordField("Password", "", 0, 0, nil). AddPasswordField("Code (optional)", "", 0, 0, nil). AddButton("Login", f.login) - f.AddAndSwitchToPage("form", f.form, true) + f.formPage = f.AddAndSwitchToPage(f.form, true) return f } @@ -90,9 +93,8 @@ func (f *Form) onError(err error) { SetText(err.Error()). AddButtons([]string{"Close"}). SetDoneFunc(func(_ int, _ string) { - f.RemovePage("modal").SwitchToPage("form") + f.RemovePage(f.errPage).SwitchToPage(f.formPage) }) - f. - AddAndSwitchToPage("modal", ui.Centered(modal, 0, 0), true). - ShowPage("form") + f.errPage = f.AddAndSwitchToPage(ui.Centered(modal, 0, 0), true) + f.ShowPage(f.formPage) } diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 736b228..fd8d56a 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -3,6 +3,7 @@ package markdown import ( "fmt" "io" + "strings" "github.com/ayn2op/discordo/internal/config" "github.com/diamondburned/ningen/v3/discordmd" @@ -34,7 +35,7 @@ func (r *renderer) Render(w io.Writer, source []byte, n ast.Node) error { case *ast.Document: // noop case *ast.Heading: - r.renderHeading(w) + r.renderHeading(w, n, entering) case *ast.Text: r.renderText(w, n, entering, source) case *ast.FencedCodeBlock: @@ -56,14 +57,26 @@ func (r *renderer) Render(w io.Writer, source []byte, n ast.Node) error { }) } -func (r *renderer) renderHeading(w io.Writer) { - io.WriteString(w, "\n") +func (r *renderer) renderHeading(w io.Writer, n *ast.Heading, entering bool) { + if entering { + io.WriteString(w, strings.Repeat("#", n.Level)) + io.WriteString(w, " ") + } else { + io.WriteString(w, "\n") + } } func (r *renderer) renderFencedCodeBlock(w io.Writer, n *ast.FencedCodeBlock, entering bool, source []byte) { io.WriteString(w, "\n") if entering { + // language + if l := n.Language(source); l != nil { + io.WriteString(w, "|=> ") + w.Write(l) + io.WriteString(w, "\n") + } + // body lines := n.Lines() for i := range lines.Len() {