messages_list.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. package chat
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "github.com/ayn2op/tview/layers"
  7. "io"
  8. "log/slog"
  9. "net/http"
  10. "os"
  11. "path/filepath"
  12. "slices"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/ayn2op/discordo/internal/clipboard"
  17. "github.com/ayn2op/discordo/internal/config"
  18. "github.com/ayn2op/discordo/internal/consts"
  19. "github.com/ayn2op/discordo/internal/markdown"
  20. "github.com/ayn2op/discordo/internal/ui"
  21. "github.com/ayn2op/tview"
  22. "github.com/diamondburned/arikawa/v3/api"
  23. "github.com/diamondburned/arikawa/v3/discord"
  24. "github.com/diamondburned/arikawa/v3/gateway"
  25. "github.com/diamondburned/arikawa/v3/state"
  26. "github.com/diamondburned/arikawa/v3/utils/json/option"
  27. "github.com/diamondburned/ningen/v3/discordmd"
  28. "github.com/gdamore/tcell/v3"
  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.ScrollList
  36. cfg *config.Config
  37. chatView *View
  38. messages []discord.Message
  39. renderer *markdown.Renderer
  40. // itemByID caches fully built message TextViews. ScrollList may ask the
  41. // builder repeatedly during drawing, so we avoid reparsing markdown and
  42. // reconstructing TextViews unless a message actually changed.
  43. itemByID map[discord.MessageID]*tview.TextView
  44. fetchingMembers struct {
  45. mu sync.Mutex
  46. value bool
  47. count uint
  48. done chan struct{}
  49. }
  50. }
  51. func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
  52. ml := &messagesList{
  53. ScrollList: tview.NewScrollList(),
  54. cfg: cfg,
  55. chatView: chatView,
  56. renderer: markdown.NewRenderer(cfg.Theme.MessagesList),
  57. itemByID: make(map[discord.MessageID]*tview.TextView),
  58. }
  59. ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme)
  60. ml.SetTitle("Messages")
  61. ml.SetBuilder(ml.buildItem)
  62. ml.SetTrackEnd(true)
  63. ml.SetInputCapture(ml.onInputCapture)
  64. return ml
  65. }
  66. func (ml *messagesList) reset() {
  67. ml.messages = nil
  68. clear(ml.itemByID)
  69. ml.
  70. Clear().
  71. SetBuilder(ml.buildItem).
  72. SetTitle("")
  73. }
  74. func (ml *messagesList) setTitle(channel discord.Channel) {
  75. title := ui.ChannelToString(channel, ml.cfg.Icons)
  76. if topic := channel.Topic; topic != "" {
  77. title += " - " + topic
  78. }
  79. ml.SetTitle(title)
  80. }
  81. func (ml *messagesList) setMessages(messages []discord.Message) {
  82. ml.messages = slices.Clone(messages)
  83. slices.Reverse(ml.messages)
  84. // New channel payload / refetch: replace the cache wholesale to keep it in
  85. // lockstep with the current message slice.
  86. clear(ml.itemByID)
  87. ml.MarkDirty()
  88. }
  89. func (ml *messagesList) addMessage(message discord.Message) {
  90. ml.messages = append(ml.messages, message)
  91. // Defensive invalidation for ID reuse/edits delivered out-of-order.
  92. delete(ml.itemByID, message.ID)
  93. ml.MarkDirty()
  94. }
  95. func (ml *messagesList) setMessage(index int, message discord.Message) {
  96. if index < 0 || index >= len(ml.messages) {
  97. return
  98. }
  99. ml.messages[index] = message
  100. delete(ml.itemByID, message.ID)
  101. ml.MarkDirty()
  102. }
  103. func (ml *messagesList) deleteMessage(index int) {
  104. if index < 0 || index >= len(ml.messages) {
  105. return
  106. }
  107. delete(ml.itemByID, ml.messages[index].ID)
  108. ml.messages = append(ml.messages[:index], ml.messages[index+1:]...)
  109. ml.MarkDirty()
  110. }
  111. func (ml *messagesList) clearSelection() {
  112. ml.SetCursor(-1)
  113. }
  114. func (ml *messagesList) buildItem(index int, cursor int) tview.ScrollListItem {
  115. if index < 0 || index >= len(ml.messages) {
  116. return nil
  117. }
  118. message := ml.messages[index]
  119. tv, ok := ml.itemByID[message.ID]
  120. if !ok {
  121. tv = tview.NewTextView().
  122. SetWrap(true).
  123. SetWordWrap(true).
  124. SetDynamicColors(true).
  125. SetText(ml.renderMessage(message))
  126. ml.itemByID[message.ID] = tv
  127. }
  128. // Selection state is visual only; we mutate style on the cached view.
  129. if index == cursor {
  130. tv.SetTextStyle(ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style)
  131. } else {
  132. tv.SetTextStyle(ml.cfg.Theme.MessagesList.MessageStyle.Style)
  133. }
  134. return tv
  135. }
  136. func (ml *messagesList) renderMessage(message discord.Message) string {
  137. var b strings.Builder
  138. ml.writeMessage(&b, message)
  139. return b.String()
  140. }
  141. func (ml *messagesList) writeMessage(writer io.Writer, message discord.Message) {
  142. if ml.cfg.HideBlockedUsers {
  143. isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID)
  144. if isBlocked {
  145. io.WriteString(writer, "[:red:b]Blocked message[:-:-]")
  146. return
  147. }
  148. }
  149. // reset
  150. io.WriteString(writer, "[-:-:-]")
  151. switch message.Type {
  152. case discord.DefaultMessage:
  153. if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
  154. ml.drawForwardedMessage(writer, message)
  155. } else {
  156. ml.drawDefaultMessage(writer, message)
  157. }
  158. case discord.GuildMemberJoinMessage:
  159. ml.drawTimestamps(writer, message.Timestamp)
  160. ml.drawAuthor(writer, message)
  161. io.WriteString(writer, "joined the server.")
  162. case discord.InlinedReplyMessage:
  163. ml.drawReplyMessage(writer, message)
  164. case discord.ChannelPinnedMessage:
  165. ml.drawPinnedMessage(writer, message)
  166. default:
  167. ml.drawTimestamps(writer, message.Timestamp)
  168. ml.drawAuthor(writer, message)
  169. }
  170. }
  171. func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
  172. return ts.Time().In(time.Local).Format(ml.cfg.Timestamps.Format)
  173. }
  174. func (ml *messagesList) drawTimestamps(w io.Writer, ts discord.Timestamp) {
  175. io.WriteString(w, "[::d]")
  176. io.WriteString(w, ml.formatTimestamp(ts))
  177. io.WriteString(w, "[::D] ")
  178. }
  179. func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
  180. name := message.Author.DisplayOrUsername()
  181. foreground := tcell.ColorDefault
  182. // Webhooks do not have nicknames or roles.
  183. if message.GuildID.IsValid() && !message.WebhookID.IsValid() {
  184. member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID)
  185. if err != nil {
  186. slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
  187. } else {
  188. if member.Nick != "" {
  189. name = member.Nick
  190. }
  191. color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
  192. r, _ := ml.chatView.state.Cabinet.Role(message.GuildID, id)
  193. return r
  194. })
  195. if ok {
  196. foreground = tcell.NewHexColor(int32(color))
  197. }
  198. }
  199. }
  200. fmt.Fprintf(w, "[%s::b]%s[-::B] ", foreground, name)
  201. }
  202. func (ml *messagesList) drawContent(w io.Writer, message discord.Message) {
  203. c := []byte(tview.Escape(message.Content))
  204. if ml.chatView.cfg.Markdown {
  205. ast := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false)
  206. ml.renderer.Render(w, c, ast)
  207. } else {
  208. w.Write(c) // write the content as is
  209. }
  210. }
  211. func (ml *messagesList) drawSnapshotContent(w io.Writer, message discord.MessageSnapshotMessage) {
  212. c := []byte(tview.Escape(message.Content))
  213. // discordmd doesn't support MessageSnapshotMessage, so we just use write it as is. todo?
  214. w.Write(c)
  215. }
  216. func (ml *messagesList) drawDefaultMessage(w io.Writer, message discord.Message) {
  217. if ml.cfg.Timestamps.Enabled {
  218. ml.drawTimestamps(w, message.Timestamp)
  219. }
  220. ml.drawAuthor(w, message)
  221. ml.drawContent(w, message)
  222. if message.EditedTimestamp.IsValid() {
  223. io.WriteString(w, " [::d](edited)[::D]")
  224. }
  225. for _, a := range message.Attachments {
  226. io.WriteString(w, "\n")
  227. fg := ml.cfg.Theme.MessagesList.AttachmentStyle.GetForeground()
  228. bg := ml.cfg.Theme.MessagesList.AttachmentStyle.GetBackground()
  229. if ml.cfg.ShowAttachmentLinks {
  230. fmt.Fprintf(w, "[%s:%s]%s:\n%s[-:-]", fg, bg, a.Filename, a.URL)
  231. } else {
  232. fmt.Fprintf(w, "[%s:%s]%s[-:-]", fg, bg, a.Filename)
  233. }
  234. }
  235. }
  236. func (ml *messagesList) drawForwardedMessage(w io.Writer, message discord.Message) {
  237. ml.drawTimestamps(w, message.Timestamp)
  238. ml.drawAuthor(w, message)
  239. fmt.Fprintf(w, "[::d]%s [::-]", ml.cfg.Theme.MessagesList.ForwardedIndicator)
  240. ml.drawSnapshotContent(w, message.MessageSnapshots[0].Message)
  241. fmt.Fprintf(w, " [::d](%s)[-:-:-] ", ml.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp))
  242. }
  243. func (ml *messagesList) drawReplyMessage(w io.Writer, message discord.Message) {
  244. // indicator
  245. io.WriteString(w, "[::d]")
  246. io.WriteString(w, ml.cfg.Theme.MessagesList.ReplyIndicator)
  247. io.WriteString(w, " ")
  248. if m := message.ReferencedMessage; m != nil {
  249. m.GuildID = message.GuildID
  250. ml.drawAuthor(w, *m)
  251. ml.drawContent(w, *m)
  252. } else {
  253. io.WriteString(w, "Original message was deleted")
  254. }
  255. io.WriteString(w, "\n")
  256. // main
  257. ml.drawDefaultMessage(w, message)
  258. }
  259. func (ml *messagesList) drawPinnedMessage(w io.Writer, message discord.Message) {
  260. io.WriteString(w, message.Author.DisplayOrUsername())
  261. io.WriteString(w, " pinned a message.")
  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. list := tview.NewList().
  464. SetWrapAround(true).
  465. SetHighlightFullLine(true).
  466. ShowSecondaryText(false).
  467. SetDoneFunc(func() {
  468. ml.chatView.RemoveLayer(attachmentsListLayerName)
  469. ml.chatView.app.SetFocus(ml)
  470. })
  471. list.
  472. SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
  473. switch event.Name() {
  474. case ml.cfg.Keybinds.MessagesList.SelectUp:
  475. return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
  476. case ml.cfg.Keybinds.MessagesList.SelectDown:
  477. return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone)
  478. case ml.cfg.Keybinds.MessagesList.SelectTop:
  479. return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)
  480. case ml.cfg.Keybinds.MessagesList.SelectBottom:
  481. return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)
  482. }
  483. return event
  484. })
  485. list.Box = ui.ConfigureBox(list.Box, &ml.cfg.Theme)
  486. for i, a := range attachments {
  487. list.AddItem(a.Filename, "", rune('a'+i), func() {
  488. if strings.HasPrefix(a.ContentType, "image/") {
  489. go ml.openAttachment(a)
  490. } else {
  491. go ml.openURL(a.URL)
  492. }
  493. })
  494. }
  495. for i, u := range urls {
  496. list.AddItem(u, "", rune('1'+i), func() {
  497. go ml.openURL(u)
  498. })
  499. }
  500. ml.chatView.
  501. AddLayer(
  502. ui.Centered(list, 0, 0),
  503. layers.WithName(attachmentsListLayerName),
  504. layers.WithResize(true),
  505. layers.WithVisible(true),
  506. layers.WithOverlay(),
  507. ).
  508. SendToFront(attachmentsListLayerName)
  509. }
  510. func (ml *messagesList) openAttachment(attachment discord.Attachment) {
  511. resp, err := http.Get(attachment.URL)
  512. if err != nil {
  513. slog.Error("failed to fetch the attachment", "err", err, "url", attachment.URL)
  514. return
  515. }
  516. defer resp.Body.Close()
  517. path := filepath.Join(consts.CacheDir(), "attachments")
  518. if err := os.MkdirAll(path, os.ModePerm); err != nil {
  519. slog.Error("failed to create attachments dir", "err", err, "path", path)
  520. return
  521. }
  522. path = filepath.Join(path, attachment.Filename)
  523. file, err := os.Create(path)
  524. if err != nil {
  525. slog.Error("failed to create attachment file", "err", err, "path", path)
  526. return
  527. }
  528. defer file.Close()
  529. if _, err := io.Copy(file, resp.Body); err != nil {
  530. slog.Error("failed to copy attachment to file", "err", err)
  531. return
  532. }
  533. if err := open.Start(path); err != nil {
  534. slog.Error("failed to open attachment file", "err", err, "path", path)
  535. return
  536. }
  537. }
  538. func (ml *messagesList) openURL(url string) {
  539. if err := open.Start(url); err != nil {
  540. slog.Error("failed to open URL", "err", err, "url", url)
  541. }
  542. }
  543. func (ml *messagesList) reply(mention bool) {
  544. message, err := ml.selectedMessage()
  545. if err != nil {
  546. slog.Error("failed to get selected message", "err", err)
  547. return
  548. }
  549. name := message.Author.DisplayOrUsername()
  550. if message.GuildID.IsValid() {
  551. member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID)
  552. if err != nil {
  553. slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
  554. } else {
  555. if member.Nick != "" {
  556. name = member.Nick
  557. }
  558. }
  559. }
  560. data := ml.chatView.messageInput.sendMessageData
  561. data.Reference = &discord.MessageReference{MessageID: message.ID}
  562. data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False}
  563. title := "Replying to "
  564. if mention {
  565. data.AllowedMentions.RepliedUser = option.True
  566. title = "[@] " + title
  567. }
  568. ml.chatView.messageInput.sendMessageData = data
  569. ml.chatView.messageInput.SetTitle(title + name)
  570. ml.chatView.app.SetFocus(ml.chatView.messageInput)
  571. }
  572. func (ml *messagesList) edit() {
  573. message, err := ml.selectedMessage()
  574. if err != nil {
  575. slog.Error("failed to get selected message", "err", err)
  576. return
  577. }
  578. me, _ := ml.chatView.state.Cabinet.Me()
  579. if message.Author.ID != me.ID {
  580. slog.Error("failed to edit message; not the author", "channel_id", message.ChannelID, "message_id", message.ID)
  581. return
  582. }
  583. ml.chatView.messageInput.SetTitle("Editing")
  584. ml.chatView.messageInput.edit = true
  585. ml.chatView.messageInput.SetText(message.Content, true)
  586. ml.chatView.app.SetFocus(ml.chatView.messageInput)
  587. }
  588. func (ml *messagesList) confirmDelete() {
  589. onChoice := func(choice string) {
  590. if choice == "Yes" {
  591. ml.delete()
  592. }
  593. }
  594. ml.chatView.showConfirmModal(
  595. "Are you sure you want to delete this message?",
  596. []string{"Yes", "No"},
  597. onChoice,
  598. )
  599. }
  600. func (ml *messagesList) delete() {
  601. msg, err := ml.selectedMessage()
  602. if err != nil {
  603. slog.Error("failed to get selected message", "err", err)
  604. return
  605. }
  606. if msg.GuildID.IsValid() {
  607. me, _ := ml.chatView.state.Cabinet.Me()
  608. if msg.Author.ID != me.ID && !ml.chatView.state.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) {
  609. slog.Error("failed to delete message; missing relevant permissions", "channel_id", msg.ChannelID, "message_id", msg.ID)
  610. return
  611. }
  612. }
  613. selected := ml.chatView.SelectedChannel()
  614. if selected == nil {
  615. return
  616. }
  617. if err := ml.chatView.state.DeleteMessage(selected.ID, msg.ID, ""); err != nil {
  618. slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
  619. return
  620. }
  621. if err := ml.chatView.state.MessageRemove(selected.ID, msg.ID); err != nil {
  622. slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
  623. return
  624. }
  625. }
  626. func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {
  627. usersToFetch := make([]discord.UserID, 0, len(messages))
  628. seen := make(map[discord.UserID]struct{}, len(messages))
  629. for _, message := range messages {
  630. // Do not fetch member for a webhook message.
  631. if message.WebhookID.IsValid() {
  632. continue
  633. }
  634. if member, _ := ml.chatView.state.Cabinet.Member(guildID, message.Author.ID); member == nil {
  635. userID := message.Author.ID
  636. if _, ok := seen[userID]; !ok {
  637. seen[userID] = struct{}{}
  638. usersToFetch = append(usersToFetch, userID)
  639. }
  640. }
  641. }
  642. if len(usersToFetch) > 0 {
  643. err := ml.chatView.state.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{
  644. GuildIDs: []discord.GuildID{guildID},
  645. UserIDs: usersToFetch,
  646. })
  647. if err != nil {
  648. slog.Error("failed to request guild members", "guild_id", guildID, "err", err)
  649. return
  650. }
  651. ml.setFetchingChunk(true, 0)
  652. ml.waitForChunkEvent()
  653. }
  654. }
  655. func (ml *messagesList) setFetchingChunk(value bool, count uint) {
  656. ml.fetchingMembers.mu.Lock()
  657. defer ml.fetchingMembers.mu.Unlock()
  658. if ml.fetchingMembers.value == value {
  659. return
  660. }
  661. ml.fetchingMembers.value = value
  662. if value {
  663. ml.fetchingMembers.done = make(chan struct{})
  664. } else {
  665. ml.fetchingMembers.count = count
  666. close(ml.fetchingMembers.done)
  667. }
  668. }
  669. func (ml *messagesList) waitForChunkEvent() uint {
  670. ml.fetchingMembers.mu.Lock()
  671. if !ml.fetchingMembers.value {
  672. ml.fetchingMembers.mu.Unlock()
  673. return 0
  674. }
  675. ml.fetchingMembers.mu.Unlock()
  676. <-ml.fetchingMembers.done
  677. return ml.fetchingMembers.count
  678. }