messages_list.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  1. package chat
  2. import (
  3. "context"
  4. "errors"
  5. "io"
  6. "log/slog"
  7. "net/http"
  8. "os"
  9. "path/filepath"
  10. "slices"
  11. "strings"
  12. "sync"
  13. "time"
  14. "github.com/ayn2op/tview/layers"
  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/markdown"
  19. "github.com/ayn2op/discordo/internal/ui"
  20. "github.com/ayn2op/tview"
  21. "github.com/diamondburned/arikawa/v3/api"
  22. "github.com/diamondburned/arikawa/v3/discord"
  23. "github.com/diamondburned/arikawa/v3/gateway"
  24. "github.com/diamondburned/arikawa/v3/state"
  25. "github.com/diamondburned/arikawa/v3/utils/json/option"
  26. "github.com/diamondburned/ningen/v3/discordmd"
  27. "github.com/gdamore/tcell/v3"
  28. "github.com/gdamore/tcell/v3/color"
  29. "github.com/skratchdot/open-golang/open"
  30. "github.com/yuin/goldmark/ast"
  31. "github.com/yuin/goldmark/parser"
  32. "github.com/yuin/goldmark/text"
  33. )
  34. type messagesList struct {
  35. *tview.List
  36. cfg *config.Config
  37. chatView *View
  38. messages []discord.Message
  39. renderer *markdown.Renderer
  40. // itemByID caches unselected message TextViews.
  41. itemByID map[discord.MessageID]*tview.TextView
  42. fetchingMembers struct {
  43. mu sync.Mutex
  44. value bool
  45. count uint
  46. done chan struct{}
  47. }
  48. }
  49. func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
  50. ml := &messagesList{
  51. List: tview.NewList(),
  52. cfg: cfg,
  53. chatView: chatView,
  54. renderer: markdown.NewRenderer(cfg.Theme.MessagesList),
  55. itemByID: make(map[discord.MessageID]*tview.TextView),
  56. }
  57. ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme)
  58. ml.SetTitle("Messages")
  59. ml.SetBuilder(ml.buildItem)
  60. ml.SetTrackEnd(true)
  61. ml.SetInputCapture(ml.onInputCapture)
  62. return ml
  63. }
  64. func (ml *messagesList) reset() {
  65. ml.messages = nil
  66. clear(ml.itemByID)
  67. ml.
  68. Clear().
  69. SetBuilder(ml.buildItem).
  70. SetTitle("")
  71. }
  72. func (ml *messagesList) setTitle(channel discord.Channel) {
  73. title := ui.ChannelToString(channel, ml.cfg.Icons)
  74. if topic := channel.Topic; topic != "" {
  75. title += " - " + topic
  76. }
  77. ml.SetTitle(title)
  78. }
  79. func (ml *messagesList) setMessages(messages []discord.Message) {
  80. ml.messages = slices.Clone(messages)
  81. slices.Reverse(ml.messages)
  82. // New channel payload / refetch: replace the cache wholesale to keep it in
  83. // lockstep with the current message slice.
  84. clear(ml.itemByID)
  85. ml.MarkDirty()
  86. }
  87. func (ml *messagesList) addMessage(message discord.Message) {
  88. ml.messages = append(ml.messages, message)
  89. // Defensive invalidation for ID reuse/edits delivered out-of-order.
  90. delete(ml.itemByID, message.ID)
  91. ml.MarkDirty()
  92. }
  93. func (ml *messagesList) setMessage(index int, message discord.Message) {
  94. if index < 0 || index >= len(ml.messages) {
  95. return
  96. }
  97. ml.messages[index] = message
  98. delete(ml.itemByID, message.ID)
  99. ml.MarkDirty()
  100. }
  101. func (ml *messagesList) deleteMessage(index int) {
  102. if index < 0 || index >= len(ml.messages) {
  103. return
  104. }
  105. delete(ml.itemByID, ml.messages[index].ID)
  106. ml.messages = append(ml.messages[:index], ml.messages[index+1:]...)
  107. ml.MarkDirty()
  108. }
  109. func (ml *messagesList) clearSelection() {
  110. ml.SetCursor(-1)
  111. }
  112. func (ml *messagesList) buildItem(index int, cursor int) tview.ListItem {
  113. if index < 0 || index >= len(ml.messages) {
  114. return nil
  115. }
  116. message := ml.messages[index]
  117. if index == cursor {
  118. return tview.NewTextView().
  119. SetWrap(true).
  120. SetWordWrap(true).
  121. SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style))
  122. }
  123. item, ok := ml.itemByID[message.ID]
  124. if !ok {
  125. item = tview.NewTextView().
  126. SetWrap(true).
  127. SetWordWrap(true).
  128. SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style))
  129. ml.itemByID[message.ID] = item
  130. }
  131. return item
  132. }
  133. func (ml *messagesList) renderMessage(message discord.Message, baseStyle tcell.Style) []tview.Line {
  134. builder := tview.NewLineBuilder()
  135. ml.writeMessage(builder, message, baseStyle)
  136. return builder.Finish()
  137. }
  138. func (ml *messagesList) writeMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  139. if ml.cfg.HideBlockedUsers {
  140. isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID)
  141. if isBlocked {
  142. builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
  143. return
  144. }
  145. }
  146. switch message.Type {
  147. case discord.DefaultMessage:
  148. if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
  149. ml.drawForwardedMessage(builder, message, baseStyle)
  150. } else {
  151. ml.drawDefaultMessage(builder, message, baseStyle)
  152. }
  153. case discord.GuildMemberJoinMessage:
  154. ml.drawTimestamps(builder, message.Timestamp, baseStyle)
  155. ml.drawAuthor(builder, message, baseStyle)
  156. builder.Write("joined the server.", baseStyle)
  157. case discord.InlinedReplyMessage:
  158. ml.drawReplyMessage(builder, message, baseStyle)
  159. case discord.ChannelPinnedMessage:
  160. ml.drawPinnedMessage(builder, message, baseStyle)
  161. default:
  162. ml.drawTimestamps(builder, message.Timestamp, baseStyle)
  163. ml.drawAuthor(builder, message, baseStyle)
  164. }
  165. }
  166. func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
  167. return ts.Time().In(time.Local).Format(ml.cfg.Timestamps.Format)
  168. }
  169. func (ml *messagesList) drawTimestamps(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
  170. dimStyle := baseStyle.Dim(true)
  171. builder.Write(ml.formatTimestamp(ts)+" ", dimStyle)
  172. }
  173. func (ml *messagesList) drawAuthor(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  174. name := message.Author.DisplayOrUsername()
  175. foreground := tcell.ColorDefault
  176. // Webhooks do not have nicknames or roles.
  177. if message.GuildID.IsValid() && !message.WebhookID.IsValid() {
  178. member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID)
  179. if err != nil {
  180. slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
  181. } else {
  182. if member.Nick != "" {
  183. name = member.Nick
  184. }
  185. color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
  186. r, _ := ml.chatView.state.Cabinet.Role(message.GuildID, id)
  187. return r
  188. })
  189. if ok {
  190. foreground = tcell.NewHexColor(int32(color))
  191. }
  192. }
  193. }
  194. style := baseStyle.Foreground(foreground).Bold(true)
  195. builder.Write(name+" ", style)
  196. }
  197. func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  198. c := []byte(message.Content)
  199. if ml.chatView.cfg.Markdown {
  200. root := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false)
  201. lines := ml.renderer.RenderLines(c, root, baseStyle)
  202. if builder.HasCurrentLine() {
  203. for len(lines) > 1 && len(lines[0]) == 0 {
  204. lines = lines[1:]
  205. }
  206. }
  207. builder.AppendLines(lines)
  208. } else {
  209. builder.Write(message.Content, baseStyle)
  210. }
  211. }
  212. func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, message discord.MessageSnapshotMessage, baseStyle tcell.Style) {
  213. c := []byte(message.Content)
  214. // discordmd doesn't support MessageSnapshotMessage, so we just use write it as is. todo?
  215. builder.Write(string(c), baseStyle)
  216. }
  217. func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  218. if ml.cfg.Timestamps.Enabled {
  219. ml.drawTimestamps(builder, message.Timestamp, baseStyle)
  220. }
  221. ml.drawAuthor(builder, message, baseStyle)
  222. ml.drawContent(builder, message, baseStyle)
  223. if message.EditedTimestamp.IsValid() {
  224. dimStyle := baseStyle.Dim(true)
  225. builder.Write(" (edited)", dimStyle)
  226. }
  227. attachmentStyle := ui.MergeStyle(baseStyle, ml.cfg.Theme.MessagesList.AttachmentStyle.Style)
  228. for _, a := range message.Attachments {
  229. builder.NewLine()
  230. if ml.cfg.ShowAttachmentLinks {
  231. builder.Write(a.Filename+":\n"+a.URL, attachmentStyle)
  232. } else {
  233. builder.Write(a.Filename, attachmentStyle)
  234. }
  235. }
  236. }
  237. func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  238. dimStyle := baseStyle.Dim(true)
  239. ml.drawTimestamps(builder, message.Timestamp, baseStyle)
  240. ml.drawAuthor(builder, message, baseStyle)
  241. builder.Write(ml.cfg.Theme.MessagesList.ForwardedIndicator+" ", dimStyle)
  242. ml.drawSnapshotContent(builder, message.MessageSnapshots[0].Message, baseStyle)
  243. builder.Write(" ("+ml.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle)
  244. }
  245. func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  246. dimStyle := baseStyle.Dim(true)
  247. // indicator
  248. builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", dimStyle)
  249. if m := message.ReferencedMessage; m != nil {
  250. m.GuildID = message.GuildID
  251. ml.drawAuthor(builder, *m, dimStyle)
  252. ml.drawContent(builder, *m, dimStyle)
  253. } else {
  254. builder.Write("Original message was deleted", dimStyle)
  255. }
  256. builder.NewLine()
  257. // main
  258. ml.drawDefaultMessage(builder, message, baseStyle)
  259. }
  260. func (ml *messagesList) drawPinnedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  261. builder.Write(message.Author.DisplayOrUsername(), baseStyle)
  262. builder.Write(" pinned a message.", baseStyle)
  263. }
  264. func (ml *messagesList) selectedMessage() (*discord.Message, error) {
  265. if len(ml.messages) == 0 {
  266. return nil, errors.New("no messages available")
  267. }
  268. cursor := ml.Cursor()
  269. if cursor == -1 || cursor >= len(ml.messages) {
  270. return nil, errors.New("no message is currently selected")
  271. }
  272. return &ml.messages[cursor], nil
  273. }
  274. func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
  275. switch event.Name() {
  276. case ml.cfg.Keybinds.MessagesList.ScrollUp:
  277. ml.ScrollUp()
  278. return nil
  279. case ml.cfg.Keybinds.MessagesList.ScrollDown:
  280. ml.ScrollDown()
  281. return nil
  282. case ml.cfg.Keybinds.MessagesList.ScrollTop:
  283. ml.ScrollToStart()
  284. return nil
  285. case ml.cfg.Keybinds.MessagesList.ScrollBottom:
  286. ml.ScrollToEnd()
  287. return nil
  288. case ml.cfg.Keybinds.MessagesList.Cancel:
  289. ml.clearSelection()
  290. return nil
  291. case ml.cfg.Keybinds.MessagesList.SelectUp, ml.cfg.Keybinds.MessagesList.SelectDown, ml.cfg.Keybinds.MessagesList.SelectTop, ml.cfg.Keybinds.MessagesList.SelectBottom, ml.cfg.Keybinds.MessagesList.SelectReply:
  292. ml._select(event.Name())
  293. return nil
  294. case ml.cfg.Keybinds.MessagesList.YankID:
  295. ml.yankID()
  296. return nil
  297. case ml.cfg.Keybinds.MessagesList.YankContent:
  298. ml.yankContent()
  299. return nil
  300. case ml.cfg.Keybinds.MessagesList.YankURL:
  301. ml.yankURL()
  302. return nil
  303. case ml.cfg.Keybinds.MessagesList.Open:
  304. ml.open()
  305. return nil
  306. case ml.cfg.Keybinds.MessagesList.Reply:
  307. ml.reply(false)
  308. return nil
  309. case ml.cfg.Keybinds.MessagesList.ReplyMention:
  310. ml.reply(true)
  311. return nil
  312. case ml.cfg.Keybinds.MessagesList.Edit:
  313. ml.edit()
  314. return nil
  315. case ml.cfg.Keybinds.MessagesList.Delete:
  316. ml.delete()
  317. return nil
  318. case ml.cfg.Keybinds.MessagesList.DeleteConfirm:
  319. ml.confirmDelete()
  320. return nil
  321. }
  322. return event
  323. }
  324. func (ml *messagesList) _select(name string) {
  325. messages := ml.messages
  326. if len(messages) == 0 {
  327. return
  328. }
  329. cursor := ml.Cursor()
  330. switch name {
  331. case ml.cfg.Keybinds.MessagesList.SelectUp:
  332. switch {
  333. case cursor == -1:
  334. cursor = len(messages) - 1
  335. case cursor > 0:
  336. cursor--
  337. case cursor == 0:
  338. selectedChannel := ml.chatView.SelectedChannel()
  339. if selectedChannel == nil {
  340. return
  341. }
  342. channelID := selectedChannel.ID
  343. before := ml.messages[0].ID
  344. limit := uint(ml.cfg.MessagesLimit)
  345. messages, err := ml.chatView.state.MessagesBefore(channelID, before, limit)
  346. if err != nil {
  347. slog.Error("failed to fetch older messages", "err", err)
  348. return
  349. }
  350. if len(messages) == 0 {
  351. return
  352. }
  353. if guildID := selectedChannel.GuildID; guildID.IsValid() {
  354. ml.requestGuildMembers(guildID, messages)
  355. }
  356. older := slices.Clone(messages)
  357. slices.Reverse(older)
  358. // Defensive invalidation if Discord returns overlapping windows.
  359. for _, message := range older {
  360. delete(ml.itemByID, message.ID)
  361. }
  362. ml.messages = slices.Concat(older, ml.messages)
  363. cursor = len(messages) - 1
  364. }
  365. case ml.cfg.Keybinds.MessagesList.SelectDown:
  366. switch {
  367. case cursor == -1:
  368. cursor = len(messages) - 1
  369. case cursor < len(messages)-1:
  370. cursor++
  371. }
  372. case ml.cfg.Keybinds.MessagesList.SelectTop:
  373. cursor = 0
  374. case ml.cfg.Keybinds.MessagesList.SelectBottom:
  375. cursor = len(messages) - 1
  376. case ml.cfg.Keybinds.MessagesList.SelectReply:
  377. if cursor == -1 || cursor >= len(messages) {
  378. return
  379. }
  380. if ref := messages[cursor].ReferencedMessage; ref != nil {
  381. refIdx := slices.IndexFunc(messages, func(m discord.Message) bool {
  382. return m.ID == ref.ID
  383. })
  384. if refIdx != -1 {
  385. cursor = refIdx
  386. }
  387. }
  388. }
  389. ml.SetCursor(cursor)
  390. }
  391. func (ml *messagesList) yankID() {
  392. msg, err := ml.selectedMessage()
  393. if err != nil {
  394. slog.Error("failed to get selected message", "err", err)
  395. return
  396. }
  397. go clipboard.Write(clipboard.FmtText, []byte(msg.ID.String()))
  398. }
  399. func (ml *messagesList) yankContent() {
  400. msg, err := ml.selectedMessage()
  401. if err != nil {
  402. slog.Error("failed to get selected message", "err", err)
  403. return
  404. }
  405. go clipboard.Write(clipboard.FmtText, []byte(msg.Content))
  406. }
  407. func (ml *messagesList) yankURL() {
  408. msg, err := ml.selectedMessage()
  409. if err != nil {
  410. slog.Error("failed to get selected message", "err", err)
  411. return
  412. }
  413. go clipboard.Write(clipboard.FmtText, []byte(msg.URL()))
  414. }
  415. func (ml *messagesList) open() {
  416. msg, err := ml.selectedMessage()
  417. if err != nil {
  418. slog.Error("failed to get selected message", "err", err)
  419. return
  420. }
  421. var urls []string
  422. if msg.Content != "" {
  423. urls = extractURLs(msg.Content)
  424. }
  425. if len(urls) == 0 && len(msg.Attachments) == 0 {
  426. return
  427. }
  428. if len(urls)+len(msg.Attachments) == 1 {
  429. if len(urls) == 1 {
  430. go ml.openURL(urls[0])
  431. } else {
  432. attachment := msg.Attachments[0]
  433. if strings.HasPrefix(attachment.ContentType, "image/") {
  434. go ml.openAttachment(msg.Attachments[0])
  435. } else {
  436. go ml.openURL(attachment.URL)
  437. }
  438. }
  439. } else {
  440. ml.showAttachmentsList(urls, msg.Attachments)
  441. }
  442. }
  443. func extractURLs(content string) []string {
  444. src := []byte(content)
  445. node := parser.NewParser(
  446. parser.WithBlockParsers(discordmd.BlockParsers()...),
  447. parser.WithInlineParsers(discordmd.InlineParserWithLink()...),
  448. ).Parse(text.NewReader(src))
  449. var urls []string
  450. ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
  451. if entering {
  452. switch n := n.(type) {
  453. case *ast.AutoLink:
  454. urls = append(urls, string(n.URL(src)))
  455. case *ast.Link:
  456. urls = append(urls, string(n.Destination))
  457. }
  458. }
  459. return ast.WalkContinue, nil
  460. })
  461. return urls
  462. }
  463. func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord.Attachment) {
  464. type attachmentAction struct {
  465. label string
  466. shortcut rune
  467. open func()
  468. }
  469. closeList := func() {
  470. ml.chatView.RemoveLayer(attachmentsListLayerName)
  471. ml.chatView.app.SetFocus(ml)
  472. }
  473. var actions []attachmentAction
  474. for i, a := range attachments {
  475. attachment := a
  476. action := func() {
  477. if strings.HasPrefix(attachment.ContentType, "image/") {
  478. go ml.openAttachment(attachment)
  479. } else {
  480. go ml.openURL(attachment.URL)
  481. }
  482. }
  483. actions = append(actions, attachmentAction{
  484. label: attachment.Filename,
  485. shortcut: rune('a' + i),
  486. open: action,
  487. })
  488. }
  489. for i, u := range urls {
  490. url := u
  491. actions = append(actions, attachmentAction{
  492. label: url,
  493. shortcut: rune('1' + i),
  494. open: func() { go ml.openURL(url) },
  495. })
  496. }
  497. normalItems := make([]*tview.TextView, len(actions))
  498. selectedItems := make([]*tview.TextView, len(actions))
  499. for i, action := range actions {
  500. normalItems[i] = tview.NewTextView().
  501. SetScrollable(false).
  502. SetWrap(false).
  503. SetWordWrap(false).
  504. SetLines([]tview.Line{{{Text: action.label, Style: tcell.StyleDefault}}})
  505. selectedItems[i] = tview.NewTextView().
  506. SetScrollable(false).
  507. SetWrap(false).
  508. SetWordWrap(false).
  509. SetLines([]tview.Line{{{Text: action.label, Style: tcell.StyleDefault.Reverse(true)}}})
  510. }
  511. list := tview.NewList().
  512. SetSnapToItems(true).
  513. SetBuilder(func(index int, cursor int) tview.ListItem {
  514. if index < 0 || index >= len(actions) {
  515. return nil
  516. }
  517. if index == cursor {
  518. return selectedItems[index]
  519. }
  520. return normalItems[index]
  521. })
  522. list.Box = ui.ConfigureBox(list.Box, &ml.cfg.Theme)
  523. if len(actions) > 0 {
  524. list.SetCursor(0)
  525. }
  526. list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
  527. switch event.Name() {
  528. case ml.cfg.Keybinds.MessagesList.SelectUp:
  529. return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
  530. case ml.cfg.Keybinds.MessagesList.SelectDown:
  531. return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone)
  532. case ml.cfg.Keybinds.MessagesList.SelectTop:
  533. return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)
  534. case ml.cfg.Keybinds.MessagesList.SelectBottom:
  535. return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)
  536. case ml.cfg.Keybinds.MessagesList.Cancel:
  537. closeList()
  538. return nil
  539. }
  540. if event.Key() == tcell.KeyEnter || event.Key() == tcell.KeyRune && event.Str() == " " {
  541. index := list.Cursor()
  542. if index >= 0 && index < len(actions) {
  543. actions[index].open()
  544. closeList()
  545. }
  546. return nil
  547. }
  548. if event.Key() == tcell.KeyRune {
  549. key := event.Str()
  550. if key == "" {
  551. return event
  552. }
  553. ch := []rune(key)[0]
  554. for index, action := range actions {
  555. if action.shortcut == ch {
  556. list.SetCursor(index)
  557. actions[index].open()
  558. closeList()
  559. return nil
  560. }
  561. }
  562. }
  563. return event
  564. })
  565. ml.chatView.
  566. AddLayer(
  567. ui.Centered(list, 0, 0),
  568. layers.WithName(attachmentsListLayerName),
  569. layers.WithResize(true),
  570. layers.WithVisible(true),
  571. layers.WithOverlay(),
  572. ).
  573. SendToFront(attachmentsListLayerName)
  574. }
  575. func (ml *messagesList) openAttachment(attachment discord.Attachment) {
  576. resp, err := http.Get(attachment.URL)
  577. if err != nil {
  578. slog.Error("failed to fetch the attachment", "err", err, "url", attachment.URL)
  579. return
  580. }
  581. defer resp.Body.Close()
  582. path := filepath.Join(consts.CacheDir(), "attachments")
  583. if err := os.MkdirAll(path, os.ModePerm); err != nil {
  584. slog.Error("failed to create attachments dir", "err", err, "path", path)
  585. return
  586. }
  587. path = filepath.Join(path, attachment.Filename)
  588. file, err := os.Create(path)
  589. if err != nil {
  590. slog.Error("failed to create attachment file", "err", err, "path", path)
  591. return
  592. }
  593. defer file.Close()
  594. if _, err := io.Copy(file, resp.Body); err != nil {
  595. slog.Error("failed to copy attachment to file", "err", err)
  596. return
  597. }
  598. if err := open.Start(path); err != nil {
  599. slog.Error("failed to open attachment file", "err", err, "path", path)
  600. return
  601. }
  602. }
  603. func (ml *messagesList) openURL(url string) {
  604. if err := open.Start(url); err != nil {
  605. slog.Error("failed to open URL", "err", err, "url", url)
  606. }
  607. }
  608. func (ml *messagesList) reply(mention bool) {
  609. message, err := ml.selectedMessage()
  610. if err != nil {
  611. slog.Error("failed to get selected message", "err", err)
  612. return
  613. }
  614. name := message.Author.DisplayOrUsername()
  615. if message.GuildID.IsValid() {
  616. member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID)
  617. if err != nil {
  618. slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
  619. } else {
  620. if member.Nick != "" {
  621. name = member.Nick
  622. }
  623. }
  624. }
  625. data := ml.chatView.messageInput.sendMessageData
  626. data.Reference = &discord.MessageReference{MessageID: message.ID}
  627. data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False}
  628. title := "Replying to "
  629. if mention {
  630. data.AllowedMentions.RepliedUser = option.True
  631. title = "[@] " + title
  632. }
  633. ml.chatView.messageInput.sendMessageData = data
  634. ml.chatView.messageInput.SetTitle(title + name)
  635. ml.chatView.app.SetFocus(ml.chatView.messageInput)
  636. }
  637. func (ml *messagesList) edit() {
  638. message, err := ml.selectedMessage()
  639. if err != nil {
  640. slog.Error("failed to get selected message", "err", err)
  641. return
  642. }
  643. me, _ := ml.chatView.state.Cabinet.Me()
  644. if message.Author.ID != me.ID {
  645. slog.Error("failed to edit message; not the author", "channel_id", message.ChannelID, "message_id", message.ID)
  646. return
  647. }
  648. ml.chatView.messageInput.SetTitle("Editing")
  649. ml.chatView.messageInput.edit = true
  650. ml.chatView.messageInput.SetText(message.Content, true)
  651. ml.chatView.app.SetFocus(ml.chatView.messageInput)
  652. }
  653. func (ml *messagesList) confirmDelete() {
  654. onChoice := func(choice string) {
  655. if choice == "Yes" {
  656. ml.delete()
  657. }
  658. }
  659. ml.chatView.showConfirmModal(
  660. "Are you sure you want to delete this message?",
  661. []string{"Yes", "No"},
  662. onChoice,
  663. )
  664. }
  665. func (ml *messagesList) delete() {
  666. msg, err := ml.selectedMessage()
  667. if err != nil {
  668. slog.Error("failed to get selected message", "err", err)
  669. return
  670. }
  671. if msg.GuildID.IsValid() {
  672. me, _ := ml.chatView.state.Cabinet.Me()
  673. if msg.Author.ID != me.ID && !ml.chatView.state.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) {
  674. slog.Error("failed to delete message; missing relevant permissions", "channel_id", msg.ChannelID, "message_id", msg.ID)
  675. return
  676. }
  677. }
  678. selected := ml.chatView.SelectedChannel()
  679. if selected == nil {
  680. return
  681. }
  682. if err := ml.chatView.state.DeleteMessage(selected.ID, msg.ID, ""); err != nil {
  683. slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
  684. return
  685. }
  686. if err := ml.chatView.state.MessageRemove(selected.ID, msg.ID); err != nil {
  687. slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
  688. return
  689. }
  690. }
  691. func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {
  692. usersToFetch := make([]discord.UserID, 0, len(messages))
  693. seen := make(map[discord.UserID]struct{}, len(messages))
  694. for _, message := range messages {
  695. // Do not fetch member for a webhook message.
  696. if message.WebhookID.IsValid() {
  697. continue
  698. }
  699. if member, _ := ml.chatView.state.Cabinet.Member(guildID, message.Author.ID); member == nil {
  700. userID := message.Author.ID
  701. if _, ok := seen[userID]; !ok {
  702. seen[userID] = struct{}{}
  703. usersToFetch = append(usersToFetch, userID)
  704. }
  705. }
  706. }
  707. if len(usersToFetch) > 0 {
  708. err := ml.chatView.state.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{
  709. GuildIDs: []discord.GuildID{guildID},
  710. UserIDs: usersToFetch,
  711. })
  712. if err != nil {
  713. slog.Error("failed to request guild members", "guild_id", guildID, "err", err)
  714. return
  715. }
  716. ml.setFetchingChunk(true, 0)
  717. ml.waitForChunkEvent()
  718. }
  719. }
  720. func (ml *messagesList) setFetchingChunk(value bool, count uint) {
  721. ml.fetchingMembers.mu.Lock()
  722. defer ml.fetchingMembers.mu.Unlock()
  723. if ml.fetchingMembers.value == value {
  724. return
  725. }
  726. ml.fetchingMembers.value = value
  727. if value {
  728. ml.fetchingMembers.done = make(chan struct{})
  729. } else {
  730. ml.fetchingMembers.count = count
  731. close(ml.fetchingMembers.done)
  732. }
  733. }
  734. func (ml *messagesList) waitForChunkEvent() uint {
  735. ml.fetchingMembers.mu.Lock()
  736. if !ml.fetchingMembers.value {
  737. ml.fetchingMembers.mu.Unlock()
  738. return 0
  739. }
  740. ml.fetchingMembers.mu.Unlock()
  741. <-ml.fetchingMembers.done
  742. return ml.fetchingMembers.count
  743. }