discordo/internal/ui/chat/composer/msg.go

349 lines
8.6 KiB
Go

package composer
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/ayn2op/arikawa/v3/api"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/utils/json/option"
"github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/textarea"
"github.com/ncruces/zenity"
"github.com/sahilm/fuzzy"
"golang.design/x/clipboard"
)
// TabSuggestMsg suggests mentions for the word before the cursor.
type TabSuggestMsg struct{}
// EditLastMsg asks to edit the user's last message.
type EditLastMsg struct{}
// SentMsg reports that the composer sent a message.
type SentMsg struct{}
type editorMsg string
// editMsg edits the composer's text.
type editMsg textarea.Change
func (c *Model) editLastMessage() tview.Cmd {
return func() tview.Msg { return EditLastMsg{} }
}
type imagePastedMsg []byte
func pasteImage() tview.Cmd {
return func() tview.Msg {
data, err := clipboard.Read(context.Background(), clipboard.FmtImage)
if err != nil {
slog.Error("failed to read from clipboard", "err", err)
return nil
}
return imagePastedMsg(data)
}
}
type filesPickedMsg struct {
channelID discord.ChannelID
files []sendpart.File
}
func (c *Model) pickFiles() tview.Cmd {
selectedChannel := c.channel
if selectedChannel == nil {
return nil
}
channelID := selectedChannel.ID
return func() tview.Msg {
paths, err := zenity.SelectFileMultiple()
if err != nil {
slog.Error("failed to open file dialog", "err", err)
return nil
}
files := make([]sendpart.File, 0, len(paths))
for _, path := range paths {
file, err := os.Open(path)
if err != nil {
slog.Error("failed to open file", "path", path, "err", err)
continue
}
files = append(files, sendpart.File{Name: filepath.Base(path), Reader: file})
}
if len(files) == 0 {
return nil
}
return filesPickedMsg{channelID: channelID, files: files}
}
}
func closeFiles(files []sendpart.File) tview.Cmd {
return func() tview.Msg {
for _, file := range files {
if closer, ok := file.Reader.(io.Closer); ok {
closer.Close()
}
}
return nil
}
}
func (c *Model) sendTyping() tview.Cmd {
if !c.cfg.TypingIndicator.Send {
return nil
}
now := time.Now()
if now.Before(c.typingUntil) {
return nil
}
c.typingUntil = now.Add(TypingDuration)
selectedChannel := c.channel
if selectedChannel == nil {
return nil
}
channelID := selectedChannel.ID
return func() tview.Msg {
c.state.Typing(channelID)
return nil
}
}
func (c *Model) send() tview.Cmd {
selectedChannel := c.channel
if selectedChannel == nil {
return nil
}
text := strings.TrimSpace(c.editState.Value())
if text == "" && len(c.sendMessageData.Files) == 0 {
return nil
}
text = c.processText(selectedChannel, []byte(text))
data := *c.sendMessageData
data.Files = slices.Clone(data.Files)
editing := c.editing
c.typingUntil = time.Time{}
c.reset()
sent := func() tview.Msg { return SentMsg{} }
return tview.Batch(sent, func() tview.Msg {
defer closeFiles(data.Files)()
if editing != nil {
editData := api.EditMessageData{Content: option.SomeNullable(text)}
if _, err := c.state.EditMessageComplex(editing.ChannelID, editing.ID, editData); err != nil {
slog.Error("failed to edit message", "err", err)
}
return nil
}
data.Content = text
if _, err := c.state.SendMessageComplex(selectedChannel.ID, data); err != nil {
slog.Error("failed to send message in channel", "channel_id", selectedChannel.ID, "err", err)
}
return nil
})
}
func (c *Model) tabComplete() tview.Cmd {
posEnd, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar)
if r != '@' {
c.stopTabCompletion()
return nil
}
pos := posEnd - (len(name) + 1)
selectedChannel := c.channel
if selectedChannel == nil {
return nil
}
gID := selectedChannel.GuildID
if c.cfg.AutocompleteLimit == 0 {
if !gID.IsValid() {
users := selectedChannel.DMRecipients
res := fuzzy.FindFrom(name, userList(users))
if len(res) > 0 {
c.editState.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ")
}
} else {
cmd := c.searchMember(gID, name)
members, err := c.state.Cabinet.Members(gID)
if err != nil {
slog.Error("failed to get members from state", "guild_id", gID, "err", err)
return cmd
}
res := fuzzy.FindFrom(name, memberList(members))
for _, r := range res {
if channelHasUser(c.state, selectedChannel.ID, members[r.Index].User.ID) {
c.editState.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
return cmd
}
}
return cmd
}
return nil
}
if c.mentionsList.ItemCount() == 0 {
return nil
}
name, ok := c.mentionsList.SelectedInsertText()
if !ok {
return nil
}
c.editState.Replace(pos, posEnd, "@"+name+" ")
c.stopTabCompletion()
return nil
}
func (c *Model) tabSuggest() tview.Cmd {
_, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar)
if r != '@' {
c.stopTabCompletion()
return nil
}
channel := c.channel
if channel == nil {
return nil
}
c.mentionsList.Clear()
gID := channel.GuildID
switch {
case name == "":
c.suggestRecentAuthors(channel)
case !gID.IsValid():
// DMs have recipients, not members.
me, _ := c.state.Cabinet.Me()
users := append(slices.Clone(channel.DMRecipients), *me)
for _, r := range fuzzy.FindFrom(name, userList(users)) {
c.addMentionUser(&users[r.Index])
}
default:
searchCmd := c.searchMember(gID, name)
mems, err := c.state.Cabinet.Members(gID)
if err != nil {
slog.Error("fetching members failed", "err", err)
return searchCmd
}
res := fuzzy.FindFrom(name, memberList(mems))
if len(res) > int(c.cfg.AutocompleteLimit) {
res = res[:int(c.cfg.AutocompleteLimit)]
}
for _, r := range res {
if channelHasUser(c.state, channel.ID, mems[r.Index].User.ID) &&
c.addMentionMember(gID, &mems[r.Index]) {
break
}
}
if c.mentionsList.ItemCount() == 0 {
c.stopTabCompletion()
return searchCmd
}
}
if c.mentionsList.ItemCount() == 0 {
c.stopTabCompletion()
return nil
}
c.mentionsList.Rebuild()
c.mentionsVisible = true
return nil
}
// searchMember performs member discovery in a command goroutine.
// It emits a follow-up suggestion message once results are loaded.
func (c *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
if name == "" {
return nil
}
key := gID.String() + " " + name
if _, ok := c.memberSearchCache[key]; ok {
return nil
}
// If searching for "ab" returns less than SearchLimit, then "abc" would not return anything new because we already searched everything starting with "ab".
// This will still be true even if a new member joins because arikawa loads new members into the state.
if count, ok := c.memberSearchCache[key[:len(key)-1]]; ok {
if count < c.state.MemberState.SearchLimit {
c.memberSearchCache[key] = count
return nil
}
}
now := time.Now()
// Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect
if c.lastSearch.Add(c.state.MemberState.SearchFrequency).After(now) {
return nil
}
c.lastSearch = now
nonce := memberSearchNonce + key
return func() tview.Msg {
if err := c.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{
GuildIDs: []discord.GuildID{gID},
Query: option.Some(name),
Presences: c.state.MemberState.RequestPresences,
Limit: c.state.MemberState.SearchLimit,
Nonce: nonce,
}); err != nil {
slog.Error("failed to search guild members", "err", err, "guild_id", gID, "query", name)
}
return nil
}
}
func (c *Model) openEditor() tview.Cmd {
if c.cfg.Editor == "" {
return func() tview.Msg {
slog.Warn("Attempt to open file with editor, but no editor is set")
return nil
}
}
text := c.editState.Value()
cfg := c.cfg
return tview.Suspend(func() tview.Msg {
file, err := os.CreateTemp("", tmpFilePattern)
if err != nil {
slog.Error("failed to create tmp file", "err", err)
return nil
}
name := file.Name()
defer os.Remove(name)
_, _ = file.WriteString(text)
_ = file.Close()
cmd, err := cfg.EditorCommand(name)
if err != nil {
slog.Error("failed to create editor command", "err", err)
return nil
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
slog.Error("failed to run command", "args", cmd.Args, "err", err)
return nil
}
msg, err := os.ReadFile(name)
if err != nil {
slog.Error("failed to read tmp file", "name", name, "err", err)
return nil
}
return editorMsg(strings.TrimSpace(string(msg)))
})
}