feat: add mention support (#555)

Co-authored-by: ayn2op <ayn2op@gmail.com>
This commit is contained in:
xqrs 2025-06-16 00:19:13 +03:00 • committed by GitHub
parent b1e30d2ef6
commit b7e095a50b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 514 additions and 92 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

7
go.mod
View file

@ -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
go.sum
View file

@ -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
internal/cache/cache.go vendored Normal file
View file

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

View file

@ -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"`

View file

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

View file

@ -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"`
}
)

View file

@ -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"`
}
)

View file

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

View file

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