Compare commits

..

1 commit

Author SHA1 Message Date
spearzificial
af615e4724 feat(composer): add emoji autocomplete and preview 2026-10-19 13:14:07 +05:30
12 changed files with 2214 additions and 498 deletions

View file

@ -1,244 +0,0 @@
package command
import (
"context"
"fmt"
"net/url"
"slices"
"strings"
"github.com/ayn2op/arikawa/v3/api"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/sahilm/fuzzy"
)
type Item struct {
Name string
Usage string
Description string
Source string
Builtin bool
Expand func(args string) string
RequiredPerm discord.Permissions
}
type itemList []Item
func (l itemList) String(i int) string {
return l[i].Name
}
func (l itemList) Len() int {
return len(l)
}
var Builtins = []Item{
{
Name: "shrug",
Usage: "[message]",
Description: "Appends ¯\\_(ツ)_/¯ to your message",
Source: "Built-in",
Builtin: true,
Expand: func(args string) string {
if args == "" {
return `¯\_(ツ)_/¯`
}
return args + ` ¯\_(ツ)_/¯`
},
},
{
Name: "tableflip",
Usage: "[message]",
Description: "Appends (╯°□°)╯︵ ┻━┻ to your message",
Source: "Built-in",
Builtin: true,
Expand: func(args string) string {
if args == "" {
return `(╯°□°)╯︵ ┻━┻`
}
return args + ` (╯°□°)╯︵ ┻━┻`
},
},
{
Name: "unflip",
Usage: "[message]",
Description: "Appends ┬─┬ノ( º _ ºノ) to your message",
Source: "Built-in",
Builtin: true,
Expand: func(args string) string {
if args == "" {
return `┬─┬ノ( º _ ºノ)`
}
return args + ` ┬─┬ノ( º _ ºノ)`
},
},
{
Name: "me",
Usage: "<message>",
Description: "Displays text with emphasis",
Source: "Built-in",
Builtin: true,
Expand: func(args string) string {
if args == "" {
return ""
}
return "*" + args + "*"
},
},
{
Name: "spoiler",
Usage: "<message>",
Description: "Marks your message as a spoiler",
Source: "Built-in",
Builtin: true,
Expand: func(args string) string {
if args == "" {
return ""
}
return "||" + args + "||"
},
},
{
Name: "nick",
Usage: "[new_nickname]",
Description: "Changes your nickname on the current server",
Source: "Built-in",
Builtin: true,
RequiredPerm: discord.PermissionChangeNickname,
},
}
// ExpandBuiltin expands recognized built-in slash commands if matched.
func ExpandBuiltin(text string) (string, bool) {
if !strings.HasPrefix(text, "/") {
return text, false
}
parts := strings.SplitN(text[1:], " ", 2)
name := parts[0]
var args string
if len(parts) > 1 {
args = parts[1]
}
for _, cmd := range Builtins {
if strings.EqualFold(cmd.Name, name) && cmd.Expand != nil {
return cmd.Expand(args), true
}
}
return text, false
}
// Search filters built-in and bot application commands based on query and permissions.
func Search(query string, channelID discord.ChannelID, userPerms discord.Permissions, botCmds []Item, limit int) []Item {
if limit <= 0 {
limit = 20
}
var candidates []Item
isAdmin := userPerms.Has(discord.PermissionAdministrator)
for _, b := range Builtins {
if b.RequiredPerm != 0 && !isAdmin && !userPerms.Has(b.RequiredPerm) {
continue
}
candidates = append(candidates, b)
}
for _, b := range botCmds {
if b.RequiredPerm != 0 && !isAdmin && !userPerms.Has(b.RequiredPerm) {
continue
}
candidates = append(candidates, b)
}
if query == "" {
if len(candidates) > limit {
return candidates[:limit]
}
return candidates
}
lowerQuery := strings.ToLower(query)
var results []Item
for _, c := range candidates {
if strings.HasPrefix(strings.ToLower(c.Name), lowerQuery) {
results = append(results, c)
if len(results) >= limit {
return results
}
}
}
matches := fuzzy.FindFrom(query, itemList(candidates))
for _, m := range matches {
item := candidates[m.Index]
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && it.Source == item.Source }) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
return results
}
type searchResponse struct {
Applications []struct {
ID discord.AppID `json:"id"`
Name string `json:"name"`
} `json:"applications"`
ApplicationCommands []discord.Command `json:"application_commands"`
}
// FetchBotCommands queries Discord's channel application commands endpoint for slash commands.
func FetchBotCommands(ctx context.Context, client *api.Client, channelID discord.ChannelID, query string) ([]Item, error) {
if client == nil || !channelID.IsValid() {
return nil, nil
}
endpoint := fmt.Sprintf("https://discord.com/api/v9/channels/%s/application-commands/search?query=%s&limit=25&include_applications=true",
channelID.String(), url.QueryEscape(query))
var resp searchResponse
if err := client.WithContext(ctx).RequestJSONInto(&resp, "GET", endpoint); err != nil {
return nil, err
}
appNames := make(map[discord.AppID]string, len(resp.Applications))
for _, app := range resp.Applications {
appNames[app.ID] = app.Name
}
items := make([]Item, 0, len(resp.ApplicationCommands))
for _, cmd := range resp.ApplicationCommands {
if cmd.Type != 0 && cmd.Type != discord.ChatInputCommand {
continue
}
source := appNames[cmd.AppID]
if source == "" {
source = "Bot"
}
var reqPerm discord.Permissions
if cmd.DefaultMemberPermissions != nil {
reqPerm = discord.Permissions(*cmd.DefaultMemberPermissions)
}
var usageParts []string
for _, opt := range cmd.Options {
usageParts = append(usageParts, "["+opt.Name()+"]")
}
items = append(items, Item{
Name: cmd.Name,
Usage: strings.Join(usageParts, " "),
Description: cmd.Description,
Source: source,
Builtin: false,
RequiredPerm: reqPerm,
})
}
return items, nil
}

View file

@ -1,83 +0,0 @@
package command
import (
"strings"
"testing"
"github.com/ayn2op/arikawa/v3/discord"
)
func TestBuiltinsSearch(t *testing.T) {
// Query "shrug"
results := Search("shrug", 0, discord.PermissionSendMessages, nil, 10)
if len(results) == 0 {
t.Fatal("expected results for 'shrug', got none")
}
if results[0].Name != "shrug" {
t.Errorf("expected top result 'shrug', got %q", results[0].Name)
}
// Query "flip"
flipResults := Search("flip", 0, discord.PermissionSendMessages, nil, 10)
if len(flipResults) < 2 {
t.Fatalf("expected at least 2 flip results, got %d", len(flipResults))
}
}
func TestPermissionFiltering(t *testing.T) {
perm := discord.Permissions(discord.PermissionManageMessages)
botCmds := []Item{
{
Name: "purge",
Description: "Delete messages",
Source: "ModBot",
RequiredPerm: perm,
},
}
// Without manage messages permission
noPerm := Search("purge", 0, discord.PermissionSendMessages, botCmds, 10)
if len(noPerm) != 0 {
t.Errorf("expected purge to be filtered out without permission, got %v", noPerm)
}
// With manage messages permission
withPerm := Search("purge", 0, discord.PermissionSendMessages|discord.PermissionManageMessages, botCmds, 10)
if len(withPerm) != 1 {
t.Fatalf("expected purge to be visible with permission, got %v", withPerm)
}
// With administrator permission
admin := Search("purge", 0, discord.PermissionAdministrator, botCmds, 10)
if len(admin) != 1 {
t.Fatalf("expected purge to be visible with admin permission, got %v", admin)
}
}
func TestExpandBuiltin(t *testing.T) {
tests := []struct {
input string
want string
expanded bool
}{
{"/shrug", `¯\_(ツ)_/¯`, true},
{"/shrug hello", `hello ¯\_(ツ)_/¯`, true},
{"/tableflip", `(╯°□°)╯︵ ┻━┻`, true},
{"/tableflip angry", `angry (╯°□°)╯︵ ┻━┻`, true},
{"/unflip", `┬─┬ノ( º _ ºノ)`, true},
{"/me dancing", `*dancing*`, true},
{"/spoiler secret text", `||secret text||`, true},
{"normal message", `normal message`, false},
{"/unknown command", `/unknown command`, false},
}
for _, tt := range tests {
got, ok := ExpandBuiltin(tt.input)
if ok != tt.expanded {
t.Errorf("ExpandBuiltin(%q) ok = %v, want %v", tt.input, ok, tt.expanded)
}
if ok && strings.TrimSpace(got) != strings.TrimSpace(tt.want) {
t.Errorf("ExpandBuiltin(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

134
internal/emoji/emoji.go Normal file
View file

@ -0,0 +1,134 @@
package emoji
import (
"slices"
"strings"
"sync"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/sahilm/fuzzy"
)
type Item struct {
Name string
InsertText string
Display string
Custom bool
}
type itemList []Item
func (l itemList) String(i int) string {
return l[i].Name
}
func (l itemList) Len() int {
return len(l)
}
var unicodeList = sync.OnceValue(func() []Item {
items := make([]Item, 0, len(Unicode))
for name, glyph := range Unicode {
items = append(items, Item{
Name: name,
InsertText: glyph,
Display: ":" + name + ": " + glyph,
Custom: false,
})
}
slices.SortFunc(items, func(a, b Item) int {
if a.Name < b.Name {
return -1
} else if a.Name > b.Name {
return 1
}
return 0
})
return items
})
// Search searches custom emojis and standard Unicode emojis matching the query.
func Search(query string, custom []discord.Emoji, limit int) []Item {
if limit <= 0 {
limit = 20
}
customItems := make([]Item, 0, len(custom))
for _, e := range custom {
customItems = append(customItems, Item{
Name: e.Name,
InsertText: e.String(),
Display: ":" + e.Name + ":",
Custom: true,
})
}
uItems := unicodeList()
if query == "" {
results := make([]Item, 0, limit)
results = append(results, customItems...)
if len(results) < limit {
needed := limit - len(results)
if needed > len(uItems) {
needed = len(uItems)
}
results = append(results, uItems[:needed]...)
}
if len(results) > limit {
return results[:limit]
}
return results
}
// Exact prefix matches on custom emojis get highest priority
var results []Item
lowerQuery := strings.ToLower(query)
for _, item := range customItems {
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
// Fuzzy search across remaining custom emojis
if len(customItems) > 0 {
customMatches := fuzzy.FindFrom(query, itemList(customItems))
for _, m := range customMatches {
item := customItems[m.Index]
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && it.Custom }) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
}
// Exact prefix matches on unicode emojis
for _, item := range uItems {
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
// Fuzzy search across unicode emojis
uMatches := fuzzy.FindFrom(query, itemList(uItems))
for _, m := range uMatches {
item := uItems[m.Index]
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && !it.Custom }) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
return results
}

View file

@ -0,0 +1,87 @@
package emoji
import (
"testing"
"github.com/ayn2op/arikawa/v3/discord"
)
func TestSearchUnicode(t *testing.T) {
results := Search("smile", nil, 5)
if len(results) == 0 {
t.Fatal("expected results for 'smile', got none")
}
found := false
for _, item := range results {
if item.Name == "smile" && item.InsertText == "😄" {
found = true
break
}
}
if !found {
t.Errorf("expected smile -> 😄 in results, got %+v", results)
}
}
func TestSearchCustomEmojis(t *testing.T) {
custom := []discord.Emoji{
{
ID: 123456789,
Name: "custom_cat",
},
{
ID: 987654321,
Name: "pepe_dance",
Animated: true,
},
}
results := Search("cat", custom, 5)
if len(results) == 0 {
t.Fatal("expected results for 'cat', got none")
}
found := false
for _, item := range results {
if item.Name == "custom_cat" {
found = true
if item.InsertText != "<:custom_cat:123456789>" {
t.Errorf("expected custom format, got %s", item.InsertText)
}
if !item.Custom {
t.Errorf("expected Custom=true")
}
break
}
}
if !found {
t.Errorf("expected custom_cat in results, got %+v", results)
}
// Test animated emoji
animResults := Search("dance", custom, 5)
if len(animResults) == 0 {
t.Fatal("expected results for 'dance', got none")
}
if animResults[0].InsertText != "<a:pepe_dance:987654321>" {
t.Errorf("expected animated format, got %s", animResults[0].InsertText)
}
}
func TestSearchEmptyQuery(t *testing.T) {
custom := []discord.Emoji{
{
ID: 111,
Name: "alpha",
},
}
results := Search("", custom, 10)
if len(results) == 0 {
t.Fatal("expected results for empty query, got none")
}
// Custom emojis should be listed first
if results[0].Name != "alpha" {
t.Errorf("expected custom emoji first for empty query, got %s", results[0].Name)
}
}

1860
internal/emoji/unicode.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -22,11 +22,10 @@ import (
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/arikawa/v3/utils/json/option"
"github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/command"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/consts"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
@ -73,8 +72,7 @@ type Model struct {
sendMessageData *api.SendMessageData
memberSearchCache map[string]uint
mentionsList mentionslist.Model
commandsList commandslist.Model
botCommands []command.Item
emojisList emojislist.Model
lastSearch time.Time
typingUntil time.Time
@ -91,14 +89,14 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
sendMessageData: &api.SendMessageData{},
memberSearchCache: make(map[string]uint),
mentionsList: mentionslist.NewModel(cfg),
commandsList: commandslist.NewModel(cfg),
emojisList: emojislist.NewModel(cfg),
}
return c
}
// SetChannel sets the channel messages are sent to, disabling the composer if the user may not send messages there.
func (m *Model) SetChannel(channel *discord.Channel) {
m.channel, m.typingUntil, m.botCommands = channel, time.Time{}, nil
m.channel, m.typingUntil = channel, time.Time{}
isDM := channel.Type == discord.DirectMessage || channel.Type == discord.GroupDM
// Sending in a thread takes another permission than in the channel it is in.
@ -271,7 +269,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.pasteText()
return m, pasteImage()
case keybind.Matches(msg, kbs.Send.Keybind):
if m.mentionsListVisible() || m.commandsListVisible() {
if m.mentionsListVisible() || m.emojisListVisible() {
return m, m.tabComplete()
}
return m, m.send()
@ -282,14 +280,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.stopTabCompletion()
return m, m.pickFiles()
case keybind.Matches(msg, kbs.Cancel.Keybind):
if m.mentionsListVisible() || m.commandsListVisible() {
if m.mentionsListVisible() || m.emojisListVisible() {
m.stopTabCompletion()
return m, nil
}
m.reset()
return m, nil
case keybind.Matches(msg, kbs.TabComplete.Keybind):
if m.mentionsListVisible() || m.commandsListVisible() {
if m.mentionsListVisible() || m.emojisListVisible() {
return m, m.tabComplete()
}
m.insert("\t")
@ -305,17 +303,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
var cmd tview.Cmd
m.mentionsList, cmd = m.mentionsList.Update(msg)
return m, cmd
case commandslist.Msg:
case emojislist.Msg:
var cmd tview.Cmd
m.commandsList, cmd = m.commandsList.Update(msg)
m.emojisList, cmd = m.emojisList.Update(msg)
return m, cmd
case botCommandsMsg:
m.botCommands = msg.commands
if m.commandsListVisible() || (strings.HasPrefix(m.text, "/") && !strings.Contains(m.text, " ")) {
query := strings.TrimPrefix(m.text[:m.cursor], "/")
return m, m.suggestCommands(query)
}
return m, nil
}
return m, nil
}
@ -323,7 +314,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
func (m Model) canEditLastMessage() bool {
return !m.Disabled() && m.editing == nil && m.text == "" &&
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
!m.mentionsListVisible() && !m.commandsListVisible()
!m.mentionsListVisible() && !m.emojisListVisible()
}
// StartEdit puts message in the composer to be edited.
@ -437,6 +428,10 @@ func isMentionChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
}
func isEmojiChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '+' || r == '-'
}
// suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user.
func (m *Model) suggestRecentAuthors(channel *discord.Channel) {
messages, err := m.state.Cabinet.Messages(channel.ID)
@ -516,17 +511,17 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
}
}
// AutocompleteView places the active completion list (mentions or commands) at the bottom of the area above the composer, near the cursor, or returns nil if none are active or the composer is not focused.
// AutocompleteView places the active completion list (mentions or emojis) at the bottom of the area above the composer, near the cursor, or returns nil if none are active or the composer is not focused.
func (m Model) AutocompleteView(focused bool) tview.Widget {
if !focused {
return nil
}
if m.commandsListVisible() {
return commandsPopup{m}
}
if m.mentionsListVisible() {
return mentionsPopup{m}
}
if m.emojisListVisible() {
return emojisPopup{m}
}
return nil
}
@ -566,28 +561,30 @@ func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
}
type commandsPopup struct {
type emojisPopup struct {
c Model
}
func (commandsPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
func (emojisPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
func (commandsPopup) Layout(limits layout.Limits) layout.Size {
func (emojisPopup) Layout(limits layout.Limits) layout.Size {
return layout.Atomic(limits, layout.Fill, layout.Fill)
}
func (p commandsPopup) Draw(screen tview.Screen, area tview.Rectangle) {
p.c.commandsList.View().Draw(screen, p.area(area))
func (p emojisPopup) Draw(screen tview.Screen, area tview.Rectangle) {
p.c.emojisList.View().Draw(screen, p.area(area))
}
func (p commandsPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
return p.c.commandsList.View().Handle(msg, p.area(area))
func (p emojisPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
return p.c.emojisList.View().Handle(msg, p.area(area))
}
func (p commandsPopup) area(area tview.Rectangle) tview.Rectangle {
func (p emojisPopup) area(area tview.Rectangle) tview.Rectangle {
c := p.c
size := c.commandsList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
x := c.box(false).InnerArea(tview.Rectangle{}).X
size := c.emojisList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
value, cursor := c.text, c.cursor
col := uucode.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width)
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
}
@ -649,7 +646,7 @@ func (m *Model) addMentionUser(user *discord.User) {
func (m *Model) stopTabCompletion() {
if m.cfg.AutocompleteLimit > 0 {
m.mentionsList.Clear()
m.commandsList.Clear()
m.emojisList.Clear()
}
}
@ -658,8 +655,8 @@ func (m Model) mentionsListVisible() bool {
return m.mentionsList.ItemCount() != 0
}
func (m Model) commandsListVisible() bool {
return m.commandsList.ItemCount() != 0
func (m Model) emojisListVisible() bool {
return m.emojisList.ItemCount() != 0
}
func (m Model) attach(name string, reader io.Reader) {

View file

@ -12,6 +12,7 @@ import (
"github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3"
ningenEmoji "github.com/ayn2op/ningen/v3/states/emoji"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/keybind"
"github.com/gdamore/tcell/v3"
@ -201,43 +202,62 @@ func TestModelProcessText(t *testing.T) {
}
}
func TestModelCommandSuggest(t *testing.T) {
func TestModelEmojiSuggest(t *testing.T) {
c, _ := newTestModel(t)
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
c.SetChannel(&channel)
c.setText("/", 1)
c.setText(":smi", 4)
c.tabSuggest()
if got := c.commandsList.ItemCount(); got == 0 {
t.Fatal("expected command suggestions for '/', got none")
if got := c.emojisList.ItemCount(); got == 0 {
t.Fatal("expected emoji suggestions for ':smi', got none")
}
if !c.commandsListVisible() {
t.Fatal("expected commandsListVisible to be true")
if !c.emojisListVisible() {
t.Fatal("expected emojisListVisible to be true")
}
if c.mentionsListVisible() {
t.Fatal("expected mentionsListVisible to be false")
}
c.setText("/sh", 3)
c.tabSuggest()
if got := c.commandsList.ItemCount(); got == 0 {
t.Fatal("expected command suggestions for '/sh', got none")
}
}
func TestModelCommandComplete(t *testing.T) {
func TestModelEmojiComplete(t *testing.T) {
c, _ := newTestModel(t)
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
c.SetChannel(&channel)
c.setText("/sh", 3)
c.setText("hello :smile", 12)
c.tabSuggest()
if !c.commandsListVisible() {
t.Fatal("expected commands list visible")
if !c.emojisListVisible() {
t.Fatal("expected emojis list visible")
}
c.tabComplete()
if got := c.text; got != "/shrug " {
t.Fatalf("text after completion = %q, want '/shrug '", got)
if got := c.text; got != "hello 😄 " {
t.Fatalf("text after completion = %q, want 'hello 😄 '", got)
}
if c.commandsListVisible() {
t.Fatal("expected commands list hidden after completion")
if c.emojisListVisible() {
t.Fatal("expected emojis list to be hidden after completion")
}
}
func TestModelCustomEmojiComplete(t *testing.T) {
c, _ := newTestModel(t)
guildID := discord.GuildID(100)
channel := discord.Channel{ID: 10, GuildID: guildID, Type: discord.GuildText}
c.state.EmojiState = ningenEmoji.NewState(c.state.Cabinet)
_ = c.state.Cabinet.GuildSet(&discord.Guild{ID: guildID}, false)
_ = c.state.Cabinet.EmojiSet(guildID, []discord.Emoji{
{ID: 555, Name: "custom_pepe"},
}, false)
c.SetChannel(&channel)
c.setText(":custom_pepe", 12)
c.tabSuggest()
if !c.emojisListVisible() {
t.Fatal("expected emojis list visible")
}
c.tabComplete()
if got := c.text; got != "<:custom_pepe:555> " {
t.Fatalf("text after completion = %q, want '<:custom_pepe:555> '", got)
}
if c.emojisListVisible() {
t.Fatal("expected emojis list to be hidden after completion")
}
}

View file

@ -15,8 +15,8 @@ import (
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/utils/json/option"
"github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/command"
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
"github.com/ayn2op/discordo/internal/emoji"
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
"github.com/ncruces/zenity"
@ -33,10 +33,6 @@ type EditLastMsg struct{}
// SentMsg reports that the composer sent a message.
type SentMsg struct{}
type botCommandsMsg struct {
commands []command.Item
}
type editorMsg string
// edit is a text of the composer and the cursor in it.
@ -148,25 +144,6 @@ func (m *Model) send() tview.Cmd {
return nil
}
// Handle /nick command
if strings.HasPrefix(text, "/nick ") || text == "/nick" {
if selectedChannel.GuildID.IsValid() && m.state != nil && m.state.Client != nil {
newNick := strings.TrimSpace(strings.TrimPrefix(text, "/nick"))
gID := selectedChannel.GuildID
m.reset()
return func() tview.Msg {
if err := m.state.Client.ModifyCurrentMember(gID, newNick); err != nil {
slog.Error("failed to change nickname", "guild_id", gID, "err", err)
}
return SentMsg{}
}
}
}
if expanded, ok := command.ExpandBuiltin(text); ok {
text = expanded
}
text = m.processText(selectedChannel, []byte(text))
data := *m.sendMessageData
data.Files = slices.Clone(data.Files)
@ -194,27 +171,12 @@ func (m *Model) send() tview.Cmd {
}
func (m *Model) tabComplete() tview.Cmd {
if m.commandsListVisible() {
return m.completeCommand()
}
if m.mentionsListVisible() {
return m.completeMention()
}
return nil
}
func (m *Model) completeCommand() tview.Cmd {
insertText, ok := m.commandsList.SelectedInsertText()
if !ok {
return nil
if m.emojisListVisible() {
return m.completeEmoji()
}
firstSpace := strings.Index(m.text, " ")
endPos := m.cursor
if firstSpace != -1 {
endPos = firstSpace
}
m.replace(0, endPos, insertText)
m.stopTabCompletion()
return nil
}
@ -270,22 +232,41 @@ func (m *Model) completeMention() tview.Cmd {
return nil
}
func (m *Model) tabSuggest() tview.Cmd {
if strings.HasPrefix(m.text, "/") {
firstSpace := strings.Index(m.text, " ")
if firstSpace == -1 || m.cursor <= firstSpace {
m.mentionsList.Clear()
query := m.text[1:m.cursor]
return m.suggestCommands(query)
}
}
m.commandsList.Clear()
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
if r != '@' {
func (m *Model) completeEmoji() tview.Cmd {
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
if r != ':' {
m.stopTabCompletion()
return nil
}
pos := posEnd - (len(name) + 1)
insertText, ok := m.emojisList.SelectedInsertText()
if !ok {
return nil
}
m.replace(pos, posEnd, insertText+" ")
m.stopTabCompletion()
return nil
}
func (m *Model) tabSuggest() tview.Cmd {
_, mentionName, rMention := wordBeforeCursor(m.text, m.cursor, isMentionChar)
if rMention == '@' {
m.emojisList.Clear()
return m.suggestMentions(mentionName)
}
_, emojiName, rEmoji := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
if rEmoji == ':' {
m.mentionsList.Clear()
return m.suggestEmojis(emojiName)
}
m.stopTabCompletion()
return nil
}
func (m *Model) suggestMentions(name string) tview.Cmd {
channel := m.channel
if channel == nil {
return nil
@ -334,22 +315,23 @@ func (m *Model) tabSuggest() tview.Cmd {
return nil
}
func (m *Model) suggestCommands(query string) tview.Cmd {
func (m *Model) suggestEmojis(name string) tview.Cmd {
channel := m.channel
if channel == nil {
return nil
}
m.commandsList.Clear()
m.emojisList.Clear()
var userPerms discord.Permissions
if channel.GuildID.IsValid() && m.state != nil {
if me, err := m.state.Cabinet.Me(); err == nil {
if perms, err := m.state.Permissions(channel.ID, me.ID); err == nil {
userPerms = perms
var customEmojis []discord.Emoji
if m.state != nil && m.state.EmojiState != nil {
guilds, err := m.state.EmojiState.ForGuild(channel.GuildID)
if err == nil {
for _, g := range guilds {
customEmojis = append(customEmojis, g.Emojis...)
}
} else {
slog.Debug("failed to get emojis for guild", "err", err)
}
} else {
userPerms = discord.PermissionAll
}
limit := int(m.cfg.AutocompleteLimit)
@ -357,57 +339,23 @@ func (m *Model) suggestCommands(query string) tview.Cmd {
limit = 20
}
results := command.Search(query, channel.ID, userPerms, m.botCommands, limit)
results := emoji.Search(name, customEmojis, limit)
for _, item := range results {
style := tcell.StyleDefault
display := "/" + item.Name
if item.Usage != "" {
display += " " + item.Usage
}
if item.Description != "" {
display += " - " + item.Description
}
if item.Source != "" {
display += " (" + item.Source + ")"
}
m.commandsList.Append(commandslist.Item{
m.emojisList.Append(emojislist.Item{
Name: item.Name,
Usage: item.Usage,
Description: item.Description,
Source: item.Source,
InsertText: "/" + item.Name + " ",
DisplayText: display,
InsertText: item.InsertText,
DisplayText: item.Display,
Style: style,
})
}
var fetchCmd tview.Cmd
if channel.GuildID.IsValid() && len(m.botCommands) == 0 {
fetchCmd = m.fetchBotCommands(channel.ID, query)
}
if m.commandsList.ItemCount() == 0 {
if m.emojisList.ItemCount() == 0 {
m.stopTabCompletion()
return fetchCmd
}
m.commandsList.Rebuild()
return fetchCmd
}
func (m *Model) fetchBotCommands(channelID discord.ChannelID, query string) tview.Cmd {
if m.state == nil || m.state.Client == nil {
return nil
}
client := m.state.Client
return func() tview.Msg {
items, err := command.FetchBotCommands(context.Background(), client, channelID, query)
if err != nil {
slog.Debug("failed to fetch bot commands", "err", err)
return nil
}
return botCommandsMsg{commands: items}
}
m.emojisList.Rebuild()
return nil
}
// searchMember performs member discovery in a command goroutine.

View file

@ -1,12 +1,9 @@
package commandslist
package emojislist
import "github.com/ayn2op/tview"
type Item struct {
Name string
Usage string
Description string
Source string
InsertText string
DisplayText string
Style tview.Style

View file

@ -1,4 +1,4 @@
package commandslist
package emojislist
import (
"github.com/ayn2op/discordo/internal/config"
@ -12,7 +12,7 @@ import (
"github.com/rockorager/go-uucode"
)
// Model shows command suggestions as a list in a box.
// Model shows emoji suggestions as a list in a box.
type Model struct {
cfg *config.Config
@ -35,7 +35,7 @@ func (m Model) View() tview.Widget {
list, width = list.Width(layout.Fixed(max(int(cfg.MinWidth), m.maxDisplayWidth()))), layout.Shrink
}
return ui.Box(list, m.cfg, false).Title(ui.Label("Commands", m.cfg.Theme.Title, false)).BorderSet(set).Width(width).Height(layout.Shrink)
return ui.Box(list, m.cfg, false).Title(ui.Label("Emoji", m.cfg.Theme.Title, false)).BorderSet(set).Width(width).Height(layout.Shrink)
}
func (Model) Init() tview.Cmd { return nil }

View file

@ -1,4 +1,4 @@
package commandslist
package emojislist
import "github.com/ayn2op/tview/viewport"

View file

@ -21,8 +21,8 @@ import (
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
"github.com/ayn2op/discordo/internal/ui/chat/composer"
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
@ -305,7 +305,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())},
)
}
case composer.TabSuggestMsg, mentionslist.Msg, commandslist.Msg:
case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg:
return m, m.updatePane(paneComposer, msg)
case paneMsg:
if msg.focus {