Compare commits
1 commit
feat/emoji
...
feat/messa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b435ce937 |
18 changed files with 271 additions and 337 deletions
|
|
@ -850,6 +850,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"`
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,21 @@ 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 +65,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 + ":",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import (
|
||||||
"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/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"
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
|
|
@ -72,7 +71,6 @@ type Model struct {
|
||||||
sendMessageData *api.SendMessageData
|
sendMessageData *api.SendMessageData
|
||||||
memberSearchCache map[string]uint
|
memberSearchCache map[string]uint
|
||||||
mentionsList mentionslist.Model
|
mentionsList mentionslist.Model
|
||||||
emojisList emojislist.Model
|
|
||||||
lastSearch time.Time
|
lastSearch time.Time
|
||||||
|
|
||||||
typingUntil time.Time
|
typingUntil time.Time
|
||||||
|
|
@ -89,7 +87,6 @@ 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),
|
||||||
emojisList: emojislist.NewModel(cfg),
|
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +266,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() {
|
||||||
return m, m.tabComplete()
|
return m, m.tabComplete()
|
||||||
}
|
}
|
||||||
return m, m.send()
|
return m, m.send()
|
||||||
|
|
@ -280,14 +277,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.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() {
|
||||||
return m, m.tabComplete()
|
return m, m.tabComplete()
|
||||||
}
|
}
|
||||||
m.insert("\t")
|
m.insert("\t")
|
||||||
|
|
@ -303,10 +300,6 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
var cmd tview.Cmd
|
var cmd tview.Cmd
|
||||||
m.mentionsList, cmd = m.mentionsList.Update(msg)
|
m.mentionsList, cmd = m.mentionsList.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
case emojislist.Msg:
|
|
||||||
var cmd tview.Cmd
|
|
||||||
m.emojisList, cmd = m.emojisList.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +307,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartEdit puts message in the composer to be edited.
|
// StartEdit puts message in the composer to be edited.
|
||||||
|
|
@ -428,10 +421,6 @@ func isMentionChar(r rune) bool {
|
||||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
|
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.
|
// suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user.
|
||||||
func (m *Model) suggestRecentAuthors(channel *discord.Channel) {
|
func (m *Model) suggestRecentAuthors(channel *discord.Channel) {
|
||||||
messages, err := m.state.Cabinet.Messages(channel.ID)
|
messages, err := m.state.Cabinet.Messages(channel.ID)
|
||||||
|
|
@ -511,23 +500,12 @@ 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.
|
// 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) AutocompleteView(focused bool) tview.Widget {
|
func (m Model) MentionsListView(focused bool) tview.Widget {
|
||||||
if !focused {
|
if !focused || !m.mentionsListVisible() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if m.mentionsListVisible() {
|
return mentionsPopup{m}
|
||||||
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.
|
// mentionsPopup places the mentions list within the area of the messages, which it is drawn over.
|
||||||
|
|
@ -561,33 +539,6 @@ func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
|
||||||
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
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 {
|
func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool {
|
||||||
if member == nil {
|
if member == nil {
|
||||||
return false
|
return false
|
||||||
|
|
@ -642,11 +593,10 @@ func (m *Model) addMentionUser(user *discord.User) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopTabCompletion clears the autocomplete lists, which hides them, when they are in use.
|
// stopTabCompletion clears the mentions list, which hides it, when it is in use.
|
||||||
func (m *Model) stopTabCompletion() {
|
func (m *Model) stopTabCompletion() {
|
||||||
if m.cfg.AutocompleteLimit > 0 {
|
if m.cfg.AutocompleteLimit > 0 {
|
||||||
m.mentionsList.Clear()
|
m.mentionsList.Clear()
|
||||||
m.emojisList.Clear()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,10 +605,6 @@ func (m Model) mentionsListVisible() bool {
|
||||||
return m.mentionsList.ItemCount() != 0
|
return m.mentionsList.ItemCount() != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) emojisListVisible() bool {
|
|
||||||
return m.emojisList.ItemCount() != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) attach(name string, reader io.Reader) {
|
func (m Model) attach(name string, reader io.Reader) {
|
||||||
m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
|
m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||||
"github.com/ayn2op/discordo/internal/config"
|
"github.com/ayn2op/discordo/internal/config"
|
||||||
"github.com/ayn2op/ningen/v3"
|
"github.com/ayn2op/ningen/v3"
|
||||||
ningenEmoji "github.com/ayn2op/ningen/v3/states/emoji"
|
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
"github.com/ayn2op/tview/keybind"
|
"github.com/ayn2op/tview/keybind"
|
||||||
"github.com/gdamore/tcell/v3"
|
"github.com/gdamore/tcell/v3"
|
||||||
|
|
@ -201,63 +200,3 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,7 @@ 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/emoji"
|
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
"github.com/gdamore/tcell/v3"
|
|
||||||
"github.com/ncruces/zenity"
|
"github.com/ncruces/zenity"
|
||||||
"github.com/sahilm/fuzzy"
|
"github.com/sahilm/fuzzy"
|
||||||
"golang.design/x/clipboard"
|
"golang.design/x/clipboard"
|
||||||
|
|
@ -171,16 +168,6 @@ func (m *Model) send() tview.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) tabComplete() 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)
|
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||||
if r != '@' {
|
if r != '@' {
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
|
|
@ -232,41 +219,12 @@ func (m *Model) completeMention() tview.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) completeEmoji() tview.Cmd {
|
func (m *Model) tabSuggest() tview.Cmd {
|
||||||
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
|
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||||
if r != ':' {
|
if r != '@' {
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
return nil
|
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
|
channel := m.channel
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -315,49 +273,6 @@ func (m *Model) suggestMentions(name string) tview.Cmd {
|
||||||
return nil
|
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.
|
// searchMember performs member discovery in a command goroutine.
|
||||||
// It emits a follow-up suggestion message once results are loaded.
|
// It emits a follow-up suggestion message once results are loaded.
|
||||||
func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
|
func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
|
||||||
|
|
|
||||||
63
internal/ui/chat/emojipicker/model.go
Normal file
63
internal/ui/chat/emojipicker/model.go
Normal 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)
|
||||||
|
}
|
||||||
39
internal/ui/chat/emojipicker/model_test.go
Normal file
39
internal/ui/chat/emojipicker/model_test.go
Normal 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{})
|
||||||
|
}
|
||||||
10
internal/ui/chat/emojipicker/msg.go
Normal file
10
internal/ui/chat/emojipicker/msg.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
package emojipicker
|
||||||
|
|
||||||
|
import "github.com/ayn2op/arikawa/v3/discord"
|
||||||
|
|
||||||
|
type (
|
||||||
|
SelectedMsg struct {
|
||||||
|
Emoji discord.APIEmoji
|
||||||
|
}
|
||||||
|
CancelMsg struct{}
|
||||||
|
)
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
package emojislist
|
|
||||||
|
|
||||||
import "github.com/ayn2op/tview"
|
|
||||||
|
|
||||||
type Item struct {
|
|
||||||
Name string
|
|
||||||
InsertText string
|
|
||||||
DisplayText string
|
|
||||||
Style tview.Style
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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{}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package emojislist
|
|
||||||
|
|
||||||
import "github.com/ayn2op/tview/viewport"
|
|
||||||
|
|
||||||
type Msg any
|
|
||||||
|
|
||||||
type listMsg int
|
|
||||||
|
|
||||||
type scrollMsg viewport.ScrollState
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import (
|
||||||
"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/composer"
|
"github.com/ayn2op/discordo/internal/ui/chat/composer"
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
"github.com/ayn2op/discordo/internal/ui/chat/emojipicker"
|
||||||
"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"
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||||
|
|
@ -53,9 +53,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 +113,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 +133,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 +143,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 +250,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 +359,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:
|
||||||
return m, m.updatePane(paneComposer, msg)
|
return m, m.updatePane(paneComposer, msg)
|
||||||
case paneMsg:
|
case paneMsg:
|
||||||
if msg.focus {
|
if msg.focus {
|
||||||
|
|
@ -327,6 +381,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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue