package chat import ( "bytes" "io" "log/slog" "os" "path/filepath" "regexp" "slices" "strings" "sync" "time" "unicode" "github.com/ayn2op/discordo/internal/cache" "github.com/ayn2op/discordo/internal/clipboard" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" "github.com/ayn2op/tview/help" "github.com/ayn2op/tview/keybind" "github.com/diamondburned/arikawa/v3/api" "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/arikawa/v3/state" "github.com/diamondburned/arikawa/v3/utils/json/option" "github.com/diamondburned/arikawa/v3/utils/sendpart" "github.com/diamondburned/ningen/v3" "github.com/diamondburned/ningen/v3/discordmd" "github.com/gdamore/tcell/v3" "github.com/ncruces/zenity" "github.com/sahilm/fuzzy" "github.com/yuin/goldmark/ast" ) const tmpFilePattern = consts.Name + "_*.md" var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") type messageInput struct { *tview.TextArea chat *Model cfg *config.Config edit bool sendMessageData *api.SendMessageData cache *cache.Cache mentionsList *mentionsList lastSearch time.Time lastHeight int typingTimerMu sync.Mutex typingTimer *time.Timer } var _ help.KeyMap = (*messageInput)(nil) func newMessageInput(cfg *config.Config, chatView *Model) *messageInput { mi := &messageInput{ TextArea: tview.NewTextArea(), cfg: cfg, chat: chatView, sendMessageData: &api.SendMessageData{}, cache: cache.NewCache(), mentionsList: newMentionsList(cfg), } mi.Box = ui.ConfigureBox(mi.Box, &cfg.Theme) mi. SetPlaceholder(tview.NewLine(tview.NewSegment("Select a channel to start chatting", tcell.StyleDefault.Dim(true)))). SetClipboard( func(s string) { if err := clipboard.Write(clipboard.FmtText, []byte(s)); err != nil { slog.Error("failed to write clipboard text", "err", err) } }, func() string { data, err := clipboard.Read(clipboard.FmtText) if err != nil { slog.Error("failed to read clipboard text", "err", err) return "" } return string(data) }, ). SetDisabled(true) return mi } func (mi *messageInput) reset() { mi.edit = false mi.sendMessageData = &api.SendMessageData{} mi.SetTitle("") mi.SetFooter("") mi.SetText("", true) mi.lastHeight = minInputHeight mi.chat.rightFlex.ResizeItem(mi, minInputHeight, 1) } func (mi *messageInput) stopTypingTimer() { if mi.typingTimer != nil { mi.typingTimer.Stop() mi.typingTimer = nil } } func (mi *messageInput) HandleEvent(event tview.Event) tview.Command { handler := mi.TextArea.HandleEvent switch event := event.(type) { case *tview.KeyEvent: switch { case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Paste.Keybind): mi.paste() return handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)) case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Send.Keybind): if mi.chat.GetVisible(mentionsListLayerName) { mi.tabComplete() } else { mi.send() } return nil case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenEditor.Keybind): var cmds []tview.Command mi.stopTabCompletion(func(next tview.Command) { if next != nil { cmds = append(cmds, next) } }) mi.editor() return tview.Batch(cmds...) case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenFilePicker.Keybind): var cmds []tview.Command mi.stopTabCompletion(func(next tview.Command) { if next != nil { cmds = append(cmds, next) } }) mi.openFilePicker() return tview.Batch(cmds...) case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind): var cmds []tview.Command if mi.chat.GetVisible(mentionsListLayerName) { mi.stopTabCompletion(func(next tview.Command) { if next != nil { cmds = append(cmds, next) } }) } else { mi.reset() cmds = append(cmds, tview.SetFocus(mi.chat.messagesList)) } return tview.Batch(cmds...) case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind): go mi.chat.app.QueueUpdateDraw(func() { mi.tabComplete() }) return nil case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Undo.Keybind): return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone)) } if mi.cfg.TypingIndicator.Send && mi.typingTimer == nil { mi.typingTimer = time.AfterFunc(typingDuration, func() { mi.typingTimerMu.Lock() mi.typingTimer = nil mi.typingTimerMu.Unlock() }) if selectedChannel := mi.chat.SelectedChannel(); selectedChannel != nil { go mi.chat.state.Typing(selectedChannel.ID) } } if mi.cfg.AutocompleteLimit > 0 { if mi.chat.GetVisible(mentionsListLayerName) { return mi.mentionsList.HandleEvent(event) } go mi.chat.app.QueueUpdateDraw(func() { mi.tabSuggestion() }) } } cmd := handler(event) mi.updateHeight() return cmd } const ( minInputHeight = 3 maxInputHeight = 8 ) func (mi *messageInput) updateHeight() { text := mi.GetText() lines := strings.Count(text, "\n") + 1 height := max(min(lines, maxInputHeight), minInputHeight) if height != mi.lastHeight { mi.lastHeight = height mi.chat.rightFlex.ResizeItem(mi, height, 1) } } func (mi *messageInput) paste() { data, err := clipboard.Read(clipboard.FmtImage) if err != nil { slog.Error("failed to read clipboard image", "err", err) return } if data != nil { name := "clipboard.png" mi.attach(name, bytes.NewReader(data)) } } func (mi *messageInput) send() { selected := mi.chat.SelectedChannel() if selected == nil { return } text := strings.TrimSpace(mi.GetText()) if text == "" && len(mi.sendMessageData.Files) == 0 { return } // Close attached files on return defer func() { for _, file := range mi.sendMessageData.Files { if closer, ok := file.Reader.(io.Closer); ok { closer.Close() } } }() text = mi.processText(selected, []byte(text)) if mi.edit { m, err := mi.chat.messagesList.selectedMessage() if err != nil { slog.Error("failed to get selected message", "err", err) return } data := api.EditMessageData{Content: option.NewNullableString(text)} if _, err := mi.chat.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil { slog.Error("failed to edit message", "err", err) } mi.edit = false } else { data := mi.sendMessageData data.Content = text if _, err := mi.chat.state.SendMessageComplex(selected.ID, *data); err != nil { slog.Error("failed to send message in channel", "channel_id", selected.ID, "err", err) } } if mi.typingTimer != nil { mi.typingTimer.Stop() mi.typingTimer = nil } mi.reset() mi.chat.messagesList.clearSelection() mi.chat.messagesList.ScrollBottom() } func (mi *messageInput) processText(channel *discord.Channel, src []byte) string { // Fast path: no mentions to expand. if bytes.IndexByte(src, '@') == -1 { return string(src) } // Fast path: no back ticks (code blocks), so expand mentions directly. if bytes.IndexByte(src, '`') == -1 { return string(mi.expandMentions(channel, src)) } var ( ranges [][2]int canMention = true ) ast.Walk(discordmd.Parse(src), func(node ast.Node, enter bool) (ast.WalkStatus, error) { switch node := node.(type) { case *ast.CodeBlock, *ast.FencedCodeBlock: canMention = !enter case *discordmd.Inline: if (node.Attr & discordmd.AttrMonospace) != 0 { canMention = !enter } case *ast.Text: if canMention { ranges = append(ranges, [2]int{node.Segment.Start, node.Segment.Stop}) } } return ast.WalkContinue, nil }) for _, rng := range ranges { src = slices.Replace(src, rng[0], rng[1], mi.expandMentions(channel, src[rng[0]:rng[1]])...) } return string(src) } func (mi *messageInput) expandMentions(c *discord.Channel, src []byte) []byte { state := mi.chat.state return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte { output := input name := string(input[1:]) if c.Type == discord.DirectMessage || c.Type == discord.GroupDM { for _, user := range c.DMRecipients { if strings.EqualFold(user.Username, name) { return []byte(user.ID.Mention()) } } // self ping me, _ := state.Cabinet.Me() if me != nil && strings.EqualFold(me.Username, name) { return []byte(me.ID.Mention()) } return output } state.MemberStore.Each(c.GuildID, func(m *discord.Member) bool { if strings.EqualFold(m.User.Username, name) { if channelHasUser(state, c.ID, m.User.ID) { output = []byte(m.User.ID.Mention()) } return true } return false }) return output }) } func (mi *messageInput) tabComplete() { posEnd, name, r := mi.GetWordUnderCursor(func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' }) if r != '@' { mi.stopTabCompletion(nil) return } pos := posEnd - (len(name) + 1) selected := mi.chat.SelectedChannel() if selected == nil { return } gID := selected.GuildID if mi.cfg.AutocompleteLimit == 0 { if !gID.IsValid() { users := selected.DMRecipients res := fuzzy.FindFrom(name, userList(users)) if len(res) > 0 { mi.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") } } else { mi.searchMember(gID, name) members, err := mi.chat.state.Cabinet.Members(gID) if err != nil { slog.Error("failed to get members from state", "guild_id", gID, "err", err) return } res := fuzzy.FindFrom(name, memberList(members)) for _, r := range res { if channelHasUser(mi.chat.state, selected.ID, members[r.Index].User.ID) { mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") return } } } return } if mi.mentionsList.itemCount() == 0 { return } name, ok := mi.mentionsList.selectedInsertText() if !ok { return } mi.Replace(pos, posEnd, "@"+name+" ") mi.stopTabCompletion(nil) } func (mi *messageInput) tabSuggestion() { _, name, r := mi.GetWordUnderCursor(func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' }) if r != '@' { mi.stopTabCompletion(nil) return } selected := mi.chat.SelectedChannel() if selected == nil { return } gID := selected.GuildID cID := selected.ID mi.mentionsList.clear() var shown map[string]struct{} var userDone struct{} if name == "" { shown = make(map[string]struct{}) // Don't show @me in the list of recent authors me, _ := mi.chat.state.Cabinet.Me() if me != nil { shown[me.Username] = userDone } } // DMs have recipients, not members if !gID.IsValid() { if name == "" { // show recent messages' authors msgs, err := mi.chat.state.Cabinet.Messages(cID) if err != nil { return } for _, m := range msgs { if _, ok := shown[m.Author.Username]; ok { continue } shown[m.Author.Username] = userDone mi.addMentionUser(&m.Author) } } else { users := selected.DMRecipients me, _ := mi.chat.state.Cabinet.Me() if me != nil { users = append(users, *me) } res := fuzzy.FindFrom(name, userList(users)) for _, r := range res { mi.addMentionUser(&users[r.Index]) } } } else if name == "" { // show recent messages' authors msgs, err := mi.chat.state.Cabinet.Messages(cID) if err != nil { return } for _, m := range msgs { if _, ok := shown[m.Author.Username]; ok { continue } shown[m.Author.Username] = userDone mi.chat.state.MemberState.RequestMember(gID, m.Author.ID) if mem, err := mi.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil { if mi.addMentionMember(gID, mem) { break } } } } else { mi.searchMember(gID, name) mems, err := mi.chat.state.Cabinet.Members(gID) if err != nil { slog.Error("fetching members failed", "err", err) return } res := fuzzy.FindFrom(name, memberList(mems)) if len(res) > int(mi.cfg.AutocompleteLimit) { res = res[:int(mi.cfg.AutocompleteLimit)] } for _, r := range res { if channelHasUser(mi.chat.state, cID, mems[r.Index].User.ID) && mi.addMentionMember(gID, &mems[r.Index]) { break } } } if mi.mentionsList.itemCount() == 0 { mi.stopTabCompletion(nil) return } mi.mentionsList.rebuild() mi.showMentionList() } type memberList []discord.Member type userList []discord.User func (ml memberList) String(i int) string { return ml[i].Nick + ml[i].User.DisplayName + ml[i].User.Tag() } func (ml memberList) Len() int { return len(ml) } func (ul userList) String(i int) string { return ul[i].DisplayName + ul[i].Tag() } func (ul userList) Len() int { return len(ul) } // channelHasUser checks if a user has permission to view the specified channel. func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID discord.UserID) bool { perms, err := state.Permissions(channelID, userID) if err != nil { slog.Error("failed to get permissions", "err", err, "channel", channelID, "user", userID) return false } return perms.Has(discord.PermissionViewChannel) } func (mi *messageInput) searchMember(gID discord.GuildID, name string) { key := gID.String() + " " + name if mi.cache.Exists(key) { return } // 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 k := key[:len(key)-1]; mi.cache.Exists(k) { if c := mi.cache.Get(k); c < mi.chat.state.MemberState.SearchLimit { mi.cache.Create(key, c) return } } // 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 mi.lastSearch.Add(mi.chat.state.MemberState.SearchFrequency).After(time.Now()) { return } mi.lastSearch = time.Now() mi.chat.messagesList.waitForChunkEvent() mi.chat.messagesList.setFetchingChunk(true, 0) mi.chat.state.MemberState.SearchMember(gID, name) mi.cache.Create(key, mi.chat.messagesList.waitForChunkEvent()) } func (mi *messageInput) showMentionList() { borders := 0 if mi.cfg.Theme.Border.Enabled { borders = 1 } l := mi.mentionsList x, _, _, _ := mi.InnerRect() _, y, _, _ := mi.Rect() _, _, maxW, maxH := mi.chat.messagesList.InnerRect() if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 { maxH = min(maxH, t) } count := mi.mentionsList.itemCount() + borders h := min(count, maxH) + borders + mi.cfg.Theme.Border.Padding[1] y -= h w := int(mi.cfg.Theme.MentionsList.MinWidth) if w == 0 { w = maxW } else { w = max(w, mi.mentionsList.maxDisplayWidth()) w = min(w+borders*2, maxW) _, col, _, _ := mi.GetCursor() x += min(col, maxW-w) } l.SetRect(x, y, w, h) mi.chat.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName) mi.chat.app.SetFocus(mi) } func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool { if m == nil { return false } name := m.User.DisplayOrUsername() if m.Nick != "" { name = m.Nick } style := tcell.StyleDefault // This avoids a slower member color lookup path. color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role { r, _ := mi.chat.state.Cabinet.Role(gID, id) return r }) if ok { style = style.Foreground(tcell.NewHexColor(int32(color))) } presence, err := mi.chat.state.Cabinet.Presence(gID, m.User.ID) if err != nil { slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err) } else if presence.Status == discord.OfflineStatus { style = style.Dim(true) } mi.mentionsList.append(mentionsListItem{ insertText: m.User.Username, displayText: name, style: style, }) return mi.mentionsList.itemCount() > int(mi.cfg.AutocompleteLimit) } func (mi *messageInput) addMentionUser(user *discord.User) { if user == nil { return } name := user.DisplayOrUsername() style := tcell.StyleDefault presence, err := mi.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID) if err != nil { slog.Info("failed to get presence from state", "user_id", user.ID, "err", err) } else if presence.Status == discord.OfflineStatus { style = style.Dim(true) } mi.mentionsList.append(mentionsListItem{ insertText: user.Username, displayText: name, style: style, }) } func (mi *messageInput) removeMentionsList() { mi.chat.HideLayer(mentionsListLayerName) } func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) { if mi.cfg.AutocompleteLimit > 0 { mi.mentionsList.clear() if emit != nil { emit(closeLayer(mentionsListLayerName)) emit(tview.SetFocus(mi)) } else { mi.removeMentionsList() mi.chat.app.SetFocus(mi) } } } func (mi *messageInput) editor() { file, err := os.CreateTemp("", tmpFilePattern) if err != nil { slog.Error("failed to create tmp file", "err", err) return } defer file.Close() defer os.Remove(file.Name()) file.WriteString(mi.GetText()) if mi.cfg.Editor == "" { slog.Warn("Attempt to open file with editor, but no editor is set") return } cmd := mi.cfg.CreateEditorCommand(file.Name()) if cmd == nil { return } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr mi.chat.app.Suspend(func() { err := cmd.Run() if err != nil { slog.Error("failed to run command", "args", cmd.Args, "err", err) return } }) msg, err := os.ReadFile(file.Name()) if err != nil { slog.Error("failed to read tmp file", "name", file.Name(), "err", err) return } mi.SetText(strings.TrimSpace(string(msg)), true) } func (mi *messageInput) openFilePicker() { if mi.chat.SelectedChannel() == nil { return } paths, err := zenity.SelectFileMultiple() if err != nil { slog.Error("failed to open file dialog", "err", err) return } for _, path := range paths { file, err := os.Open(path) if err != nil { slog.Error("failed to open file", "path", path, "err", err) continue } name := filepath.Base(path) mi.attach(name, file) } } func (mi *messageInput) attach(name string, reader io.Reader) { mi.sendMessageData.Files = append(mi.sendMessageData.Files, sendpart.File{Name: name, Reader: reader}) var names []string for _, file := range mi.sendMessageData.Files { names = append(names, file.Name) } mi.SetFooter("Attached " + humanJoin(names)) } func (mi *messageInput) ShortHelp() []keybind.Keybind { if mi.chat.GetVisible(mentionsListLayerName) { cfg := mi.cfg.Keybinds.MentionsList icfg := mi.cfg.Keybinds.MessageInput short := []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, icfg.Cancel.Keybind} if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { short = append(short, icfg.OpenFilePicker.Keybind) } return short } cfg := mi.cfg.Keybinds.MessageInput short := []keybind.Keybind{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind} if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { short = append(short, cfg.OpenFilePicker.Keybind) } return short } func (mi *messageInput) FullHelp() [][]keybind.Keybind { if mi.chat.GetVisible(mentionsListLayerName) { mcfg := mi.cfg.Keybinds.MentionsList icfg := mi.cfg.Keybinds.MessageInput return [][]keybind.Keybind{ {mcfg.Up.Keybind, mcfg.Down.Keybind, mcfg.Top.Keybind, mcfg.Bottom.Keybind}, {icfg.TabComplete.Keybind, icfg.Cancel.Keybind}, } } cfg := mi.cfg.Keybinds.MessageInput openEditor := []keybind.Keybind{cfg.Paste.Keybind, cfg.OpenEditor.Keybind} if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { openEditor = append(openEditor, cfg.OpenFilePicker.Keybind) } return [][]keybind.Keybind{ {cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.TabComplete.Keybind, cfg.Undo.Keybind}, openEditor, } }