message_input.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
  1. package chat
  2. import (
  3. "bytes"
  4. "io"
  5. "log/slog"
  6. "os"
  7. "path/filepath"
  8. "regexp"
  9. "slices"
  10. "strings"
  11. "sync"
  12. "time"
  13. "unicode"
  14. "github.com/ayn2op/discordo/internal/cache"
  15. "github.com/ayn2op/discordo/internal/clipboard"
  16. "github.com/ayn2op/discordo/internal/config"
  17. "github.com/ayn2op/discordo/internal/consts"
  18. "github.com/ayn2op/discordo/internal/ui"
  19. "github.com/ayn2op/tview"
  20. "github.com/ayn2op/tview/help"
  21. "github.com/ayn2op/tview/keybind"
  22. "github.com/ayn2op/tview/layers"
  23. "github.com/diamondburned/arikawa/v3/api"
  24. "github.com/diamondburned/arikawa/v3/discord"
  25. "github.com/diamondburned/arikawa/v3/state"
  26. "github.com/diamondburned/arikawa/v3/utils/json/option"
  27. "github.com/diamondburned/arikawa/v3/utils/sendpart"
  28. "github.com/diamondburned/ningen/v3"
  29. "github.com/diamondburned/ningen/v3/discordmd"
  30. "github.com/gdamore/tcell/v3"
  31. "github.com/ncruces/zenity"
  32. "github.com/sahilm/fuzzy"
  33. "github.com/yuin/goldmark/ast"
  34. )
  35. const tmpFilePattern = consts.Name + "_*.md"
  36. var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+")
  37. type messageInput struct {
  38. *tview.TextArea
  39. cfg *config.Config
  40. chatView *View
  41. edit bool
  42. sendMessageData *api.SendMessageData
  43. cache *cache.Cache
  44. mentionsList *mentionsList
  45. lastSearch time.Time
  46. typingTimerMu sync.Mutex
  47. typingTimer *time.Timer
  48. }
  49. var _ help.KeyMap = (*messageInput)(nil)
  50. func newMessageInput(cfg *config.Config, chatView *View) *messageInput {
  51. mi := &messageInput{
  52. TextArea: tview.NewTextArea(),
  53. cfg: cfg,
  54. chatView: chatView,
  55. sendMessageData: &api.SendMessageData{},
  56. cache: cache.NewCache(),
  57. mentionsList: newMentionsList(cfg),
  58. }
  59. mi.Box = ui.ConfigureBox(mi.Box, &cfg.Theme)
  60. mi.
  61. SetPlaceholder(tview.NewLine(tview.NewSegment("Select a channel to start chatting", tcell.StyleDefault.Dim(true)))).
  62. SetClipboard(
  63. func(s string) { clipboard.Write(clipboard.FmtText, []byte(s)) },
  64. func() string { return string(clipboard.Read(clipboard.FmtText)) },
  65. ).
  66. SetDisabled(true)
  67. return mi
  68. }
  69. func (mi *messageInput) reset() {
  70. mi.edit = false
  71. mi.sendMessageData = &api.SendMessageData{}
  72. mi.SetTitle("")
  73. mi.SetFooter("")
  74. mi.SetText("", true)
  75. }
  76. func (mi *messageInput) stopTypingTimer() {
  77. if mi.typingTimer != nil {
  78. mi.typingTimer.Stop()
  79. mi.typingTimer = nil
  80. }
  81. }
  82. func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
  83. switch event := event.(type) {
  84. case *tview.KeyEvent:
  85. consume := tview.ConsumeEventCommand{}
  86. consumeRedraw := tview.BatchCommand{tview.RedrawCommand{}, tview.ConsumeEventCommand{}}
  87. handler := mi.TextArea.HandleEvent
  88. switch {
  89. case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Paste.Keybind):
  90. mi.paste()
  91. return handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))
  92. case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Send.Keybind):
  93. if mi.chatView.GetVisible(mentionsListLayerName) {
  94. mi.tabComplete()
  95. } else {
  96. mi.send()
  97. }
  98. return consumeRedraw
  99. case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenEditor.Keybind):
  100. var cmds tview.BatchCommand
  101. mi.stopTabCompletion(func(next tview.Command) {
  102. if next != nil {
  103. cmds = append(cmds, next)
  104. }
  105. })
  106. mi.editor()
  107. cmds = append(cmds, consumeRedraw)
  108. return cmds
  109. case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenFilePicker.Keybind):
  110. var cmds tview.BatchCommand
  111. mi.stopTabCompletion(func(next tview.Command) {
  112. if next != nil {
  113. cmds = append(cmds, next)
  114. }
  115. })
  116. mi.openFilePicker()
  117. cmds = append(cmds, consumeRedraw)
  118. return cmds
  119. case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind):
  120. var cmds tview.BatchCommand
  121. if mi.chatView.GetVisible(mentionsListLayerName) {
  122. mi.stopTabCompletion(func(next tview.Command) {
  123. if next != nil {
  124. cmds = append(cmds, next)
  125. }
  126. })
  127. } else {
  128. mi.reset()
  129. }
  130. cmds = append(cmds, consumeRedraw)
  131. return cmds
  132. case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind):
  133. go mi.chatView.app.QueueUpdateDraw(func() { mi.tabComplete() })
  134. return consume
  135. case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Undo.Keybind):
  136. return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
  137. }
  138. if mi.cfg.TypingIndicator.Send && mi.typingTimer == nil {
  139. mi.typingTimer = time.AfterFunc(typingDuration, func() {
  140. mi.typingTimerMu.Lock()
  141. mi.typingTimer = nil
  142. mi.typingTimerMu.Unlock()
  143. })
  144. if selectedChannel := mi.chatView.SelectedChannel(); selectedChannel != nil {
  145. go mi.chatView.state.Typing(selectedChannel.ID)
  146. }
  147. }
  148. if mi.cfg.AutocompleteLimit > 0 {
  149. if mi.chatView.GetVisible(mentionsListLayerName) {
  150. switch {
  151. case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind):
  152. mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone))
  153. return consumeRedraw
  154. case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Down.Keybind):
  155. mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone))
  156. return consumeRedraw
  157. case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Top.Keybind):
  158. mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone))
  159. return consumeRedraw
  160. case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Bottom.Keybind):
  161. mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone))
  162. return consumeRedraw
  163. }
  164. }
  165. go mi.chatView.app.QueueUpdateDraw(func() { mi.tabSuggestion() })
  166. }
  167. return handler(event)
  168. }
  169. return mi.TextArea.HandleEvent(event)
  170. }
  171. func (mi *messageInput) paste() {
  172. if data := clipboard.Read(clipboard.FmtImage); data != nil {
  173. name := "clipboard.png"
  174. mi.attach(name, bytes.NewReader(data))
  175. }
  176. }
  177. func (mi *messageInput) send() {
  178. selected := mi.chatView.SelectedChannel()
  179. if selected == nil {
  180. return
  181. }
  182. text := strings.TrimSpace(mi.GetText())
  183. if text == "" && len(mi.sendMessageData.Files) == 0 {
  184. return
  185. }
  186. // Close attached files on return
  187. defer func() {
  188. for _, file := range mi.sendMessageData.Files {
  189. if closer, ok := file.Reader.(io.Closer); ok {
  190. closer.Close()
  191. }
  192. }
  193. }()
  194. text = mi.processText(selected, []byte(text))
  195. if mi.edit {
  196. m, err := mi.chatView.messagesList.selectedMessage()
  197. if err != nil {
  198. slog.Error("failed to get selected message", "err", err)
  199. return
  200. }
  201. data := api.EditMessageData{Content: option.NewNullableString(text)}
  202. if _, err := mi.chatView.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil {
  203. slog.Error("failed to edit message", "err", err)
  204. }
  205. mi.edit = false
  206. } else {
  207. data := mi.sendMessageData
  208. data.Content = text
  209. if _, err := mi.chatView.state.SendMessageComplex(selected.ID, *data); err != nil {
  210. slog.Error("failed to send message in channel", "channel_id", selected.ID, "err", err)
  211. }
  212. }
  213. if mi.typingTimer != nil {
  214. mi.typingTimer.Stop()
  215. mi.typingTimer = nil
  216. }
  217. mi.reset()
  218. mi.chatView.messagesList.clearSelection()
  219. mi.chatView.messagesList.ScrollToEnd()
  220. }
  221. func (mi *messageInput) processText(channel *discord.Channel, src []byte) string {
  222. // Fast path: no mentions to expand.
  223. if bytes.IndexByte(src, '@') == -1 {
  224. return string(src)
  225. }
  226. // Fast path: no back ticks (code blocks), so expand mentions directly.
  227. if bytes.IndexByte(src, '`') == -1 {
  228. return string(mi.expandMentions(channel, src))
  229. }
  230. var (
  231. ranges [][2]int
  232. canMention = true
  233. )
  234. ast.Walk(discordmd.Parse(src), func(node ast.Node, enter bool) (ast.WalkStatus, error) {
  235. switch node := node.(type) {
  236. case *ast.CodeBlock, *ast.FencedCodeBlock:
  237. canMention = !enter
  238. case *discordmd.Inline:
  239. if (node.Attr & discordmd.AttrMonospace) != 0 {
  240. canMention = !enter
  241. }
  242. case *ast.Text:
  243. if canMention {
  244. ranges = append(ranges, [2]int{node.Segment.Start,
  245. node.Segment.Stop})
  246. }
  247. }
  248. return ast.WalkContinue, nil
  249. })
  250. for _, rng := range ranges {
  251. src = slices.Replace(src, rng[0], rng[1], mi.expandMentions(channel, src[rng[0]:rng[1]])...)
  252. }
  253. return string(src)
  254. }
  255. func (mi *messageInput) expandMentions(c *discord.Channel, src []byte) []byte {
  256. state := mi.chatView.state
  257. return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte {
  258. output := input
  259. name := string(input[1:])
  260. if c.Type == discord.DirectMessage || c.Type == discord.GroupDM {
  261. for _, user := range c.DMRecipients {
  262. if strings.EqualFold(user.Username, name) {
  263. return []byte(user.ID.Mention())
  264. }
  265. }
  266. // self ping
  267. me, _ := state.Cabinet.Me()
  268. if strings.EqualFold(me.Username, name) {
  269. return []byte(me.ID.Mention())
  270. }
  271. return output
  272. }
  273. state.MemberStore.Each(c.GuildID, func(m *discord.Member) bool {
  274. if strings.EqualFold(m.User.Username, name) {
  275. if channelHasUser(state, c.ID, m.User.ID) {
  276. output = []byte(m.User.ID.Mention())
  277. }
  278. return true
  279. }
  280. return false
  281. })
  282. return output
  283. })
  284. }
  285. func (mi *messageInput) tabComplete() {
  286. posEnd, name, r := mi.GetWordUnderCursor(func(r rune) bool {
  287. return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
  288. })
  289. if r != '@' {
  290. mi.stopTabCompletion(nil)
  291. return
  292. }
  293. pos := posEnd - (len(name) + 1)
  294. selected := mi.chatView.SelectedChannel()
  295. if selected == nil {
  296. return
  297. }
  298. gID := selected.GuildID
  299. if mi.cfg.AutocompleteLimit == 0 {
  300. if !gID.IsValid() {
  301. users := selected.DMRecipients
  302. res := fuzzy.FindFrom(name, userList(users))
  303. if len(res) > 0 {
  304. mi.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ")
  305. }
  306. } else {
  307. mi.searchMember(gID, name)
  308. members, err := mi.chatView.state.Cabinet.Members(gID)
  309. if err != nil {
  310. slog.Error("failed to get members from state", "guild_id", gID, "err", err)
  311. return
  312. }
  313. res := fuzzy.FindFrom(name, memberList(members))
  314. for _, r := range res {
  315. if channelHasUser(mi.chatView.state, selected.ID, members[r.Index].User.ID) {
  316. mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
  317. return
  318. }
  319. }
  320. }
  321. return
  322. }
  323. if mi.mentionsList.itemCount() == 0 {
  324. return
  325. }
  326. name, ok := mi.mentionsList.selectedInsertText()
  327. if !ok {
  328. return
  329. }
  330. mi.Replace(pos, posEnd, "@"+name+" ")
  331. mi.stopTabCompletion(nil)
  332. }
  333. func (mi *messageInput) tabSuggestion() {
  334. _, name, r := mi.GetWordUnderCursor(func(r rune) bool {
  335. return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
  336. })
  337. if r != '@' {
  338. mi.stopTabCompletion(nil)
  339. return
  340. }
  341. selected := mi.chatView.SelectedChannel()
  342. if selected == nil {
  343. return
  344. }
  345. gID := selected.GuildID
  346. cID := selected.ID
  347. mi.mentionsList.clear()
  348. var shown map[string]struct{}
  349. var userDone struct{}
  350. if name == "" {
  351. shown = make(map[string]struct{})
  352. // Don't show @me in the list of recent authors
  353. me, _ := mi.chatView.state.Cabinet.Me()
  354. shown[me.Username] = userDone
  355. }
  356. // DMs have recipients, not members
  357. if !gID.IsValid() {
  358. if name == "" { // show recent messages' authors
  359. msgs, err := mi.chatView.state.Cabinet.Messages(cID)
  360. if err != nil {
  361. return
  362. }
  363. for _, m := range msgs {
  364. if _, ok := shown[m.Author.Username]; ok {
  365. continue
  366. }
  367. shown[m.Author.Username] = userDone
  368. mi.addMentionUser(&m.Author)
  369. }
  370. } else {
  371. users := selected.DMRecipients
  372. me, _ := mi.chatView.state.Cabinet.Me()
  373. users = append(users, *me)
  374. res := fuzzy.FindFrom(name, userList(users))
  375. for _, r := range res {
  376. mi.addMentionUser(&users[r.Index])
  377. }
  378. }
  379. } else if name == "" { // show recent messages' authors
  380. msgs, err := mi.chatView.state.Cabinet.Messages(cID)
  381. if err != nil {
  382. return
  383. }
  384. for _, m := range msgs {
  385. if _, ok := shown[m.Author.Username]; ok {
  386. continue
  387. }
  388. shown[m.Author.Username] = userDone
  389. mi.chatView.state.MemberState.RequestMember(gID, m.Author.ID)
  390. if mem, err := mi.chatView.state.Cabinet.Member(gID, m.Author.ID); err == nil {
  391. if mi.addMentionMember(gID, mem) {
  392. break
  393. }
  394. }
  395. }
  396. } else {
  397. mi.searchMember(gID, name)
  398. mems, err := mi.chatView.state.Cabinet.Members(gID)
  399. if err != nil {
  400. slog.Error("fetching members failed", "err", err)
  401. return
  402. }
  403. res := fuzzy.FindFrom(name, memberList(mems))
  404. if len(res) > int(mi.cfg.AutocompleteLimit) {
  405. res = res[:int(mi.cfg.AutocompleteLimit)]
  406. }
  407. for _, r := range res {
  408. if channelHasUser(mi.chatView.state, cID, mems[r.Index].User.ID) &&
  409. mi.addMentionMember(gID, &mems[r.Index]) {
  410. break
  411. }
  412. }
  413. }
  414. if mi.mentionsList.itemCount() == 0 {
  415. mi.stopTabCompletion(nil)
  416. return
  417. }
  418. mi.mentionsList.rebuild()
  419. mi.showMentionList()
  420. }
  421. type memberList []discord.Member
  422. type userList []discord.User
  423. func (ml memberList) String(i int) string {
  424. return ml[i].Nick + ml[i].User.DisplayName + ml[i].User.Tag()
  425. }
  426. func (ml memberList) Len() int {
  427. return len(ml)
  428. }
  429. func (ul userList) String(i int) string {
  430. return ul[i].DisplayName + ul[i].Tag()
  431. }
  432. func (ul userList) Len() int {
  433. return len(ul)
  434. }
  435. // channelHasUser checks if a user has permission to view the specified channel
  436. func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID discord.UserID) bool {
  437. perms, err := state.Permissions(channelID, userID)
  438. if err != nil {
  439. slog.Error("failed to get permissions", "err", err, "channel", channelID, "user", userID)
  440. return false
  441. }
  442. return perms.Has(discord.PermissionViewChannel)
  443. }
  444. func (mi *messageInput) searchMember(gID discord.GuildID, name string) {
  445. key := gID.String() + " " + name
  446. if mi.cache.Exists(key) {
  447. return
  448. }
  449. // If searching for "ab" returns less than SearchLimit,
  450. // then "abc" would not return anything new because we already searched
  451. // everything starting with "ab". This will still be true even if a new
  452. // member joins because arikawa loads new members into the state.
  453. if k := key[:len(key)-1]; mi.cache.Exists(k) {
  454. if c := mi.cache.Get(k); c < mi.chatView.state.MemberState.SearchLimit {
  455. mi.cache.Create(key, c)
  456. return
  457. }
  458. }
  459. // 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
  460. if mi.lastSearch.Add(mi.chatView.state.MemberState.SearchFrequency).After(time.Now()) {
  461. return
  462. }
  463. mi.lastSearch = time.Now()
  464. mi.chatView.messagesList.waitForChunkEvent()
  465. mi.chatView.messagesList.setFetchingChunk(true, 0)
  466. mi.chatView.state.MemberState.SearchMember(gID, name)
  467. mi.cache.Create(key, mi.chatView.messagesList.waitForChunkEvent())
  468. }
  469. func (mi *messageInput) showMentionList() {
  470. borders := 0
  471. if mi.cfg.Theme.Border.Enabled {
  472. borders = 1
  473. }
  474. l := mi.mentionsList
  475. x, _, _, _ := mi.GetInnerRect()
  476. _, y, _, _ := mi.GetRect()
  477. _, _, maxW, maxH := mi.chatView.messagesList.GetInnerRect()
  478. if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 {
  479. maxH = min(maxH, t)
  480. }
  481. count := mi.mentionsList.itemCount() + borders
  482. h := min(count, maxH) + borders + mi.cfg.Theme.Border.Padding[1]
  483. y -= h
  484. w := int(mi.cfg.Theme.MentionsList.MinWidth)
  485. if w == 0 {
  486. w = maxW
  487. } else {
  488. w = max(w, mi.mentionsList.maxDisplayWidth())
  489. w = min(w+borders*2, maxW)
  490. _, col, _, _ := mi.GetCursor()
  491. x += min(col, maxW-w)
  492. }
  493. l.SetRect(x, y, w, h)
  494. mi.chatView.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName)
  495. mi.chatView.app.SetFocus(mi)
  496. }
  497. func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool {
  498. if m == nil {
  499. return false
  500. }
  501. name := m.User.DisplayOrUsername()
  502. if m.Nick != "" {
  503. name = m.Nick
  504. }
  505. style := tcell.StyleDefault
  506. // This avoids a slower member color lookup path.
  507. color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role {
  508. r, _ := mi.chatView.state.Cabinet.Role(gID, id)
  509. return r
  510. })
  511. if ok {
  512. style = style.Foreground(tcell.NewHexColor(int32(color)))
  513. }
  514. presence, err := mi.chatView.state.Cabinet.Presence(gID, m.User.ID)
  515. if err != nil {
  516. slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err)
  517. } else if presence.Status == discord.OfflineStatus {
  518. style = style.Dim(true)
  519. }
  520. mi.mentionsList.append(mentionsListItem{
  521. insertText: m.User.Username,
  522. displayText: name,
  523. style: style,
  524. })
  525. return mi.mentionsList.itemCount() > int(mi.cfg.AutocompleteLimit)
  526. }
  527. func (mi *messageInput) addMentionUser(user *discord.User) {
  528. if user == nil {
  529. return
  530. }
  531. name := user.DisplayOrUsername()
  532. style := tcell.StyleDefault
  533. presence, err := mi.chatView.state.Cabinet.Presence(discord.NullGuildID, user.ID)
  534. if err != nil {
  535. slog.Info("failed to get presence from state", "user_id", user.ID, "err", err)
  536. } else if presence.Status == discord.OfflineStatus {
  537. style = style.Dim(true)
  538. }
  539. mi.mentionsList.append(mentionsListItem{
  540. insertText: user.Username,
  541. displayText: name,
  542. style: style,
  543. })
  544. }
  545. // used by chatView
  546. func (mi *messageInput) removeMentionsList() {
  547. mi.chatView.HideLayer(mentionsListLayerName)
  548. }
  549. func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) {
  550. if mi.cfg.AutocompleteLimit > 0 {
  551. mi.mentionsList.clear()
  552. if emit != nil {
  553. emit(layers.CloseLayerCommand{Name: mentionsListLayerName})
  554. emit(tview.SetFocusCommand{Target: mi})
  555. } else {
  556. mi.removeMentionsList()
  557. mi.chatView.app.SetFocus(mi)
  558. }
  559. }
  560. }
  561. func (mi *messageInput) editor() {
  562. file, err := os.CreateTemp("", tmpFilePattern)
  563. if err != nil {
  564. slog.Error("failed to create tmp file", "err", err)
  565. return
  566. }
  567. defer file.Close()
  568. defer os.Remove(file.Name())
  569. file.WriteString(mi.GetText())
  570. if mi.cfg.Editor == "" {
  571. slog.Warn("Attempt to open file with editor, but no editor is set")
  572. return
  573. }
  574. cmd := mi.cfg.CreateEditorCommand(file.Name())
  575. if cmd == nil {
  576. return
  577. }
  578. cmd.Stdin = os.Stdin
  579. cmd.Stdout = os.Stdout
  580. cmd.Stderr = os.Stderr
  581. mi.chatView.app.Suspend(func() {
  582. err := cmd.Run()
  583. if err != nil {
  584. slog.Error("failed to run command", "args", cmd.Args, "err", err)
  585. return
  586. }
  587. })
  588. msg, err := os.ReadFile(file.Name())
  589. if err != nil {
  590. slog.Error("failed to read tmp file", "name", file.Name(), "err", err)
  591. return
  592. }
  593. mi.SetText(strings.TrimSpace(string(msg)), true)
  594. }
  595. func (mi *messageInput) openFilePicker() {
  596. if mi.chatView.SelectedChannel() == nil {
  597. return
  598. }
  599. paths, err := zenity.SelectFileMultiple()
  600. if err != nil {
  601. slog.Error("failed to open file dialog", "err", err)
  602. return
  603. }
  604. for _, path := range paths {
  605. file, err := os.Open(path)
  606. if err != nil {
  607. slog.Error("failed to open file", "path", path, "err", err)
  608. continue
  609. }
  610. name := filepath.Base(path)
  611. mi.attach(name, file)
  612. }
  613. }
  614. func (mi *messageInput) attach(name string, reader io.Reader) {
  615. mi.sendMessageData.Files = append(mi.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
  616. var names []string
  617. for _, file := range mi.sendMessageData.Files {
  618. names = append(names, file.Name)
  619. }
  620. mi.SetFooter("Attached " + humanJoin(names))
  621. }
  622. func (mi *messageInput) ShortHelp() []keybind.Keybind {
  623. if mi.chatView.GetVisible(mentionsListLayerName) {
  624. cfg := mi.cfg.Keybinds.MentionsList
  625. icfg := mi.cfg.Keybinds.MessageInput
  626. short := []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, icfg.Cancel.Keybind}
  627. if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
  628. short = append(short, icfg.OpenFilePicker.Keybind)
  629. }
  630. return short
  631. }
  632. cfg := mi.cfg.Keybinds.MessageInput
  633. short := []keybind.Keybind{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
  634. if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
  635. short = append(short, cfg.OpenFilePicker.Keybind)
  636. }
  637. return short
  638. }
  639. func (mi *messageInput) FullHelp() [][]keybind.Keybind {
  640. if mi.chatView.GetVisible(mentionsListLayerName) {
  641. mcfg := mi.cfg.Keybinds.MentionsList
  642. icfg := mi.cfg.Keybinds.MessageInput
  643. return [][]keybind.Keybind{
  644. {mcfg.Up.Keybind, mcfg.Down.Keybind, mcfg.Top.Keybind, mcfg.Bottom.Keybind},
  645. {icfg.TabComplete.Keybind, icfg.Cancel.Keybind},
  646. }
  647. }
  648. cfg := mi.cfg.Keybinds.MessageInput
  649. openEditor := []keybind.Keybind{cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
  650. if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
  651. openEditor = append(openEditor, cfg.OpenFilePicker.Keybind)
  652. }
  653. return [][]keybind.Keybind{
  654. {cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.TabComplete.Keybind, cfg.Undo.Keybind},
  655. openEditor,
  656. }
  657. }