Compare commits

..

6 commits

23 changed files with 993 additions and 12 deletions

View file

@ -367,6 +367,12 @@ The number of empty rows between messages, except those of a group.
Default: `1` 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 ### ui.messages_list.reply_indicator
Default: `">"` Default: `">"`
@ -850,6 +856,12 @@ Reply (with mention) to the selected message.
Default: `"r"` Default: `"r"`
### keybinds.messages_list.react
Add an emoji reaction to the selected message.
Default: `"a"`
### keybinds.messages_list.cancel ### keybinds.messages_list.cancel
Default: `"esc"` Default: `"esc"`

244
internal/command/command.go Normal file
View file

@ -0,0 +1,244 @@
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

@ -0,0 +1,83 @@
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)
}
}
}

View file

@ -108,6 +108,8 @@ type MessagesListKeybinds struct {
Reply Keybind `toml:"reply"` Reply Keybind `toml:"reply"`
// Reply (with mention) to the selected message. // Reply (with mention) to the selected message.
ReplyMention Keybind `toml:"reply_mention"` ReplyMention Keybind `toml:"reply_mention"`
// Add an emoji reaction to the selected message.
React Keybind `toml:"react"`
Cancel Keybind `toml:"cancel"` Cancel Keybind `toml:"cancel"`
Edit Keybind `toml:"edit"` Edit Keybind `toml:"edit"`
@ -275,6 +277,7 @@ func defaultKeybinds() Keybinds {
SelectReply: key("sel reply", "s"), SelectReply: key("sel reply", "s"),
Reply: key("reply", "R"), Reply: key("reply", "R"),
ReplyMention: key("@reply", "r"), ReplyMention: key("@reply", "r"),
React: key("react", "a"),
Cancel: key("cancel", "esc"), Cancel: key("cancel", "esc"),
Edit: key("edit", "e"), Edit: key("edit", "e"),

View file

@ -174,6 +174,9 @@ type (
// The number of empty rows between messages, except those of a group. // The number of empty rows between messages, except those of a group.
Spacing uint8 `toml:"spacing"` 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"` ReplyIndicator string `toml:"reply_indicator"`
ForwardedIndicator string `toml:"forwarded_indicator"` ForwardedIndicator string `toml:"forwarded_indicator"`

View file

@ -10,12 +10,20 @@ import (
) )
type Item struct { type Item struct {
ID discord.EmojiID
Name string Name string
InsertText string InsertText string
Display string Display string
Custom bool 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 type itemList []Item
func (l itemList) String(i int) string { func (l itemList) String(i int) string {
@ -56,6 +64,7 @@ func Search(query string, custom []discord.Emoji, limit int) []Item {
customItems := make([]Item, 0, len(custom)) customItems := make([]Item, 0, len(custom))
for _, e := range custom { for _, e := range custom {
customItems = append(customItems, Item{ customItems = append(customItems, Item{
ID: e.ID,
Name: e.Name, Name: e.Name,
InsertText: e.String(), InsertText: e.String(),
Display: ":" + e.Name + ":", Display: ":" + e.Name + ":",

View file

@ -0,0 +1,13 @@
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
}

View file

@ -0,0 +1,103 @@
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{}

View file

@ -0,0 +1,9 @@
package commandslist
import "github.com/ayn2op/tview/viewport"
type Msg any
type listMsg int
type scrollMsg viewport.ScrollState

View file

@ -22,9 +22,11 @@ import (
"github.com/ayn2op/arikawa/v3/state" "github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/arikawa/v3/utils/json/option" "github.com/ayn2op/arikawa/v3/utils/json/option"
"github.com/ayn2op/arikawa/v3/utils/sendpart" "github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/command"
"github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/consts"
"github.com/ayn2op/discordo/internal/ui" "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/emojislist"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist" "github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
"github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3"
@ -72,7 +74,9 @@ type Model struct {
sendMessageData *api.SendMessageData sendMessageData *api.SendMessageData
memberSearchCache map[string]uint memberSearchCache map[string]uint
mentionsList mentionslist.Model mentionsList mentionslist.Model
commandsList commandslist.Model
emojisList emojislist.Model emojisList emojislist.Model
botCommands []command.Item
lastSearch time.Time lastSearch time.Time
typingUntil time.Time typingUntil time.Time
@ -89,6 +93,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
sendMessageData: &api.SendMessageData{}, sendMessageData: &api.SendMessageData{},
memberSearchCache: make(map[string]uint), memberSearchCache: make(map[string]uint),
mentionsList: mentionslist.NewModel(cfg), mentionsList: mentionslist.NewModel(cfg),
commandsList: commandslist.NewModel(cfg),
emojisList: emojislist.NewModel(cfg), emojisList: emojislist.NewModel(cfg),
} }
return c return c
@ -96,7 +101,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. // 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) { func (m *Model) SetChannel(channel *discord.Channel) {
m.channel, m.typingUntil = channel, time.Time{} m.channel, m.typingUntil, m.botCommands = channel, time.Time{}, nil
isDM := channel.Type == discord.DirectMessage || channel.Type == discord.GroupDM isDM := channel.Type == discord.DirectMessage || channel.Type == discord.GroupDM
// Sending in a thread takes another permission than in the channel it is in. // Sending in a thread takes another permission than in the channel it is in.
@ -269,7 +274,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.pasteText() m.pasteText()
return m, pasteImage() return m, pasteImage()
case keybind.Matches(msg, kbs.Send.Keybind): case keybind.Matches(msg, kbs.Send.Keybind):
if m.mentionsListVisible() || m.emojisListVisible() { if m.mentionsListVisible() || m.emojisListVisible() || m.commandsListVisible() {
return m, m.tabComplete() return m, m.tabComplete()
} }
return m, m.send() return m, m.send()
@ -280,14 +285,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.stopTabCompletion() m.stopTabCompletion()
return m, m.pickFiles() return m, m.pickFiles()
case keybind.Matches(msg, kbs.Cancel.Keybind): case keybind.Matches(msg, kbs.Cancel.Keybind):
if m.mentionsListVisible() || m.emojisListVisible() { if m.mentionsListVisible() || m.emojisListVisible() || m.commandsListVisible() {
m.stopTabCompletion() m.stopTabCompletion()
return m, nil return m, nil
} }
m.reset() m.reset()
return m, nil return m, nil
case keybind.Matches(msg, kbs.TabComplete.Keybind): case keybind.Matches(msg, kbs.TabComplete.Keybind):
if m.mentionsListVisible() || m.emojisListVisible() { if m.mentionsListVisible() || m.emojisListVisible() || m.commandsListVisible() {
return m, m.tabComplete() return m, m.tabComplete()
} }
m.insert("\t") m.insert("\t")
@ -307,6 +312,17 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
var cmd tview.Cmd var cmd tview.Cmd
m.emojisList, cmd = m.emojisList.Update(msg) m.emojisList, cmd = m.emojisList.Update(msg)
return m, cmd 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 return m, nil
} }
@ -314,7 +330,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
func (m Model) canEditLastMessage() bool { func (m Model) canEditLastMessage() bool {
return !m.Disabled() && m.editing == nil && m.text == "" && return !m.Disabled() && m.editing == nil && m.text == "" &&
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 && m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
!m.mentionsListVisible() && !m.emojisListVisible() !m.mentionsListVisible() && !m.emojisListVisible() && !m.commandsListVisible()
} }
// StartEdit puts message in the composer to be edited. // StartEdit puts message in the composer to be edited.
@ -511,17 +527,20 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
} }
} }
// 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. // 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.
func (m Model) AutocompleteView(focused bool) tview.Widget { func (m Model) AutocompleteView(focused bool) tview.Widget {
if !focused { if !focused {
return nil return nil
} }
if m.mentionsListVisible() { if m.commandsListVisible() {
return mentionsPopup{m} return commandsPopup{m}
} }
if m.emojisListVisible() { if m.emojisListVisible() {
return emojisPopup{m} return emojisPopup{m}
} }
if m.mentionsListVisible() {
return mentionsPopup{m}
}
return nil return nil
} }
@ -561,6 +580,31 @@ 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} 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 { type emojisPopup struct {
c Model c Model
} }
@ -647,6 +691,7 @@ func (m *Model) stopTabCompletion() {
if m.cfg.AutocompleteLimit > 0 { if m.cfg.AutocompleteLimit > 0 {
m.mentionsList.Clear() m.mentionsList.Clear()
m.emojisList.Clear() m.emojisList.Clear()
m.commandsList.Clear()
} }
} }
@ -655,6 +700,10 @@ func (m Model) mentionsListVisible() bool {
return m.mentionsList.ItemCount() != 0 return m.mentionsList.ItemCount() != 0
} }
func (m Model) commandsListVisible() bool {
return m.commandsList.ItemCount() != 0
}
func (m Model) emojisListVisible() bool { func (m Model) emojisListVisible() bool {
return m.emojisList.ItemCount() != 0 return m.emojisList.ItemCount() != 0
} }

View file

@ -202,6 +202,47 @@ 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) { func TestModelEmojiSuggest(t *testing.T) {
c, _ := newTestModel(t) c, _ := newTestModel(t)
channel := discord.Channel{ID: 1, Type: discord.DirectMessage} channel := discord.Channel{ID: 1, Type: discord.DirectMessage}

View file

@ -15,7 +15,9 @@ import (
"github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/utils/json/option" "github.com/ayn2op/arikawa/v3/utils/json/option"
"github.com/ayn2op/arikawa/v3/utils/sendpart" "github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/command"
"github.com/ayn2op/discordo/internal/emoji" "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/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
@ -33,6 +35,10 @@ type EditLastMsg struct{}
// SentMsg reports that the composer sent a message. // SentMsg reports that the composer sent a message.
type SentMsg struct{} type SentMsg struct{}
type botCommandsMsg struct {
commands []command.Item
}
type editorMsg string type editorMsg string
// edit is a text of the composer and the cursor in it. // edit is a text of the composer and the cursor in it.
@ -144,6 +150,25 @@ func (m *Model) send() tview.Cmd {
return nil 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)) text = m.processText(selectedChannel, []byte(text))
data := *m.sendMessageData data := *m.sendMessageData
data.Files = slices.Clone(data.Files) data.Files = slices.Clone(data.Files)
@ -171,12 +196,30 @@ func (m *Model) send() tview.Cmd {
} }
func (m *Model) tabComplete() tview.Cmd { func (m *Model) tabComplete() tview.Cmd {
if m.mentionsListVisible() { if m.commandsListVisible() {
return m.completeMention() return m.completeCommand()
} }
if m.emojisListVisible() { if m.emojisListVisible() {
return m.completeEmoji() 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 return nil
} }
@ -250,15 +293,28 @@ func (m *Model) completeEmoji() tview.Cmd {
} }
func (m *Model) tabSuggest() 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) _, mentionName, rMention := wordBeforeCursor(m.text, m.cursor, isMentionChar)
if rMention == '@' { if rMention == '@' {
m.emojisList.Clear() m.emojisList.Clear()
m.commandsList.Clear()
return m.suggestMentions(mentionName) return m.suggestMentions(mentionName)
} }
_, emojiName, rEmoji := wordBeforeCursor(m.text, m.cursor, isEmojiChar) _, emojiName, rEmoji := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
if rEmoji == ':' { if rEmoji == ':' {
m.mentionsList.Clear() m.mentionsList.Clear()
m.commandsList.Clear()
return m.suggestEmojis(emojiName) return m.suggestEmojis(emojiName)
} }
@ -315,6 +371,82 @@ func (m *Model) suggestMentions(name string) tview.Cmd {
return nil 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 { func (m *Model) suggestEmojis(name string) tview.Cmd {
channel := m.channel channel := m.channel
if channel == nil { if channel == nil {

View file

@ -0,0 +1,63 @@
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)
}

View file

@ -0,0 +1,39 @@
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{})
}

View file

@ -0,0 +1,10 @@
package emojipicker
import "github.com/ayn2op/arikawa/v3/discord"
type (
SelectedMsg struct {
Emoji discord.APIEmoji
}
CancelMsg struct{}
)

View file

@ -17,6 +17,7 @@ func (m Model) ShortHelp() []keybind.Keybind {
} }
if selectedMessage, ok := m.selectedMessage(); ok { if selectedMessage, ok := m.selectedMessage(); ok {
help = append(help, cfg.React.Keybind)
if !ui.IsMe(m.state, selectedMessage.Author.ID) { if !ui.IsMe(m.state, selectedMessage.Author.ID) {
help = append(help, cfg.Reply.Keybind) help = append(help, cfg.Reply.Keybind)
} }
@ -35,6 +36,7 @@ func (m Model) FullHelp() [][]keybind.Keybind {
cfg := m.cfg.Keybinds.MessagesList cfg := m.cfg.Keybinds.MessagesList
var actions, manage, attachments []keybind.Keybind var actions, manage, attachments []keybind.Keybind
if message, ok := m.selectedMessage(); ok { if message, ok := m.selectedMessage(); ok {
actions = append(actions, cfg.React.Keybind)
mine := ui.IsMe(m.state, message.Author.ID) mine := ui.IsMe(m.state, message.Author.ID)
if !mine { if !mine {
actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind) actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind)

View file

@ -175,7 +175,14 @@ 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) { 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() name := message.Author.DisplayOrUsername()
foreground := tcell.ColorDefault foreground := tcell.ColorDefault
@ -211,6 +218,17 @@ func (m Model) memberForMessage(message discord.Message) *discord.Member {
return 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. // 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) { func (m Model) drawContent(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style) {
if root == nil && m.cfg.Markdown.Enabled { if root == nil && m.cfg.Markdown.Enabled {

View file

@ -464,6 +464,8 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
return m, m.seek(m.voiceStatus.Position + seekStep) return m, m.seek(m.voiceStatus.Position + seekStep)
case keybind.Matches(msg, kbs.Download.Keybind): case keybind.Matches(msg, kbs.Download.Keybind):
return m, m.download() return m, m.download()
case keybind.Matches(msg, kbs.React.Keybind):
return m, m.react()
case keybind.Matches(msg, kbs.Reply.Keybind): case keybind.Matches(msg, kbs.Reply.Keybind):
return m, m.reply(false) return m, m.reply(false)
case keybind.Matches(msg, kbs.ReplyMention.Keybind): case keybind.Matches(msg, kbs.ReplyMention.Keybind):

View file

@ -12,6 +12,7 @@ import (
"github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/state" "github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/markdown"
"github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
tviewimage "github.com/ayn2op/tview/image" tviewimage "github.com/ayn2op/tview/image"
@ -401,3 +402,39 @@ 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)
}
}

View file

@ -25,6 +25,11 @@ import (
// Msg is implemented by the messages the list sends itself, which must reach it whatever has the focus. // Msg is implemented by the messages the list sends itself, which must reach it whatever has the focus.
type Msg interface{ messagesList() } type Msg interface{ messagesList() }
// ReactMsg requests opening the reaction picker for the message.
type ReactMsg struct {
Message discord.Message
}
type olderMessagesLoadedMsg struct { type olderMessagesLoadedMsg struct {
channelID discord.ChannelID channelID discord.ChannelID
older []discord.Message older []discord.Message
@ -345,6 +350,15 @@ func (m Model) reply(mention bool) tview.Cmd {
return func() tview.Msg { return ReplyMsg{Message: message, Name: name, Mention: mention} } 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 { func (m Model) editSelectedMessage() tview.Cmd {
selectedMessage, ok := m.selectedMessage() selectedMessage, ok := m.selectedMessage()
if !ok { if !ok {

View file

@ -21,7 +21,9 @@ import (
"github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker" "github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker" "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/composer"
"github.com/ayn2op/discordo/internal/ui/chat/emojipicker"
"github.com/ayn2op/discordo/internal/ui/chat/emojislist" "github.com/ayn2op/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/discordo/internal/ui/chat/guildstree" "github.com/ayn2op/discordo/internal/ui/chat/guildstree"
"github.com/ayn2op/discordo/internal/ui/chat/memberstree" "github.com/ayn2op/discordo/internal/ui/chat/memberstree"
@ -53,9 +55,11 @@ type Model struct {
membersTree memberstree.Model membersTree memberstree.Model
messagesList messageslist.Model messagesList messageslist.Model
composer composer.Model composer composer.Model
channelsPicker channelspicker.Model channelsPicker channelspicker.Model
// attachmentsPicker picks an attachment or link of the selected message. // attachmentsPicker picks an attachment or link of the selected message.
attachmentsPicker attachmentspicker.Model attachmentsPicker attachmentspicker.Model
emojiPicker emojipicker.Model
reactingMessage *discord.Message
selectedChannel *discord.Channel selectedChannel *discord.Channel
// windowUnfocused reports whether the terminal window lost focus. // windowUnfocused reports whether the terminal window lost focus.
@ -111,6 +115,7 @@ func NewModel(cfg *config.Config, token string) Model {
m.composer = composer.NewModel(cfg, m.state) m.composer = composer.NewModel(cfg, m.state)
m.channelsPicker = channelspicker.NewModel(cfg) m.channelsPicker = channelspicker.NewModel(cfg)
m.attachmentsPicker = attachmentspicker.NewModel(cfg) m.attachmentsPicker = attachmentspicker.NewModel(cfg)
m.emojiPicker = emojipicker.NewModel(cfg)
m.guildsTreeVisible = cfg.Sidebar.Visible m.guildsTreeVisible = cfg.Sidebar.Visible
m.membersTreeVisible = cfg.MembersTree.Visible m.membersTreeVisible = cfg.MembersTree.Visible
@ -130,6 +135,7 @@ const (
overlayNone overlay = iota overlayNone overlay = iota
overlayChannelsPicker overlayChannelsPicker
overlayAttachmentsPicker overlayAttachmentsPicker
overlayEmojiPicker
) )
// pickerView returns the view of the picker shown on top, or nil if none is open. // pickerView returns the view of the picker shown on top, or nil if none is open.
@ -139,6 +145,8 @@ func (m Model) pickerView() tview.Widget {
return m.channelsPicker.View() return m.channelsPicker.View()
case overlayAttachmentsPicker: case overlayAttachmentsPicker:
return m.attachmentsPicker.View() return m.attachmentsPicker.View()
case overlayEmojiPicker:
return m.emojiPicker.View()
default: default:
return nil return nil
} }
@ -244,6 +252,54 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.attachmentsPicker.SetItems(picker.Items(msg)) m.attachmentsPicker.SetItems(picker.Items(msg))
m.overlay = overlayAttachmentsPicker m.overlay = overlayAttachmentsPicker
return m, nil 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: case messageslist.ReplyMsg:
m.composer.StartReply(msg.Message, msg.Name, msg.Mention) m.composer.StartReply(msg.Message, msg.Name, msg.Mention)
m.setFocus(paneComposer) m.setFocus(paneComposer)
@ -305,7 +361,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())}, ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())},
) )
} }
case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg: case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg, commandslist.Msg:
return m, m.updatePane(paneComposer, msg) return m, m.updatePane(paneComposer, msg)
case paneMsg: case paneMsg:
if msg.focus { if msg.focus {
@ -327,6 +383,8 @@ func (m *Model) route(msg tview.Msg) tview.Cmd {
m.channelsPicker, cmd = m.channelsPicker.Update(msg) m.channelsPicker, cmd = m.channelsPicker.Update(msg)
case overlayAttachmentsPicker: case overlayAttachmentsPicker:
m.attachmentsPicker, cmd = m.attachmentsPicker.Update(msg) m.attachmentsPicker, cmd = m.attachmentsPicker.Update(msg)
case overlayEmojiPicker:
m.emojiPicker, cmd = m.emojiPicker.Update(msg)
default: default:
cmd = m.updatePane(m.focused, msg) cmd = m.updatePane(m.focused, msg)
} }

View file

@ -8,6 +8,8 @@ import (
"github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/discordo/internal/config" "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/ayn2op/tview"
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
) )
@ -99,3 +101,35 @@ 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")
}
}

View file

@ -110,6 +110,9 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
case *gateway.MessageUpdateEvent: case *gateway.MessageUpdateEvent:
return m.messagesList.UpdateMessage(event.Message) return m.messagesList.UpdateMessage(event.Message)
case *gateway.PresenceUpdateEvent: case *gateway.PresenceUpdateEvent:
if m.cfg.UI.MessagesList.ShowUserStatus {
m.messagesList.InvalidateRendered()
}
return tview.Batch(m.updatePane(paneGuildsTree, event), m.updatePane(paneMembersTree, event)) return tview.Batch(m.updatePane(paneGuildsTree, event), m.updatePane(paneMembersTree, event))
case *gateway.GuildMemberListUpdateEvent: case *gateway.GuildMemberListUpdateEvent:
return m.updatePane(paneMembersTree, event) return m.updatePane(paneMembersTree, event)