messages.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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 MessagesTextView struct {
  20. *tview.TextView
  21. app *App
  22. }
  23. func NewMessagesTextView(app *App) *MessagesTextView {
  24. mtv := &MessagesTextView{
  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 (mtv *MessagesTextView) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
  41. if mtv.app.SelectedChannel == nil {
  42. return nil
  43. }
  44. // Messages should return messages ordered from latest to earliest.
  45. ms, err := mtv.app.State.Cabinet.Messages(mtv.app.SelectedChannel.ID)
  46. if err != nil || len(ms) == 0 {
  47. return nil
  48. }
  49. switch e.Name() {
  50. case mtv.app.Config.Keys.SelectPreviousMessage:
  51. // If there are no highlighted regions, select the latest (last) message in the messages TextView.
  52. if len(mtv.app.MessagesTextView.GetHighlights()) == 0 {
  53. mtv.app.SelectedMessage = 0
  54. } else {
  55. // If the selected message is the oldest (first) message, select the latest (last) message in the messages TextView.
  56. if mtv.app.SelectedMessage == len(ms)-1 {
  57. mtv.app.SelectedMessage = 0
  58. } else {
  59. mtv.app.SelectedMessage++
  60. }
  61. }
  62. mtv.app.MessagesTextView.
  63. Highlight(ms[mtv.app.SelectedMessage].ID.String()).
  64. ScrollToHighlight()
  65. return nil
  66. case mtv.app.Config.Keys.SelectNextMessage:
  67. // If there are no highlighted regions, select the latest (last) message in the messages TextView.
  68. if len(mtv.app.MessagesTextView.GetHighlights()) == 0 {
  69. mtv.app.SelectedMessage = 0
  70. } else {
  71. // If the selected message is the latest (last) message, select the oldest (first) message in the messages TextView.
  72. if mtv.app.SelectedMessage == 0 {
  73. mtv.app.SelectedMessage = len(ms) - 1
  74. } else {
  75. mtv.app.SelectedMessage--
  76. }
  77. }
  78. mtv.app.MessagesTextView.
  79. Highlight(ms[mtv.app.SelectedMessage].ID.String()).
  80. ScrollToHighlight()
  81. return nil
  82. case mtv.app.Config.Keys.SelectFirstMessage:
  83. mtv.app.SelectedMessage = len(ms) - 1
  84. mtv.app.MessagesTextView.
  85. Highlight(ms[mtv.app.SelectedMessage].ID.String()).
  86. ScrollToHighlight()
  87. return nil
  88. case mtv.app.Config.Keys.SelectLastMessage:
  89. mtv.app.SelectedMessage = 0
  90. mtv.app.MessagesTextView.
  91. Highlight(ms[mtv.app.SelectedMessage].ID.String()).
  92. ScrollToHighlight()
  93. return nil
  94. case mtv.app.Config.Keys.OpenMessageActionsList:
  95. hs := mtv.app.MessagesTextView.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(mtv.app, m)
  108. mtv.app.SetRoot(actionsList, true)
  109. return nil
  110. case "Esc":
  111. mtv.app.SelectedMessage = -1
  112. mtv.app.SetFocus(mtv.app.MainFlex)
  113. mtv.app.MessagesTextView.
  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.MessagesTextView)
  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. })
  155. }
  156. // If the message contains attachments, add the appropriate actions to the actions list.
  157. if len(m.Attachments) != 0 {
  158. mal.AddItem("Open Attachment", "", 'o', mal.openAttachmentAction)
  159. mal.AddItem("Download Attachment", "", 'd', mal.downloadAttachmentAction)
  160. }
  161. // If the client user has the `MANAGE_MESSAGES` permission, add a new action to delete the message.
  162. if hasPermission(app.State, app.SelectedChannel.ID, discord.PermissionManageMessages) {
  163. mal.AddItem("Delete", "", 'd', mal.deleteAction)
  164. }
  165. mal.AddItem("Copy Content", "", 'c', mal.copyContentAction)
  166. mal.AddItem("Copy ID", "", 'i', mal.copyIDAction)
  167. mal.SetTitle("Press the Escape key to close")
  168. mal.SetTitleAlign(tview.AlignLeft)
  169. mal.SetBorder(true)
  170. mal.SetBorderPadding(0, 0, 1, 1)
  171. return mal
  172. }
  173. func (mal *MessageActionsList) replyAction() {
  174. mal.app.MessageInputField.SetTitle("Replying to " + mal.message.Author.Tag())
  175. mal.app.
  176. SetRoot(mal.app.MainFlex, true).
  177. SetFocus(mal.app.MessageInputField)
  178. }
  179. func (mal *MessageActionsList) mentionReplyAction() {
  180. mal.app.MessageInputField.SetTitle("[@] Replying to " + mal.message.Author.Tag())
  181. mal.app.
  182. SetRoot(mal.app.MainFlex, true).
  183. SetFocus(mal.app.MessageInputField)
  184. }
  185. func (mal *MessageActionsList) selectReplyAction() {
  186. ms, err := mal.app.State.Cabinet.Messages(mal.message.ChannelID)
  187. if err != nil {
  188. return
  189. }
  190. mal.app.SelectedMessage, _ = findMessageByID(ms, mal.message.ReferencedMessage.ID)
  191. mal.app.MessagesTextView.
  192. Highlight(mal.message.ReferencedMessage.ID.String()).
  193. ScrollToHighlight()
  194. mal.app.
  195. SetRoot(mal.app.MainFlex, true).
  196. SetFocus(mal.app.MessagesTextView)
  197. }
  198. func (mal *MessageActionsList) openAttachmentAction() {
  199. for _, a := range mal.message.Attachments {
  200. cacheDirPath, _ := os.UserCacheDir()
  201. f, err := os.Create(filepath.Join(cacheDirPath, a.Filename))
  202. if err != nil {
  203. return
  204. }
  205. defer f.Close()
  206. resp, err := http.Get(a.URL)
  207. if err != nil {
  208. return
  209. }
  210. d, err := io.ReadAll(resp.Body)
  211. if err != nil {
  212. return
  213. }
  214. f.Write(d)
  215. go open.Run(f.Name())
  216. }
  217. mal.app.
  218. SetRoot(mal.app.MainFlex, true).
  219. SetFocus(mal.app.MessagesTextView)
  220. }
  221. func (mal *MessageActionsList) downloadAttachmentAction() {
  222. for _, a := range mal.message.Attachments {
  223. f, err := os.Create(filepath.Join(mal.app.Config.AttachmentDownloadsDir, a.Filename))
  224. if err != nil {
  225. return
  226. }
  227. defer f.Close()
  228. resp, err := http.Get(a.URL)
  229. if err != nil {
  230. return
  231. }
  232. d, err := io.ReadAll(resp.Body)
  233. if err != nil {
  234. return
  235. }
  236. f.Write(d)
  237. }
  238. mal.app.
  239. SetRoot(mal.app.MainFlex, true).
  240. SetFocus(mal.app.MessagesTextView)
  241. }
  242. func (mal *MessageActionsList) deleteAction() {
  243. mal.app.MessagesTextView.Clear()
  244. err := mal.app.State.MessageRemove(mal.message.ChannelID, mal.message.ID)
  245. if err != nil {
  246. return
  247. }
  248. err = mal.app.State.DeleteMessage(mal.message.ChannelID, mal.message.ID, "Unknown")
  249. if err != nil {
  250. return
  251. }
  252. // The returned slice will be sorted from latest to oldest.
  253. ms, err := mal.app.State.Cabinet.Messages(mal.message.ChannelID)
  254. if err != nil {
  255. return
  256. }
  257. for i := len(ms) - 1; i >= 0; i-- {
  258. _, err = mal.app.MessagesTextView.Write(buildMessage(mal.app, ms[i]))
  259. if err != nil {
  260. return
  261. }
  262. }
  263. mal.app.
  264. SetRoot(mal.app.MainFlex, true).
  265. SetFocus(mal.app.MessagesTextView)
  266. }
  267. func (mal *MessageActionsList) copyContentAction() {
  268. err := clipboard.WriteAll(mal.message.Content)
  269. if err != nil {
  270. return
  271. }
  272. mal.app.SetRoot(mal.app.MainFlex, true)
  273. mal.app.SetFocus(mal.app.MessagesTextView)
  274. }
  275. func (mal *MessageActionsList) copyIDAction() {
  276. err := clipboard.WriteAll(mal.message.ID.String())
  277. if err != nil {
  278. return
  279. }
  280. mal.app.SetRoot(mal.app.MainFlex, true)
  281. mal.app.SetFocus(mal.app.MessagesTextView)
  282. }
  283. type MessageInput struct {
  284. *tview.InputField
  285. app *App
  286. }
  287. func NewMessageInput(app *App) *MessageInput {
  288. mi := &MessageInput{
  289. InputField: tview.NewInputField(),
  290. app: app,
  291. }
  292. mi.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
  293. mi.SetPlaceholder("Message...")
  294. mi.SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor))
  295. mi.SetTitleAlign(tview.AlignLeft)
  296. mi.SetBorder(true)
  297. mi.SetBorderPadding(0, 0, 1, 1)
  298. mi.SetInputCapture(mi.onInputCapture)
  299. return mi
  300. }
  301. func (mi *MessageInput) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
  302. switch e.Name() {
  303. case "Enter":
  304. if mi.app.SelectedChannel == nil {
  305. return nil
  306. }
  307. t := strings.TrimSpace(mi.app.MessageInputField.GetText())
  308. if t == "" {
  309. return nil
  310. }
  311. ms, err := mi.app.State.Messages(mi.app.SelectedChannel.ID, mi.app.Config.MessagesLimit)
  312. if err != nil {
  313. return nil
  314. }
  315. if len(mi.app.MessagesTextView.GetHighlights()) != 0 {
  316. mID, err := discord.ParseSnowflake(mi.app.MessagesTextView.GetHighlights()[0])
  317. if err != nil {
  318. return nil
  319. }
  320. _, m := findMessageByID(ms, discord.MessageID(mID))
  321. d := api.SendMessageData{
  322. Content: t,
  323. Reference: m.Reference,
  324. AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
  325. }
  326. // If the title of the message InputField widget has "[@]" as a prefix, send the message as a reply and mention the replied user.
  327. if strings.HasPrefix(mi.app.MessageInputField.GetTitle(), "[@]") {
  328. d.AllowedMentions.RepliedUser = option.True
  329. }
  330. go mi.app.State.SendMessageComplex(m.ChannelID, d)
  331. mi.app.SelectedMessage = -1
  332. mi.app.MessagesTextView.Highlight()
  333. mi.app.MessageInputField.SetTitle("")
  334. } else {
  335. go mi.app.State.SendMessage(mi.app.SelectedChannel.ID, t)
  336. }
  337. mi.app.MessageInputField.SetText("")
  338. return nil
  339. case "Ctrl+V":
  340. text, _ := clipboard.ReadAll()
  341. text = mi.app.MessageInputField.GetText() + text
  342. mi.app.MessageInputField.SetText(text)
  343. return nil
  344. case "Esc":
  345. mi.app.MessageInputField.
  346. SetText("").
  347. SetTitle("")
  348. mi.app.SetFocus(mi.app.MainFlex)
  349. mi.app.SelectedMessage = -1
  350. mi.app.MessagesTextView.Highlight()
  351. return nil
  352. case mi.app.Config.Keys.OpenExternalEditor:
  353. e := os.Getenv("EDITOR")
  354. if e == "" {
  355. return nil
  356. }
  357. f, err := os.CreateTemp(os.TempDir(), "discordo-*.md")
  358. if err != nil {
  359. return nil
  360. }
  361. defer os.Remove(f.Name())
  362. cmd := exec.Command(e, f.Name())
  363. cmd.Stdin = os.Stdin
  364. cmd.Stdout = os.Stdout
  365. mi.app.Suspend(func() {
  366. err = cmd.Run()
  367. if err != nil {
  368. return
  369. }
  370. })
  371. b, err := io.ReadAll(f)
  372. if err != nil {
  373. return nil
  374. }
  375. mi.app.MessageInputField.SetText(string(b))
  376. return nil
  377. }
  378. return e
  379. }