Compare commits
No commits in common. "dev" and "feat/emoji-autocomplete" have entirely different histories.
dev
...
feat/emoji
23 changed files with 12 additions and 993 deletions
|
|
@ -367,12 +367,6 @@ The number of empty rows between messages, except those of a group.
|
|||
|
||||
Default: `1`
|
||||
|
||||
### ui.messages_list.show_user_status
|
||||
|
||||
Whether to show the author's presence status dot alongside their name.
|
||||
|
||||
Default: `false`
|
||||
|
||||
### ui.messages_list.reply_indicator
|
||||
|
||||
Default: `">"`
|
||||
|
|
@ -856,12 +850,6 @@ Reply (with mention) to the selected message.
|
|||
|
||||
Default: `"r"`
|
||||
|
||||
### keybinds.messages_list.react
|
||||
|
||||
Add an emoji reaction to the selected message.
|
||||
|
||||
Default: `"a"`
|
||||
|
||||
### keybinds.messages_list.cancel
|
||||
|
||||
Default: `"esc"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -108,8 +108,6 @@ type MessagesListKeybinds struct {
|
|||
Reply Keybind `toml:"reply"`
|
||||
// Reply (with mention) to the selected message.
|
||||
ReplyMention Keybind `toml:"reply_mention"`
|
||||
// Add an emoji reaction to the selected message.
|
||||
React Keybind `toml:"react"`
|
||||
|
||||
Cancel Keybind `toml:"cancel"`
|
||||
Edit Keybind `toml:"edit"`
|
||||
|
|
@ -277,7 +275,6 @@ func defaultKeybinds() Keybinds {
|
|||
SelectReply: key("sel reply", "s"),
|
||||
Reply: key("reply", "R"),
|
||||
ReplyMention: key("@reply", "r"),
|
||||
React: key("react", "a"),
|
||||
|
||||
Cancel: key("cancel", "esc"),
|
||||
Edit: key("edit", "e"),
|
||||
|
|
|
|||
|
|
@ -174,9 +174,6 @@ type (
|
|||
// The number of empty rows between messages, except those of a group.
|
||||
Spacing uint8 `toml:"spacing"`
|
||||
|
||||
// Whether to show the author's presence status dot alongside their name.
|
||||
ShowUserStatus bool `toml:"show_user_status"`
|
||||
|
||||
ReplyIndicator string `toml:"reply_indicator"`
|
||||
ForwardedIndicator string `toml:"forwarded_indicator"`
|
||||
|
||||
|
|
|
|||
|
|
@ -10,20 +10,12 @@ import (
|
|||
)
|
||||
|
||||
type Item struct {
|
||||
ID discord.EmojiID
|
||||
Name string
|
||||
InsertText string
|
||||
Display string
|
||||
Custom bool
|
||||
}
|
||||
|
||||
// APIEmoji returns the formatted API emoji identifier for reactions.
|
||||
func (i Item) APIEmoji() discord.APIEmoji {
|
||||
if i.Custom && i.ID.IsValid() {
|
||||
return discord.NewAPIEmoji(i.ID, i.Name)
|
||||
}
|
||||
return discord.APIEmoji(i.InsertText)
|
||||
}
|
||||
type itemList []Item
|
||||
|
||||
func (l itemList) String(i int) string {
|
||||
|
|
@ -64,7 +56,6 @@ func Search(query string, custom []discord.Emoji, limit int) []Item {
|
|||
customItems := make([]Item, 0, len(custom))
|
||||
for _, e := range custom {
|
||||
customItems = append(customItems, Item{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
InsertText: e.String(),
|
||||
Display: ":" + e.Name + ":",
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
package commandslist
|
||||
|
||||
import "github.com/ayn2op/tview"
|
||||
|
||||
type Item struct {
|
||||
Name string
|
||||
Usage string
|
||||
Description string
|
||||
Source string
|
||||
InsertText string
|
||||
DisplayText string
|
||||
Style tview.Style
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
package commandslist
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/layout"
|
||||
"github.com/ayn2op/tview/list"
|
||||
"github.com/ayn2op/tview/scrollbar"
|
||||
"github.com/ayn2op/tview/text"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
"github.com/rockorager/go-uucode"
|
||||
)
|
||||
|
||||
// Model shows command suggestions as a list in a box.
|
||||
type Model struct {
|
||||
cfg *config.Config
|
||||
|
||||
items []Item
|
||||
cursor int
|
||||
scrollState viewport.ScrollState
|
||||
}
|
||||
|
||||
func NewModel(cfg *config.Config) Model {
|
||||
return Model{cfg: cfg, cursor: -1}
|
||||
}
|
||||
|
||||
// View shows the list in a box whose bottom corners join the composer's border below it.
|
||||
func (m Model) View() tview.Widget {
|
||||
set := m.cfg.UI.Border.NormalSet.BorderSet
|
||||
set.BottomLeft, set.BottomRight = set.BottomT, set.BottomT
|
||||
cfg := m.cfg.UI.MentionsList
|
||||
list, width := m.listView().Height(layout.Shrink.Max(int(cfg.MaxHeight))), layout.Fill
|
||||
if cfg.MinWidth != 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
func (Model) Init() tview.Cmd { return nil }
|
||||
|
||||
func (m Model) listView() viewport.Widget {
|
||||
l := list.New(m.cursor, len(m.items), func(i int) tview.Widget {
|
||||
item := m.items[i]
|
||||
return text.New(item.DisplayText).Style(item.Style).Wrapping(text.WrappingNone)
|
||||
}).
|
||||
SelectedStyle(m.cfg.Theme.MentionsList.SelectedStyle.Style).
|
||||
Keybind(ui.SelectionKeybind(m.cfg.Keybinds.MentionsList.SelectionKeybinds)).
|
||||
Focused(true).
|
||||
OnChange(func(cursor int) tview.Msg { return listMsg(cursor) })
|
||||
return viewport.New(l, m.scrollState).
|
||||
Target(l.Target(m.cursor)).
|
||||
ScrollBar(scrollbar.New(), viewport.ScrollBarVisibilityAutomatic).
|
||||
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) })
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case listMsg:
|
||||
m.cursor = int(msg)
|
||||
m.scrollState.ScrollToTarget()
|
||||
case scrollMsg:
|
||||
m.scrollState = viewport.ScrollState(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) Append(item Item) {
|
||||
m.items = append(m.items, item)
|
||||
}
|
||||
|
||||
func (m *Model) Clear() {
|
||||
m.items = nil
|
||||
m.cursor = -1
|
||||
}
|
||||
|
||||
func (m Model) ItemCount() int {
|
||||
return len(m.items)
|
||||
}
|
||||
|
||||
func (m Model) SelectedInsertText() (string, bool) {
|
||||
index := m.cursor
|
||||
if index < 0 || index >= len(m.items) {
|
||||
return "", false
|
||||
}
|
||||
return m.items[index].InsertText, true
|
||||
}
|
||||
|
||||
func (m Model) maxDisplayWidth() int {
|
||||
width := 0
|
||||
for _, item := range m.items {
|
||||
width = max(width, uucode.StringWidth(item.DisplayText))
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func (m *Model) Rebuild() {
|
||||
m.cursor = min(0, len(m.items)-1)
|
||||
m.scrollState.ScrollToTarget()
|
||||
}
|
||||
|
||||
var _ tview.Model[Model] = Model{}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
package commandslist
|
||||
|
||||
import "github.com/ayn2op/tview/viewport"
|
||||
|
||||
type Msg any
|
||||
|
||||
type listMsg int
|
||||
|
||||
type scrollMsg viewport.ScrollState
|
||||
|
|
@ -22,11 +22,9 @@ 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"
|
||||
|
|
@ -74,9 +72,7 @@ type Model struct {
|
|||
sendMessageData *api.SendMessageData
|
||||
memberSearchCache map[string]uint
|
||||
mentionsList mentionslist.Model
|
||||
commandsList commandslist.Model
|
||||
emojisList emojislist.Model
|
||||
botCommands []command.Item
|
||||
lastSearch time.Time
|
||||
|
||||
typingUntil time.Time
|
||||
|
|
@ -93,7 +89,6 @@ 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
|
||||
|
|
@ -101,7 +96,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
|
|||
|
||||
// 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.
|
||||
|
|
@ -274,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.emojisListVisible() || m.commandsListVisible() {
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
return m, m.tabComplete()
|
||||
}
|
||||
return m, m.send()
|
||||
|
|
@ -285,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.emojisListVisible() || 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.emojisListVisible() || m.commandsListVisible() {
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
return m, m.tabComplete()
|
||||
}
|
||||
m.insert("\t")
|
||||
|
|
@ -312,17 +307,6 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
var cmd tview.Cmd
|
||||
m.emojisList, cmd = m.emojisList.Update(msg)
|
||||
return m, cmd
|
||||
case commandslist.Msg:
|
||||
var cmd tview.Cmd
|
||||
m.commandsList, cmd = m.commandsList.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
|
||||
}
|
||||
|
|
@ -330,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.emojisListVisible() && !m.commandsListVisible()
|
||||
!m.mentionsListVisible() && !m.emojisListVisible()
|
||||
}
|
||||
|
||||
// StartEdit puts message in the composer to be edited.
|
||||
|
|
@ -527,20 +511,17 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// AutocompleteView places the active completion list (mentions, emojis, 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}
|
||||
}
|
||||
if m.mentionsListVisible() {
|
||||
return mentionsPopup{m}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -580,31 +561,6 @@ 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 {
|
||||
c Model
|
||||
}
|
||||
|
||||
func (commandsPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
|
||||
|
||||
func (commandsPopup) 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 commandsPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||
return p.c.commandsList.View().Handle(msg, p.area(area))
|
||||
}
|
||||
|
||||
func (p commandsPopup) 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
|
||||
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
||||
}
|
||||
|
||||
type emojisPopup struct {
|
||||
c Model
|
||||
}
|
||||
|
|
@ -691,7 +647,6 @@ func (m *Model) stopTabCompletion() {
|
|||
if m.cfg.AutocompleteLimit > 0 {
|
||||
m.mentionsList.Clear()
|
||||
m.emojisList.Clear()
|
||||
m.commandsList.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -700,10 +655,6 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,47 +202,6 @@ func TestModelProcessText(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestModelCommandSuggest(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||
c.SetChannel(&channel)
|
||||
c.setText("/", 1)
|
||||
c.tabSuggest()
|
||||
if got := c.commandsList.ItemCount(); got == 0 {
|
||||
t.Fatal("expected command suggestions for '/', got none")
|
||||
}
|
||||
if !c.commandsListVisible() {
|
||||
t.Fatal("expected commandsListVisible 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) {
|
||||
c, _ := newTestModel(t)
|
||||
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||
c.SetChannel(&channel)
|
||||
c.setText("/sh", 3)
|
||||
c.tabSuggest()
|
||||
if !c.commandsListVisible() {
|
||||
t.Fatal("expected commands list visible")
|
||||
}
|
||||
c.tabComplete()
|
||||
if got := c.text; got != "/shrug " {
|
||||
t.Fatalf("text after completion = %q, want '/shrug '", got)
|
||||
}
|
||||
if c.commandsListVisible() {
|
||||
t.Fatal("expected commands list hidden after completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEmojiSuggest(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ 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/emoji"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
|
|
@ -35,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.
|
||||
|
|
@ -150,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)
|
||||
|
|
@ -196,30 +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()
|
||||
}
|
||||
if m.emojisListVisible() {
|
||||
return m.completeEmoji()
|
||||
}
|
||||
if m.mentionsListVisible() {
|
||||
return m.completeMention()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) completeCommand() tview.Cmd {
|
||||
insertText, ok := m.commandsList.SelectedInsertText()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
firstSpace := strings.Index(m.text, " ")
|
||||
endPos := m.cursor
|
||||
if firstSpace != -1 {
|
||||
endPos = firstSpace
|
||||
}
|
||||
m.replace(0, endPos, insertText)
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -293,28 +250,15 @@ func (m *Model) completeEmoji() tview.Cmd {
|
|||
}
|
||||
|
||||
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()
|
||||
m.emojisList.Clear()
|
||||
query := m.text[1:m.cursor]
|
||||
return m.suggestCommands(query)
|
||||
}
|
||||
}
|
||||
m.commandsList.Clear()
|
||||
|
||||
_, mentionName, rMention := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||
if rMention == '@' {
|
||||
m.emojisList.Clear()
|
||||
m.commandsList.Clear()
|
||||
return m.suggestMentions(mentionName)
|
||||
}
|
||||
|
||||
_, emojiName, rEmoji := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
|
||||
if rEmoji == ':' {
|
||||
m.mentionsList.Clear()
|
||||
m.commandsList.Clear()
|
||||
return m.suggestEmojis(emojiName)
|
||||
}
|
||||
|
||||
|
|
@ -371,82 +315,6 @@ func (m *Model) suggestMentions(name string) tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) suggestCommands(query string) tview.Cmd {
|
||||
channel := m.channel
|
||||
if channel == nil {
|
||||
return nil
|
||||
}
|
||||
m.commandsList.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
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userPerms = discord.PermissionAll
|
||||
}
|
||||
|
||||
limit := int(m.cfg.AutocompleteLimit)
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
results := command.Search(query, channel.ID, userPerms, m.botCommands, 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{
|
||||
Name: item.Name,
|
||||
Usage: item.Usage,
|
||||
Description: item.Description,
|
||||
Source: item.Source,
|
||||
InsertText: "/" + item.Name + " ",
|
||||
DisplayText: 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 {
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) suggestEmojis(name string) tview.Cmd {
|
||||
channel := m.channel
|
||||
if channel == nil {
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
package emojipicker
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/emoji"
|
||||
uipicker "github.com/ayn2op/discordo/internal/ui/picker"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/picker"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
uipicker.Model
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewModel(cfg *config.Config) Model {
|
||||
return Model{Model: uipicker.NewModel(cfg, "Add Reaction"), cfg: cfg}
|
||||
}
|
||||
|
||||
var _ tview.Model[Model] = Model{}
|
||||
|
||||
// View shows the picker in a box titled Add Reaction.
|
||||
func (m Model) View() tview.Widget {
|
||||
return m.Model.View(func(item picker.Item) tview.Msg {
|
||||
apiEmoji, ok := item.Reference.(discord.APIEmoji)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return SelectedMsg{Emoji: apiEmoji}
|
||||
}, CancelMsg{})
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||
var cmd tview.Cmd
|
||||
m.Model, cmd = m.Model.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// RefreshEmojis repopulates the picker with custom guild emojis and Unicode emojis.
|
||||
func (m *Model) RefreshEmojis(state *ningen.State, guildID discord.GuildID) {
|
||||
var customEmojis []discord.Emoji
|
||||
if state != nil && state.EmojiState != nil {
|
||||
guilds, err := state.EmojiState.ForGuild(guildID)
|
||||
if err == nil {
|
||||
for _, g := range guilds {
|
||||
customEmojis = append(customEmojis, g.Emojis...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := emoji.Search("", customEmojis, 2500)
|
||||
items := make(picker.Items, 0, len(results))
|
||||
for _, item := range results {
|
||||
items = append(items, picker.Item{
|
||||
Text: item.Display,
|
||||
FilterText: item.Name,
|
||||
Reference: item.APIEmoji(),
|
||||
})
|
||||
}
|
||||
m.SetItems(items)
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package emojipicker
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/arikawa/v3/state/store/defaultstore"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
ningenEmoji "github.com/ayn2op/ningen/v3/states/emoji"
|
||||
)
|
||||
|
||||
func TestEmojiPickerRefresh(t *testing.T) {
|
||||
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st := ningen.FromState(state.NewWithStore("", defaultstore.New()))
|
||||
guildID := discord.GuildID(100)
|
||||
st.EmojiState = ningenEmoji.NewState(st.Cabinet)
|
||||
_ = st.Cabinet.GuildSet(&discord.Guild{ID: guildID}, false)
|
||||
_ = st.Cabinet.EmojiSet(guildID, []discord.Emoji{
|
||||
{ID: 555, Name: "custom_pepe"},
|
||||
}, false)
|
||||
|
||||
p := NewModel(cfg)
|
||||
p.RefreshEmojis(st, guildID)
|
||||
|
||||
widget := p.View()
|
||||
if widget == nil {
|
||||
t.Fatal("expected non-nil view")
|
||||
}
|
||||
|
||||
// Test Update
|
||||
p, _ = p.Update(CancelMsg{})
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package emojipicker
|
||||
|
||||
import "github.com/ayn2op/arikawa/v3/discord"
|
||||
|
||||
type (
|
||||
SelectedMsg struct {
|
||||
Emoji discord.APIEmoji
|
||||
}
|
||||
CancelMsg struct{}
|
||||
)
|
||||
|
|
@ -17,7 +17,6 @@ func (m Model) ShortHelp() []keybind.Keybind {
|
|||
}
|
||||
|
||||
if selectedMessage, ok := m.selectedMessage(); ok {
|
||||
help = append(help, cfg.React.Keybind)
|
||||
if !ui.IsMe(m.state, selectedMessage.Author.ID) {
|
||||
help = append(help, cfg.Reply.Keybind)
|
||||
}
|
||||
|
|
@ -36,7 +35,6 @@ func (m Model) FullHelp() [][]keybind.Keybind {
|
|||
cfg := m.cfg.Keybinds.MessagesList
|
||||
var actions, manage, attachments []keybind.Keybind
|
||||
if message, ok := m.selectedMessage(); ok {
|
||||
actions = append(actions, cfg.React.Keybind)
|
||||
mine := ui.IsMe(m.state, message.Author.ID)
|
||||
if !mine {
|
||||
actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind)
|
||||
|
|
|
|||
|
|
@ -175,14 +175,7 @@ func (m Model) drawCozy(builder *markdown.Builder, message discord.Message, base
|
|||
}
|
||||
}
|
||||
|
||||
const statusMarker = "●"
|
||||
|
||||
func (m Model) drawAuthor(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) {
|
||||
if m.cfg.UI.MessagesList.ShowUserStatus && !message.WebhookID.IsValid() {
|
||||
status := m.status(message.GuildID, message.Author.ID)
|
||||
builder.Write(statusMarker+" ", m.cfg.Theme.MembersTree.StatusStyle(status))
|
||||
}
|
||||
|
||||
name := message.Author.DisplayOrUsername()
|
||||
foreground := tcell.ColorDefault
|
||||
|
||||
|
|
@ -218,17 +211,6 @@ func (m Model) memberForMessage(message discord.Message) *discord.Member {
|
|||
return member
|
||||
}
|
||||
|
||||
func (m Model) status(guildID discord.GuildID, userID discord.UserID) discord.Status {
|
||||
if m.state == nil || m.state.Cabinet == nil {
|
||||
return discord.OfflineStatus
|
||||
}
|
||||
presence, err := m.state.Cabinet.Presence(guildID, userID)
|
||||
if err != nil {
|
||||
return discord.OfflineStatus
|
||||
}
|
||||
return presence.Status
|
||||
}
|
||||
|
||||
// drawContent renders the message body. root is its parsed content, or nil to parse it here.
|
||||
func (m Model) drawContent(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style) {
|
||||
if root == nil && m.cfg.Markdown.Enabled {
|
||||
|
|
|
|||
|
|
@ -464,8 +464,6 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
return m, m.seek(m.voiceStatus.Position + seekStep)
|
||||
case keybind.Matches(msg, kbs.Download.Keybind):
|
||||
return m, m.download()
|
||||
case keybind.Matches(msg, kbs.React.Keybind):
|
||||
return m, m.react()
|
||||
case keybind.Matches(msg, kbs.Reply.Keybind):
|
||||
return m, m.reply(false)
|
||||
case keybind.Matches(msg, kbs.ReplyMention.Keybind):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/markdown"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
tviewimage "github.com/ayn2op/tview/image"
|
||||
|
|
@ -402,39 +401,3 @@ func TestModelReactionsView(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelReact(t *testing.T) {
|
||||
ml := newTestModel(t, testConfig(t))
|
||||
msg := discord.Message{ID: 10, ChannelID: 1}
|
||||
ml.items = []messageItem{{message: msg}}
|
||||
ml.cursor = 0
|
||||
|
||||
cmd := ml.react()
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd for react()")
|
||||
}
|
||||
result := cmd()
|
||||
reactMsg, ok := result.(ReactMsg)
|
||||
if !ok || reactMsg.Message.ID != 10 {
|
||||
t.Fatalf("result = %v, want ReactMsg with ID 10", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelShowUserStatus(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
cfg.UI.MessagesList.ShowUserStatus = true
|
||||
ml := newTestModel(t, cfg)
|
||||
user := discord.User{ID: 10, Username: "alice"}
|
||||
_ = ml.state.PresenceSet(discord.NullGuildID, &discord.Presence{User: user, Status: discord.OnlineStatus}, false)
|
||||
|
||||
msg := discord.Message{ID: 1, Author: user, Content: "hello"}
|
||||
builder := new(markdown.Builder)
|
||||
ml.drawAuthor(builder, msg, tcell.StyleDefault)
|
||||
spans := builder.Finish()
|
||||
if len(spans) == 0 {
|
||||
t.Fatal("expected spans from drawAuthor")
|
||||
}
|
||||
if !strings.HasPrefix(spans[0].Text, statusMarker) {
|
||||
t.Fatalf("first span text = %q, want statusMarker %q", spans[0].Text, statusMarker)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,6 @@ import (
|
|||
// Msg is implemented by the messages the list sends itself, which must reach it whatever has the focus.
|
||||
type Msg interface{ messagesList() }
|
||||
|
||||
// ReactMsg requests opening the reaction picker for the message.
|
||||
type ReactMsg struct {
|
||||
Message discord.Message
|
||||
}
|
||||
|
||||
type olderMessagesLoadedMsg struct {
|
||||
channelID discord.ChannelID
|
||||
older []discord.Message
|
||||
|
|
@ -350,15 +345,6 @@ func (m Model) reply(mention bool) tview.Cmd {
|
|||
return func() tview.Msg { return ReplyMsg{Message: message, Name: name, Mention: mention} }
|
||||
}
|
||||
|
||||
func (m Model) react() tview.Cmd {
|
||||
selectedMessage, ok := m.selectedMessage()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
message := *selectedMessage
|
||||
return func() tview.Msg { return ReactMsg{Message: message} }
|
||||
}
|
||||
|
||||
func (m Model) editSelectedMessage() tview.Cmd {
|
||||
selectedMessage, ok := m.selectedMessage()
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ 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/emojipicker"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
|
||||
|
|
@ -55,11 +53,9 @@ type Model struct {
|
|||
membersTree memberstree.Model
|
||||
messagesList messageslist.Model
|
||||
composer composer.Model
|
||||
channelsPicker channelspicker.Model
|
||||
channelsPicker channelspicker.Model
|
||||
// attachmentsPicker picks an attachment or link of the selected message.
|
||||
attachmentsPicker attachmentspicker.Model
|
||||
emojiPicker emojipicker.Model
|
||||
reactingMessage *discord.Message
|
||||
|
||||
selectedChannel *discord.Channel
|
||||
// windowUnfocused reports whether the terminal window lost focus.
|
||||
|
|
@ -115,7 +111,6 @@ func NewModel(cfg *config.Config, token string) Model {
|
|||
m.composer = composer.NewModel(cfg, m.state)
|
||||
m.channelsPicker = channelspicker.NewModel(cfg)
|
||||
m.attachmentsPicker = attachmentspicker.NewModel(cfg)
|
||||
m.emojiPicker = emojipicker.NewModel(cfg)
|
||||
|
||||
m.guildsTreeVisible = cfg.Sidebar.Visible
|
||||
m.membersTreeVisible = cfg.MembersTree.Visible
|
||||
|
|
@ -135,7 +130,6 @@ const (
|
|||
overlayNone overlay = iota
|
||||
overlayChannelsPicker
|
||||
overlayAttachmentsPicker
|
||||
overlayEmojiPicker
|
||||
)
|
||||
|
||||
// pickerView returns the view of the picker shown on top, or nil if none is open.
|
||||
|
|
@ -145,8 +139,6 @@ func (m Model) pickerView() tview.Widget {
|
|||
return m.channelsPicker.View()
|
||||
case overlayAttachmentsPicker:
|
||||
return m.attachmentsPicker.View()
|
||||
case overlayEmojiPicker:
|
||||
return m.emojiPicker.View()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -252,54 +244,6 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
m.attachmentsPicker.SetItems(picker.Items(msg))
|
||||
m.overlay = overlayAttachmentsPicker
|
||||
return m, nil
|
||||
case messageslist.ReactMsg:
|
||||
m.overlay = overlayEmojiPicker
|
||||
m.reactingMessage = &msg.Message
|
||||
var gID discord.GuildID
|
||||
if m.selectedChannel != nil {
|
||||
gID = m.selectedChannel.GuildID
|
||||
}
|
||||
m.emojiPicker.RefreshEmojis(m.state, gID)
|
||||
return m, nil
|
||||
case emojipicker.CancelMsg:
|
||||
m.overlay = overlayNone
|
||||
m.reactingMessage = nil
|
||||
return m, nil
|
||||
case emojipicker.SelectedMsg:
|
||||
m.overlay = overlayNone
|
||||
if m.reactingMessage == nil || m.selectedChannel == nil {
|
||||
return m, nil
|
||||
}
|
||||
chID := m.selectedChannel.ID
|
||||
msgID := m.reactingMessage.ID
|
||||
targetEmoji := msg.Emoji
|
||||
reactions := m.reactingMessage.Reactions
|
||||
m.reactingMessage = nil
|
||||
|
||||
alreadyReacted := false
|
||||
for _, r := range reactions {
|
||||
if r.Emoji.APIString() == targetEmoji && r.Me {
|
||||
alreadyReacted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return m, func() tview.Msg {
|
||||
if m.state == nil || m.state.Client == nil {
|
||||
return nil
|
||||
}
|
||||
client := m.state.Client
|
||||
if alreadyReacted {
|
||||
if err := client.Unreact(chID, msgID, targetEmoji); err != nil {
|
||||
slog.Error("failed to remove reaction", "channel_id", chID, "message_id", msgID, "emoji", targetEmoji, "err", err)
|
||||
}
|
||||
} else {
|
||||
if err := client.React(chID, msgID, targetEmoji); err != nil {
|
||||
slog.Error("failed to add reaction", "channel_id", chID, "message_id", msgID, "emoji", targetEmoji, "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case messageslist.ReplyMsg:
|
||||
m.composer.StartReply(msg.Message, msg.Name, msg.Mention)
|
||||
m.setFocus(paneComposer)
|
||||
|
|
@ -361,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, emojislist.Msg, commandslist.Msg:
|
||||
case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg:
|
||||
return m, m.updatePane(paneComposer, msg)
|
||||
case paneMsg:
|
||||
if msg.focus {
|
||||
|
|
@ -383,8 +327,6 @@ func (m *Model) route(msg tview.Msg) tview.Cmd {
|
|||
m.channelsPicker, cmd = m.channelsPicker.Update(msg)
|
||||
case overlayAttachmentsPicker:
|
||||
m.attachmentsPicker, cmd = m.attachmentsPicker.Update(msg)
|
||||
case overlayEmojiPicker:
|
||||
m.emojiPicker, cmd = m.emojiPicker.Update(msg)
|
||||
default:
|
||||
cmd = m.updatePane(m.focused, msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojipicker"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/messageslist"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
)
|
||||
|
|
@ -101,35 +99,3 @@ func TestModelCycleFocus(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelReactions(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
channel := discord.Channel{ID: 1, GuildID: 2, Type: discord.GuildText}
|
||||
m.setSelectedChannel(&channel)
|
||||
|
||||
msg := discord.Message{ID: 10, ChannelID: 1}
|
||||
*m, _ = m.Update(messageslist.ReactMsg{Message: msg})
|
||||
if m.overlay != overlayEmojiPicker {
|
||||
t.Fatalf("overlay = %v, want overlayEmojiPicker", m.overlay)
|
||||
}
|
||||
if m.reactingMessage == nil || m.reactingMessage.ID != 10 {
|
||||
t.Fatalf("reactingMessage = %v, want ID 10", m.reactingMessage)
|
||||
}
|
||||
|
||||
// Cancel closes overlay
|
||||
*m, _ = m.Update(emojipicker.CancelMsg{})
|
||||
if m.overlay != overlayNone {
|
||||
t.Fatalf("overlay after cancel = %v, want overlayNone", m.overlay)
|
||||
}
|
||||
|
||||
// Select triggers react command and closes overlay
|
||||
*m, _ = m.Update(messageslist.ReactMsg{Message: msg})
|
||||
var cmd tview.Cmd
|
||||
*m, cmd = m.Update(emojipicker.SelectedMsg{Emoji: discord.APIEmoji("😄")})
|
||||
if m.overlay != overlayNone {
|
||||
t.Fatalf("overlay after select = %v, want overlayNone", m.overlay)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil command for reaction action")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,9 +110,6 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
|
|||
case *gateway.MessageUpdateEvent:
|
||||
return m.messagesList.UpdateMessage(event.Message)
|
||||
case *gateway.PresenceUpdateEvent:
|
||||
if m.cfg.UI.MessagesList.ShowUserStatus {
|
||||
m.messagesList.InvalidateRendered()
|
||||
}
|
||||
return tview.Batch(m.updatePane(paneGuildsTree, event), m.updatePane(paneMembersTree, event))
|
||||
case *gateway.GuildMemberListUpdateEvent:
|
||||
return m.updatePane(paneMembersTree, event)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue