Procházet zdrojové kódy

feat: add mention support (#555)

Co-authored-by: ayn2op <ayn2op@gmail.com>
xqrs před 10 měsíci
rodič
revize
b7e095a50b

+ 11 - 11
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()

+ 2 - 0
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()

+ 319 - 10
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

+ 25 - 32
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
 }

+ 2 - 1
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
 )
 

+ 21 - 24
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)
 	}
 }

+ 5 - 2
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

+ 10 - 0
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=

+ 46 - 0
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]
+	}
+}

+ 3 - 0
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"`

+ 28 - 1
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

+ 10 - 3
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"`
 	}
 )

+ 9 - 0
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"`
+	}
 )

+ 7 - 5
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)
 }

+ 16 - 3
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() {