Compare commits

...

2 commits

Author SHA1 Message Date
spearzificial
9e5b7e7906 chore(dev): merge message reactions with emoji and command features 2026-10-19 14:18:22 +05:30
spearzificial
7b435ce937 feat(messageslist): add emoji reaction picker and toggle (#827) 2026-10-19 14:16:20 +05:30
12 changed files with 257 additions and 1 deletions

View file

@ -850,6 +850,12 @@ 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"`

View file

@ -108,6 +108,8 @@ 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"`
@ -275,6 +277,7 @@ 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"),

View file

@ -10,12 +10,20 @@ 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 {
@ -56,6 +64,7 @@ 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 + ":",

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 {
help = append(help, cfg.React.Keybind)
if !ui.IsMe(m.state, selectedMessage.Author.ID) {
help = append(help, cfg.Reply.Keybind)
}
@ -35,6 +36,7 @@ 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)

View file

@ -464,6 +464,8 @@ 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):

View file

@ -401,3 +401,20 @@ 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)
}
}

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.
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
@ -345,6 +350,15 @@ 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 {

View file

@ -23,6 +23,7 @@ import (
"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"
@ -54,9 +55,11 @@ 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.
@ -112,6 +115,7 @@ 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
@ -131,6 +135,7 @@ const (
overlayNone overlay = iota
overlayChannelsPicker
overlayAttachmentsPicker
overlayEmojiPicker
)
// pickerView returns the view of the picker shown on top, or nil if none is open.
@ -140,6 +145,8 @@ 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
}
@ -245,6 +252,54 @@ 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)
@ -328,6 +383,8 @@ 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)
}

View file

@ -8,6 +8,8 @@ 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"
)
@ -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")
}
}