messages.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. package ui
  2. import (
  3. "io"
  4. "net/http"
  5. "os"
  6. "os/exec"
  7. "path/filepath"
  8. "regexp"
  9. "strings"
  10. "github.com/atotto/clipboard"
  11. "github.com/diamondburned/arikawa/v3/api"
  12. "github.com/diamondburned/arikawa/v3/discord"
  13. "github.com/diamondburned/arikawa/v3/utils/json/option"
  14. "github.com/gdamore/tcell/v2"
  15. "github.com/rivo/tview"
  16. "github.com/skratchdot/open-golang/open"
  17. )
  18. var linkRegex = regexp.MustCompile("https?://.+")
  19. type MessagesPanel struct {
  20. *tview.TextView
  21. app *App
  22. }
  23. func NewMessagesPanel(app *App) *MessagesPanel {
  24. mtv := &MessagesPanel{
  25. TextView: tview.NewTextView(),
  26. app: app,
  27. }
  28. mtv.SetDynamicColors(true)
  29. mtv.SetRegions(true)
  30. mtv.SetWordWrap(true)
  31. mtv.SetInputCapture(mtv.onInputCapture)
  32. mtv.SetChangedFunc(func() {
  33. mtv.app.Draw()
  34. })
  35. mtv.SetTitleAlign(tview.AlignLeft)
  36. mtv.SetBorder(true)
  37. mtv.SetBorderPadding(0, 0, 1, 1)
  38. return mtv
  39. }
  40. func (mp *MessagesPanel) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
  41. if mp.app.SelectedChannel == nil {
  42. return nil
  43. }
  44. // Messages should return messages ordered from latest to earliest.
  45. ms, err := mp.app.State.Cabinet.Messages(mp.app.SelectedChannel.ID)
  46. if err != nil || len(ms) == 0 {
  47. return nil
  48. }
  49. switch e.Name() {
  50. case mp.app.Config.Keys.SelectPreviousMessage:
  51. // If there are no highlighted regions, select the latest (last) message in the messages panel.
  52. if len(mp.app.MessagesPanel.GetHighlights()) == 0 {
  53. mp.app.SelectedMessage = 0
  54. } else {
  55. // If the selected message is the oldest (first) message, select the latest (last) message in the messages panel.
  56. if mp.app.SelectedMessage == len(ms)-1 {
  57. mp.app.SelectedMessage = 0
  58. } else {
  59. mp.app.SelectedMessage++
  60. }
  61. }
  62. mp.app.MessagesPanel.
  63. Highlight(ms[mp.app.SelectedMessage].ID.String()).
  64. ScrollToHighlight()
  65. return nil
  66. case mp.app.Config.Keys.SelectNextMessage:
  67. // If there are no highlighted regions, select the latest (last) message in the messages panel.
  68. if len(mp.app.MessagesPanel.GetHighlights()) == 0 {
  69. mp.app.SelectedMessage = 0
  70. } else {
  71. // If the selected message is the latest (last) message, select the oldest (first) message in the messages panel.
  72. if mp.app.SelectedMessage == 0 {
  73. mp.app.SelectedMessage = len(ms) - 1
  74. } else {
  75. mp.app.SelectedMessage--
  76. }
  77. }
  78. mp.app.MessagesPanel.
  79. Highlight(ms[mp.app.SelectedMessage].ID.String()).
  80. ScrollToHighlight()
  81. return nil
  82. case mp.app.Config.Keys.SelectFirstMessage:
  83. mp.app.SelectedMessage = len(ms) - 1
  84. mp.app.MessagesPanel.
  85. Highlight(ms[mp.app.SelectedMessage].ID.String()).
  86. ScrollToHighlight()
  87. return nil
  88. case mp.app.Config.Keys.SelectLastMessage:
  89. mp.app.SelectedMessage = 0
  90. mp.app.MessagesPanel.
  91. Highlight(ms[mp.app.SelectedMessage].ID.String()).
  92. ScrollToHighlight()
  93. return nil
  94. case mp.app.Config.Keys.OpenMessageActionsList:
  95. hs := mp.app.MessagesPanel.GetHighlights()
  96. if len(hs) == 0 {
  97. return nil
  98. }
  99. mID, err := discord.ParseSnowflake(hs[0])
  100. if err != nil {
  101. return nil
  102. }
  103. _, m := findMessageByID(ms, discord.MessageID(mID))
  104. if m == nil {
  105. return nil
  106. }
  107. actionsList := NewMessageActionsList(mp.app, m)
  108. mp.app.SetRoot(actionsList, true)
  109. return nil
  110. case "Esc":
  111. mp.app.SelectedMessage = -1
  112. mp.app.SetFocus(mp.app.MainFlex)
  113. mp.app.MessagesPanel.
  114. Clear().
  115. Highlight().
  116. SetTitle("")
  117. return nil
  118. }
  119. return e
  120. }
  121. type MessageActionsList struct {
  122. *tview.List
  123. app *App
  124. message *discord.Message
  125. }
  126. func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList {
  127. mal := &MessageActionsList{
  128. List: tview.NewList(),
  129. app: app,
  130. message: m,
  131. }
  132. mal.ShowSecondaryText(false)
  133. mal.SetDoneFunc(func() {
  134. app.
  135. SetRoot(app.MainFlex, true).
  136. SetFocus(app.MessagesPanel)
  137. })
  138. // If the client user has the `SEND_MESSAGES` permission, add "Reply" and "Mention Reply" actions.
  139. if hasPermission(app.State, app.SelectedChannel.ID, discord.PermissionSendMessages) {
  140. mal.AddItem("Reply", "", 'r', mal.replyAction)
  141. mal.AddItem("Mention Reply", "", 'R', mal.mentionReplyAction)
  142. }
  143. // If the referenced message exists, add a new action to select the reply.
  144. if m.ReferencedMessage != nil {
  145. mal.AddItem("Select Reply", "", 'm', mal.selectReplyAction)
  146. }
  147. // If the content of the message contains link(s), add the appropriate actions to the list.
  148. links := linkRegex.FindAllString(m.Content, -1)
  149. if len(links) != 0 {
  150. mal.AddItem("Open Link", "", 'l', func() {
  151. for _, l := range links {
  152. go open.Run(l)
  153. }
  154. app.SetRoot(app.MainFlex, true)
  155. app.SetFocus(app.MessagesPanel)
  156. })
  157. }
  158. // If the message contains attachments, add the appropriate actions to the actions list.
  159. if len(m.Attachments) != 0 {
  160. mal.AddItem("Open Attachment", "", 'o', mal.openAttachmentAction)
  161. mal.AddItem("Download Attachment", "", 'd', mal.downloadAttachmentAction)
  162. }
  163. // If the client user has the `MANAGE_MESSAGES` permission, add a new action to delete the message.
  164. if hasPermission(app.State, app.SelectedChannel.ID, discord.PermissionManageMessages) {
  165. mal.AddItem("Delete", "", 'd', mal.deleteAction)
  166. }
  167. mal.AddItem("Copy Content", "", 'c', mal.copyContentAction)
  168. mal.AddItem("Copy ID", "", 'i', mal.copyIDAction)
  169. mal.SetTitle("Press the Escape key to close")
  170. mal.SetTitleAlign(tview.AlignLeft)
  171. mal.SetBorder(true)
  172. mal.SetBorderPadding(0, 0, 1, 1)
  173. return mal
  174. }
  175. func (mal *MessageActionsList) replyAction() {
  176. mal.app.MessageInputField.SetTitle("Replying to " + mal.message.Author.Tag())
  177. mal.app.
  178. SetRoot(mal.app.MainFlex, true).
  179. SetFocus(mal.app.MessageInputField)
  180. }
  181. func (mal *MessageActionsList) mentionReplyAction() {
  182. mal.app.MessageInputField.SetTitle("[@] Replying to " + mal.message.Author.Tag())
  183. mal.app.
  184. SetRoot(mal.app.MainFlex, true).
  185. SetFocus(mal.app.MessageInputField)
  186. }
  187. func (mal *MessageActionsList) selectReplyAction() {
  188. ms, err := mal.app.State.Cabinet.Messages(mal.message.ChannelID)
  189. if err != nil {
  190. return
  191. }
  192. mal.app.SelectedMessage, _ = findMessageByID(ms, mal.message.ReferencedMessage.ID)
  193. mal.app.MessagesPanel.
  194. Highlight(mal.message.ReferencedMessage.ID.String()).
  195. ScrollToHighlight()
  196. mal.app.
  197. SetRoot(mal.app.MainFlex, true).
  198. SetFocus(mal.app.MessagesPanel)
  199. }
  200. func (mal *MessageActionsList) openAttachmentAction() {
  201. for _, a := range mal.message.Attachments {
  202. cacheDirPath, _ := os.UserCacheDir()
  203. f, err := os.Create(filepath.Join(cacheDirPath, a.Filename))
  204. if err != nil {
  205. return
  206. }
  207. defer f.Close()
  208. resp, err := http.Get(a.URL)
  209. if err != nil {
  210. return
  211. }
  212. d, err := io.ReadAll(resp.Body)
  213. if err != nil {
  214. return
  215. }
  216. f.Write(d)
  217. go open.Run(f.Name())
  218. }
  219. mal.app.
  220. SetRoot(mal.app.MainFlex, true).
  221. SetFocus(mal.app.MessagesPanel)
  222. }
  223. func (mal *MessageActionsList) downloadAttachmentAction() {
  224. for _, a := range mal.message.Attachments {
  225. f, err := os.Create(filepath.Join(mal.app.Config.AttachmentDownloadsDir, a.Filename))
  226. if err != nil {
  227. return
  228. }
  229. defer f.Close()
  230. resp, err := http.Get(a.URL)
  231. if err != nil {
  232. return
  233. }
  234. d, err := io.ReadAll(resp.Body)
  235. if err != nil {
  236. return
  237. }
  238. f.Write(d)
  239. }
  240. mal.app.
  241. SetRoot(mal.app.MainFlex, true).
  242. SetFocus(mal.app.MessagesPanel)
  243. }
  244. func (mal *MessageActionsList) deleteAction() {
  245. mal.app.MessagesPanel.Clear()
  246. err := mal.app.State.MessageRemove(mal.message.ChannelID, mal.message.ID)
  247. if err != nil {
  248. return
  249. }
  250. err = mal.app.State.DeleteMessage(mal.message.ChannelID, mal.message.ID, "Unknown")
  251. if err != nil {
  252. return
  253. }
  254. // The returned slice will be sorted from latest to oldest.
  255. ms, err := mal.app.State.Cabinet.Messages(mal.message.ChannelID)
  256. if err != nil {
  257. return
  258. }
  259. for i := len(ms) - 1; i >= 0; i-- {
  260. _, err = mal.app.MessagesPanel.Write(buildMessage(mal.app, ms[i]))
  261. if err != nil {
  262. return
  263. }
  264. }
  265. mal.app.
  266. SetRoot(mal.app.MainFlex, true).
  267. SetFocus(mal.app.MessagesPanel)
  268. }
  269. func (mal *MessageActionsList) copyContentAction() {
  270. err := clipboard.WriteAll(mal.message.Content)
  271. if err != nil {
  272. return
  273. }
  274. mal.app.SetRoot(mal.app.MainFlex, true)
  275. mal.app.SetFocus(mal.app.MessagesPanel)
  276. }
  277. func (mal *MessageActionsList) copyIDAction() {
  278. err := clipboard.WriteAll(mal.message.ID.String())
  279. if err != nil {
  280. return
  281. }
  282. mal.app.SetRoot(mal.app.MainFlex, true)
  283. mal.app.SetFocus(mal.app.MessagesPanel)
  284. }
  285. type MessageInput struct {
  286. *tview.InputField
  287. app *App
  288. }
  289. func NewMessageInput(app *App) *MessageInput {
  290. mi := &MessageInput{
  291. InputField: tview.NewInputField(),
  292. app: app,
  293. }
  294. mi.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
  295. mi.SetPlaceholder("Message...")
  296. mi.SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor))
  297. mi.SetTitleAlign(tview.AlignLeft)
  298. mi.SetBorder(true)
  299. mi.SetBorderPadding(0, 0, 1, 1)
  300. mi.SetInputCapture(mi.onInputCapture)
  301. return mi
  302. }
  303. func (mi *MessageInput) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
  304. switch e.Name() {
  305. case "Enter":
  306. if mi.app.SelectedChannel == nil {
  307. return nil
  308. }
  309. t := strings.TrimSpace(mi.app.MessageInputField.GetText())
  310. if t == "" {
  311. return nil
  312. }
  313. ms, err := mi.app.State.Messages(mi.app.SelectedChannel.ID, mi.app.Config.MessagesLimit)
  314. if err != nil {
  315. return nil
  316. }
  317. if len(mi.app.MessagesPanel.GetHighlights()) != 0 {
  318. mID, err := discord.ParseSnowflake(mi.app.MessagesPanel.GetHighlights()[0])
  319. if err != nil {
  320. return nil
  321. }
  322. _, m := findMessageByID(ms, discord.MessageID(mID))
  323. d := api.SendMessageData{
  324. Content: t,
  325. Reference: m.Reference,
  326. AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
  327. }
  328. // If the title of the message InputField widget has "[@]" as a prefix, send the message as a reply and mention the replied user.
  329. if strings.HasPrefix(mi.app.MessageInputField.GetTitle(), "[@]") {
  330. d.AllowedMentions.RepliedUser = option.True
  331. }
  332. go mi.app.State.SendMessageComplex(m.ChannelID, d)
  333. mi.app.SelectedMessage = -1
  334. mi.app.MessagesPanel.Highlight()
  335. mi.app.MessageInputField.SetTitle("")
  336. } else {
  337. go mi.app.State.SendMessage(mi.app.SelectedChannel.ID, t)
  338. }
  339. mi.app.MessageInputField.SetText("")
  340. return nil
  341. case "Ctrl+V":
  342. text, _ := clipboard.ReadAll()
  343. text = mi.app.MessageInputField.GetText() + text
  344. mi.app.MessageInputField.SetText(text)
  345. return nil
  346. case "Esc":
  347. mi.app.MessageInputField.
  348. SetText("").
  349. SetTitle("")
  350. mi.app.SetFocus(mi.app.MainFlex)
  351. mi.app.SelectedMessage = -1
  352. mi.app.MessagesPanel.Highlight()
  353. return nil
  354. case mi.app.Config.Keys.OpenExternalEditor:
  355. e := os.Getenv("EDITOR")
  356. if e == "" {
  357. return nil
  358. }
  359. f, err := os.CreateTemp(os.TempDir(), "discordo-*.md")
  360. if err != nil {
  361. return nil
  362. }
  363. defer os.Remove(f.Name())
  364. cmd := exec.Command(e, f.Name())
  365. cmd.Stdin = os.Stdin
  366. cmd.Stdout = os.Stdout
  367. mi.app.Suspend(func() {
  368. err = cmd.Run()
  369. if err != nil {
  370. return
  371. }
  372. })
  373. b, err := io.ReadAll(f)
  374. if err != nil {
  375. return nil
  376. }
  377. mi.app.MessageInputField.SetText(string(b))
  378. return nil
  379. }
  380. return e
  381. }