messages_list.go 23 KB

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