diff --git a/docs/config.md b/docs/config.md index 119ba8b..884c959 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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"` diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 0c7d1ba..5c5de52 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -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"), diff --git a/internal/emoji/emoji.go b/internal/emoji/emoji.go index 025089a..716b57d 100644 --- a/internal/emoji/emoji.go +++ b/internal/emoji/emoji.go @@ -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 + ":", diff --git a/internal/ui/chat/emojipicker/model.go b/internal/ui/chat/emojipicker/model.go new file mode 100644 index 0000000..c5c349e --- /dev/null +++ b/internal/ui/chat/emojipicker/model.go @@ -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) +} diff --git a/internal/ui/chat/emojipicker/model_test.go b/internal/ui/chat/emojipicker/model_test.go new file mode 100644 index 0000000..2e6c778 --- /dev/null +++ b/internal/ui/chat/emojipicker/model_test.go @@ -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{}) +} diff --git a/internal/ui/chat/emojipicker/msg.go b/internal/ui/chat/emojipicker/msg.go new file mode 100644 index 0000000..2b5a0af --- /dev/null +++ b/internal/ui/chat/emojipicker/msg.go @@ -0,0 +1,10 @@ +package emojipicker + +import "github.com/ayn2op/arikawa/v3/discord" + +type ( + SelectedMsg struct { + Emoji discord.APIEmoji + } + CancelMsg struct{} +) diff --git a/internal/ui/chat/messageslist/keymap.go b/internal/ui/chat/messageslist/keymap.go index 2e0c0be..9cc7874 100644 --- a/internal/ui/chat/messageslist/keymap.go +++ b/internal/ui/chat/messageslist/keymap.go @@ -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) diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index 5327769..4240717 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -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): diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index 1fc5c02..e93581a 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -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) + } +} diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index 3a27371..899f9ab 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -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 { diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index 379eb69..db01e9c 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -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) } diff --git a/internal/ui/chat/model_test.go b/internal/ui/chat/model_test.go index 3f9bcb8..4db1c5d 100644 --- a/internal/ui/chat/model_test.go +++ b/internal/ui/chat/model_test.go @@ -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") + } +}