Compare commits

..

1 commit

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

View file

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

View file

@ -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"),

View file

@ -10,21 +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 {
@ -65,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 + ":",

View file

@ -25,6 +25,7 @@ import (
"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/emojislist"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
@ -71,6 +72,7 @@ type Model struct {
sendMessageData *api.SendMessageData
memberSearchCache map[string]uint
mentionsList mentionslist.Model
emojisList emojislist.Model
lastSearch time.Time
typingUntil time.Time
@ -87,6 +89,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
sendMessageData: &api.SendMessageData{},
memberSearchCache: make(map[string]uint),
mentionsList: mentionslist.NewModel(cfg),
emojisList: emojislist.NewModel(cfg),
}
return c
}
@ -266,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() {
if m.mentionsListVisible() || m.emojisListVisible() {
return m, m.tabComplete()
}
return m, m.send()
@ -277,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() {
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() {
if m.mentionsListVisible() || m.emojisListVisible() {
return m, m.tabComplete()
}
m.insert("\t")
@ -300,6 +303,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
var cmd tview.Cmd
m.mentionsList, cmd = m.mentionsList.Update(msg)
return m, cmd
case emojislist.Msg:
var cmd tview.Cmd
m.emojisList, cmd = m.emojisList.Update(msg)
return m, cmd
}
return m, nil
}
@ -307,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.mentionsListVisible() && !m.emojisListVisible()
}
// StartEdit puts message in the composer to be edited.
@ -421,6 +428,10 @@ func isMentionChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
}
func isEmojiChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '+' || r == '-'
}
// suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user.
func (m *Model) suggestRecentAuthors(channel *discord.Channel) {
messages, err := m.state.Cabinet.Messages(channel.ID)
@ -500,12 +511,23 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
}
}
// MentionsListView places the mentions list at the bottom of the area above the composer, near the cursor, or returns nil if it is empty or the composer is not focused.
func (m Model) MentionsListView(focused bool) tview.Widget {
if !focused || !m.mentionsListVisible() {
// 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
}
return mentionsPopup{m}
if m.mentionsListVisible() {
return mentionsPopup{m}
}
if m.emojisListVisible() {
return emojisPopup{m}
}
return nil
}
// MentionsListView is an alias for AutocompleteView.
func (m Model) MentionsListView(focused bool) tview.Widget {
return m.AutocompleteView(focused)
}
// mentionsPopup places the mentions list within the area of the messages, which it is drawn over.
@ -539,6 +561,33 @@ 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 emojisPopup struct {
c Model
}
func (emojisPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
func (emojisPopup) Layout(limits layout.Limits) layout.Size {
return layout.Atomic(limits, layout.Fill, layout.Fill)
}
func (p emojisPopup) Draw(screen tview.Screen, area tview.Rectangle) {
p.c.emojisList.View().Draw(screen, p.area(area))
}
func (p emojisPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
return p.c.emojisList.View().Handle(msg, p.area(area))
}
func (p emojisPopup) area(area tview.Rectangle) tview.Rectangle {
c := p.c
size := c.emojisList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
value, cursor := c.text, c.cursor
col := uucode.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width)
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
}
func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool {
if member == nil {
return false
@ -593,10 +642,11 @@ func (m *Model) addMentionUser(user *discord.User) {
})
}
// stopTabCompletion clears the mentions list, which hides it, when it is in use.
// stopTabCompletion clears the autocomplete lists, which hides them, when they are in use.
func (m *Model) stopTabCompletion() {
if m.cfg.AutocompleteLimit > 0 {
m.mentionsList.Clear()
m.emojisList.Clear()
}
}
@ -605,6 +655,10 @@ func (m Model) mentionsListVisible() bool {
return m.mentionsList.ItemCount() != 0
}
func (m Model) emojisListVisible() bool {
return m.emojisList.ItemCount() != 0
}
func (m Model) attach(name string, reader io.Reader) {
m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
}

View file

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

View file

@ -15,7 +15,10 @@ 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/emoji"
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
"github.com/ncruces/zenity"
"github.com/sahilm/fuzzy"
"golang.design/x/clipboard"
@ -168,6 +171,16 @@ func (m *Model) send() tview.Cmd {
}
func (m *Model) tabComplete() tview.Cmd {
if m.mentionsListVisible() {
return m.completeMention()
}
if m.emojisListVisible() {
return m.completeEmoji()
}
return nil
}
func (m *Model) completeMention() tview.Cmd {
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
if r != '@' {
m.stopTabCompletion()
@ -219,12 +232,41 @@ func (m *Model) tabComplete() tview.Cmd {
return nil
}
func (m *Model) tabSuggest() tview.Cmd {
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
if r != '@' {
func (m *Model) completeEmoji() tview.Cmd {
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
if r != ':' {
m.stopTabCompletion()
return nil
}
pos := posEnd - (len(name) + 1)
insertText, ok := m.emojisList.SelectedInsertText()
if !ok {
return nil
}
m.replace(pos, posEnd, insertText+" ")
m.stopTabCompletion()
return nil
}
func (m *Model) tabSuggest() tview.Cmd {
_, mentionName, rMention := wordBeforeCursor(m.text, m.cursor, isMentionChar)
if rMention == '@' {
m.emojisList.Clear()
return m.suggestMentions(mentionName)
}
_, emojiName, rEmoji := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
if rEmoji == ':' {
m.mentionsList.Clear()
return m.suggestEmojis(emojiName)
}
m.stopTabCompletion()
return nil
}
func (m *Model) suggestMentions(name string) tview.Cmd {
channel := m.channel
if channel == nil {
return nil
@ -273,6 +315,49 @@ func (m *Model) tabSuggest() tview.Cmd {
return nil
}
func (m *Model) suggestEmojis(name string) tview.Cmd {
channel := m.channel
if channel == nil {
return nil
}
m.emojisList.Clear()
var customEmojis []discord.Emoji
if m.state != nil && m.state.EmojiState != nil {
guilds, err := m.state.EmojiState.ForGuild(channel.GuildID)
if err == nil {
for _, g := range guilds {
customEmojis = append(customEmojis, g.Emojis...)
}
} else {
slog.Debug("failed to get emojis for guild", "err", err)
}
}
limit := int(m.cfg.AutocompleteLimit)
if limit <= 0 {
limit = 20
}
results := emoji.Search(name, customEmojis, limit)
for _, item := range results {
style := tcell.StyleDefault
m.emojisList.Append(emojislist.Item{
Name: item.Name,
InsertText: item.InsertText,
DisplayText: item.Display,
Style: style,
})
}
if m.emojisList.ItemCount() == 0 {
m.stopTabCompletion()
return nil
}
m.emojisList.Rebuild()
return nil
}
// searchMember performs member discovery in a command goroutine.
// It emits a follow-up suggestion message once results are loaded.
func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {

View file

@ -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)
}

View file

@ -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{})
}

View file

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

View file

@ -0,0 +1,10 @@
package emojislist
import "github.com/ayn2op/tview"
type Item struct {
Name string
InsertText string
DisplayText string
Style tview.Style
}

View file

@ -0,0 +1,103 @@
package emojislist
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 emoji 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("Emoji", 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 emojislist
import "github.com/ayn2op/tview/viewport"
type Msg any
type listMsg int
type scrollMsg viewport.ScrollState

View file

@ -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)

View file

@ -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):

View file

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

View file

@ -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 {

View file

@ -22,7 +22,7 @@ import (
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
"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"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
@ -53,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.
@ -113,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
@ -133,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.
@ -143,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
}
@ -250,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)
@ -359,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:
case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg:
return m, m.updatePane(paneComposer, msg)
case paneMsg:
if msg.focus {
@ -381,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)
}

View file

@ -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")
}
}