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"`
|
||||
|
||||
### keybinds.messages_list.react
|
||||
|
||||
Add an emoji reaction to the selected message.
|
||||
|
||||
Default: `"a"`
|
||||
|
||||
### keybinds.messages_list.cancel
|
||||
|
||||
Default: `"esc"`
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -10,12 +10,21 @@ 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 +65,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 + ":",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ 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"
|
||||
|
|
@ -72,7 +71,6 @@ type Model struct {
|
|||
sendMessageData *api.SendMessageData
|
||||
memberSearchCache map[string]uint
|
||||
mentionsList mentionslist.Model
|
||||
emojisList emojislist.Model
|
||||
lastSearch time.Time
|
||||
|
||||
typingUntil time.Time
|
||||
|
|
@ -89,7 +87,6 @@ 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
|
||||
}
|
||||
|
|
@ -269,7 +266,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() || m.emojisListVisible() {
|
||||
if m.mentionsListVisible() {
|
||||
return m, m.tabComplete()
|
||||
}
|
||||
return m, m.send()
|
||||
|
|
@ -280,14 +277,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() || m.emojisListVisible() {
|
||||
if m.mentionsListVisible() {
|
||||
m.stopTabCompletion()
|
||||
return m, nil
|
||||
}
|
||||
m.reset()
|
||||
return m, nil
|
||||
case keybind.Matches(msg, kbs.TabComplete.Keybind):
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
if m.mentionsListVisible() {
|
||||
return m, m.tabComplete()
|
||||
}
|
||||
m.insert("\t")
|
||||
|
|
@ -303,10 +300,6 @@ 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
|
||||
}
|
||||
|
|
@ -314,7 +307,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.emojisListVisible()
|
||||
!m.mentionsListVisible()
|
||||
}
|
||||
|
||||
// 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 == '.'
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -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.
|
||||
func (m Model) AutocompleteView(focused bool) tview.Widget {
|
||||
if !focused {
|
||||
return nil
|
||||
}
|
||||
if m.mentionsListVisible() {
|
||||
return mentionsPopup{m}
|
||||
}
|
||||
if m.emojisListVisible() {
|
||||
return emojisPopup{m}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MentionsListView is an alias for AutocompleteView.
|
||||
// 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 {
|
||||
return m.AutocompleteView(focused)
|
||||
if !focused || !m.mentionsListVisible() {
|
||||
return nil
|
||||
}
|
||||
return mentionsPopup{m}
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -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() {
|
||||
if m.cfg.AutocompleteLimit > 0 {
|
||||
m.mentionsList.Clear()
|
||||
m.emojisList.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -655,10 +605,6 @@ 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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ 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"
|
||||
|
|
@ -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/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"
|
||||
|
|
@ -171,16 +168,6 @@ 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()
|
||||
|
|
@ -232,41 +219,12 @@ func (m *Model) completeMention() tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||
if r != '@' {
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) suggestMentions(name string) tview.Cmd {
|
||||
channel := m.channel
|
||||
if channel == nil {
|
||||
return nil
|
||||
|
|
@ -315,49 +273,6 @@ func (m *Model) suggestMentions(name string) 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 {
|
||||
|
|
|
|||
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 {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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/emojislist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojipicker"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||
|
|
@ -56,6 +56,8 @@ type Model struct {
|
|||
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.
|
||||
|
|
@ -111,6 +113,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
|
||||
|
|
@ -130,6 +133,7 @@ const (
|
|||
overlayNone overlay = iota
|
||||
overlayChannelsPicker
|
||||
overlayAttachmentsPicker
|
||||
overlayEmojiPicker
|
||||
)
|
||||
|
||||
// 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()
|
||||
case overlayAttachmentsPicker:
|
||||
return m.attachmentsPicker.View()
|
||||
case overlayEmojiPicker:
|
||||
return m.emojiPicker.View()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -244,6 +250,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)
|
||||
|
|
@ -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())},
|
||||
)
|
||||
}
|
||||
case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg:
|
||||
case composer.TabSuggestMsg, mentionslist.Msg:
|
||||
return m, m.updatePane(paneComposer, msg)
|
||||
case paneMsg:
|
||||
if msg.focus {
|
||||
|
|
@ -327,6 +381,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue