Compare commits
1 commit
feat/emoji
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68958f75c |
15 changed files with 63 additions and 2417 deletions
|
|
@ -367,6 +367,12 @@ The number of empty rows between messages, except those of a group.
|
|||
|
||||
Default: `1`
|
||||
|
||||
### ui.messages_list.show_user_status
|
||||
|
||||
Whether to show the author's presence status dot alongside their name.
|
||||
|
||||
Default: `false`
|
||||
|
||||
### ui.messages_list.reply_indicator
|
||||
|
||||
Default: `">"`
|
||||
|
|
|
|||
|
|
@ -174,6 +174,9 @@ type (
|
|||
// The number of empty rows between messages, except those of a group.
|
||||
Spacing uint8 `toml:"spacing"`
|
||||
|
||||
// Whether to show the author's presence status dot alongside their name.
|
||||
ShowUserStatus bool `toml:"show_user_status"`
|
||||
|
||||
ReplyIndicator string `toml:"reply_indicator"`
|
||||
ForwardedIndicator string `toml:"forwarded_indicator"`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
package emoji
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
Name string
|
||||
InsertText string
|
||||
Display string
|
||||
Custom bool
|
||||
}
|
||||
|
||||
type itemList []Item
|
||||
|
||||
func (l itemList) String(i int) string {
|
||||
return l[i].Name
|
||||
}
|
||||
|
||||
func (l itemList) Len() int {
|
||||
return len(l)
|
||||
}
|
||||
|
||||
var unicodeList = sync.OnceValue(func() []Item {
|
||||
items := make([]Item, 0, len(Unicode))
|
||||
for name, glyph := range Unicode {
|
||||
items = append(items, Item{
|
||||
Name: name,
|
||||
InsertText: glyph,
|
||||
Display: ":" + name + ": " + glyph,
|
||||
Custom: false,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(items, func(a, b Item) int {
|
||||
if a.Name < b.Name {
|
||||
return -1
|
||||
} else if a.Name > b.Name {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
// Search searches custom emojis and standard Unicode emojis matching the query.
|
||||
func Search(query string, custom []discord.Emoji, limit int) []Item {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
customItems := make([]Item, 0, len(custom))
|
||||
for _, e := range custom {
|
||||
customItems = append(customItems, Item{
|
||||
Name: e.Name,
|
||||
InsertText: e.String(),
|
||||
Display: ":" + e.Name + ":",
|
||||
Custom: true,
|
||||
})
|
||||
}
|
||||
|
||||
uItems := unicodeList()
|
||||
|
||||
if query == "" {
|
||||
results := make([]Item, 0, limit)
|
||||
results = append(results, customItems...)
|
||||
if len(results) < limit {
|
||||
needed := limit - len(results)
|
||||
if needed > len(uItems) {
|
||||
needed = len(uItems)
|
||||
}
|
||||
results = append(results, uItems[:needed]...)
|
||||
}
|
||||
if len(results) > limit {
|
||||
return results[:limit]
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Exact prefix matches on custom emojis get highest priority
|
||||
var results []Item
|
||||
lowerQuery := strings.ToLower(query)
|
||||
|
||||
for _, item := range customItems {
|
||||
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy search across remaining custom emojis
|
||||
if len(customItems) > 0 {
|
||||
customMatches := fuzzy.FindFrom(query, itemList(customItems))
|
||||
for _, m := range customMatches {
|
||||
item := customItems[m.Index]
|
||||
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && it.Custom }) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact prefix matches on unicode emojis
|
||||
for _, item := range uItems {
|
||||
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy search across unicode emojis
|
||||
uMatches := fuzzy.FindFrom(query, itemList(uItems))
|
||||
for _, m := range uMatches {
|
||||
item := uItems[m.Index]
|
||||
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && !it.Custom }) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package emoji
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
)
|
||||
|
||||
func TestSearchUnicode(t *testing.T) {
|
||||
results := Search("smile", nil, 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for 'smile', got none")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range results {
|
||||
if item.Name == "smile" && item.InsertText == "😄" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected smile -> 😄 in results, got %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCustomEmojis(t *testing.T) {
|
||||
custom := []discord.Emoji{
|
||||
{
|
||||
ID: 123456789,
|
||||
Name: "custom_cat",
|
||||
},
|
||||
{
|
||||
ID: 987654321,
|
||||
Name: "pepe_dance",
|
||||
Animated: true,
|
||||
},
|
||||
}
|
||||
|
||||
results := Search("cat", custom, 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for 'cat', got none")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range results {
|
||||
if item.Name == "custom_cat" {
|
||||
found = true
|
||||
if item.InsertText != "<:custom_cat:123456789>" {
|
||||
t.Errorf("expected custom format, got %s", item.InsertText)
|
||||
}
|
||||
if !item.Custom {
|
||||
t.Errorf("expected Custom=true")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected custom_cat in results, got %+v", results)
|
||||
}
|
||||
|
||||
// Test animated emoji
|
||||
animResults := Search("dance", custom, 5)
|
||||
if len(animResults) == 0 {
|
||||
t.Fatal("expected results for 'dance', got none")
|
||||
}
|
||||
if animResults[0].InsertText != "<a:pepe_dance:987654321>" {
|
||||
t.Errorf("expected animated format, got %s", animResults[0].InsertText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEmptyQuery(t *testing.T) {
|
||||
custom := []discord.Emoji{
|
||||
{
|
||||
ID: 111,
|
||||
Name: "alpha",
|
||||
},
|
||||
}
|
||||
results := Search("", custom, 10)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for empty query, got none")
|
||||
}
|
||||
// Custom emojis should be listed first
|
||||
if results[0].Name != "alpha" {
|
||||
t.Errorf("expected custom emoji first for empty query, got %s", results[0].Name)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
// 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() {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
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 != ':' {
|
||||
func (m *Model) tabSuggest() tview.Cmd {
|
||||
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||
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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -175,7 +175,14 @@ func (m Model) drawCozy(builder *markdown.Builder, message discord.Message, base
|
|||
}
|
||||
}
|
||||
|
||||
const statusMarker = "●"
|
||||
|
||||
func (m Model) drawAuthor(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) {
|
||||
if m.cfg.UI.MessagesList.ShowUserStatus && !message.WebhookID.IsValid() {
|
||||
status := m.status(message.GuildID, message.Author.ID)
|
||||
builder.Write(statusMarker+" ", m.cfg.Theme.MembersTree.StatusStyle(status))
|
||||
}
|
||||
|
||||
name := message.Author.DisplayOrUsername()
|
||||
foreground := tcell.ColorDefault
|
||||
|
||||
|
|
@ -211,6 +218,17 @@ func (m Model) memberForMessage(message discord.Message) *discord.Member {
|
|||
return member
|
||||
}
|
||||
|
||||
func (m Model) status(guildID discord.GuildID, userID discord.UserID) discord.Status {
|
||||
if m.state == nil || m.state.Cabinet == nil {
|
||||
return discord.OfflineStatus
|
||||
}
|
||||
presence, err := m.state.Cabinet.Presence(guildID, userID)
|
||||
if err != nil {
|
||||
return discord.OfflineStatus
|
||||
}
|
||||
return presence.Status
|
||||
}
|
||||
|
||||
// drawContent renders the message body. root is its parsed content, or nil to parse it here.
|
||||
func (m Model) drawContent(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style) {
|
||||
if root == nil && m.cfg.Markdown.Enabled {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/markdown"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
tviewimage "github.com/ayn2op/tview/image"
|
||||
|
|
@ -401,3 +402,22 @@ func TestModelReactionsView(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelShowUserStatus(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
cfg.UI.MessagesList.ShowUserStatus = true
|
||||
ml := newTestModel(t, cfg)
|
||||
user := discord.User{ID: 10, Username: "alice"}
|
||||
_ = ml.state.PresenceSet(discord.NullGuildID, &discord.Presence{User: user, Status: discord.OnlineStatus}, false)
|
||||
|
||||
msg := discord.Message{ID: 1, Author: user, Content: "hello"}
|
||||
builder := new(markdown.Builder)
|
||||
ml.drawAuthor(builder, msg, tcell.StyleDefault)
|
||||
spans := builder.Finish()
|
||||
if len(spans) == 0 {
|
||||
t.Fatal("expected spans from drawAuthor")
|
||||
}
|
||||
if !strings.HasPrefix(spans[0].Text, statusMarker) {
|
||||
t.Fatalf("first span text = %q, want statusMarker %q", spans[0].Text, statusMarker)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ 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/guildstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||
|
|
@ -305,7 +304,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 {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
|
|||
case *gateway.MessageUpdateEvent:
|
||||
return m.messagesList.UpdateMessage(event.Message)
|
||||
case *gateway.PresenceUpdateEvent:
|
||||
if m.cfg.UI.MessagesList.ShowUserStatus {
|
||||
m.messagesList.InvalidateRendered()
|
||||
}
|
||||
return tview.Batch(m.updatePane(paneGuildsTree, event), m.updatePane(paneMembersTree, event))
|
||||
case *gateway.GuildMemberListUpdateEvent:
|
||||
return m.updatePane(paneMembersTree, event)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue