messages_list.go 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318
  1. package chat
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "slices"
  8. "strings"
  9. "sync"
  10. "time"
  11. "unicode/utf8"
  12. "github.com/ayn2op/discordo/internal/clipboard"
  13. "github.com/ayn2op/discordo/internal/config"
  14. "github.com/ayn2op/discordo/internal/markdown"
  15. "github.com/ayn2op/discordo/internal/ui"
  16. "github.com/ayn2op/tview"
  17. "github.com/ayn2op/tview/help"
  18. "github.com/ayn2op/tview/keybind"
  19. "github.com/ayn2op/tview/list"
  20. "github.com/diamondburned/arikawa/v3/api"
  21. "github.com/diamondburned/arikawa/v3/discord"
  22. "github.com/diamondburned/arikawa/v3/gateway"
  23. "github.com/diamondburned/arikawa/v3/state"
  24. "github.com/diamondburned/arikawa/v3/utils/json/option"
  25. "github.com/diamondburned/ningen/v3/discordmd"
  26. "github.com/gdamore/tcell/v3"
  27. "github.com/gdamore/tcell/v3/color"
  28. "github.com/yuin/goldmark/ast"
  29. )
  30. type messagesList struct {
  31. *list.Model
  32. cfg *config.Config
  33. chatView *Model
  34. messages []discord.Message
  35. // rows is the virtual list model rendered by tview (message rows +
  36. // date-separator rows). It is rebuilt lazily when rowsDirty is true.
  37. rows []messagesListRow
  38. rowsDirty bool
  39. renderer *markdown.Renderer
  40. // itemByID caches unselected message TextViews.
  41. itemByID map[discord.MessageID]*tview.TextView
  42. attachmentsPicker *attachmentsPicker
  43. timestampsHidden bool
  44. repliesCollapsed bool
  45. expandedReplies map[discord.MessageID]struct{}
  46. lastWidth int
  47. // Help cache: invalidated when cursor changes or messages are modified.
  48. cachedHelpCursor int
  49. cachedHelpURLs []string
  50. cachedHelpDirty bool
  51. fetchingMembers struct {
  52. mu sync.Mutex
  53. value bool
  54. count uint
  55. done chan struct{}
  56. }
  57. }
  58. var _ help.KeyMap = (*messagesList)(nil)
  59. type messagesListRowKind uint8
  60. const (
  61. messagesListRowMessage messagesListRowKind = iota
  62. messagesListRowSeparator
  63. )
  64. type messagesListRow struct {
  65. kind messagesListRowKind
  66. messageIndex int
  67. timestamp discord.Timestamp
  68. }
  69. func newMessagesList(cfg *config.Config, chatView *Model) *messagesList {
  70. ml := &messagesList{
  71. Model: list.NewModel(),
  72. cfg: cfg,
  73. chatView: chatView,
  74. renderer: markdown.NewRenderer(cfg),
  75. itemByID: make(map[discord.MessageID]*tview.TextView),
  76. expandedReplies: make(map[discord.MessageID]struct{}),
  77. cachedHelpCursor: -1,
  78. cachedHelpDirty: true,
  79. }
  80. ml.attachmentsPicker = newAttachmentsPicker(cfg, chatView)
  81. ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme)
  82. ml.SetTitle("Messages")
  83. ml.SetBuilder(ml.buildItem)
  84. ml.SetChangedFunc(ml.onRowCursorChanged)
  85. ml.SetTrackEnd(true)
  86. ml.SetKeybinds(list.Keybinds{
  87. ScrollUp: cfg.Keybinds.MessagesList.ScrollUp.Keybind,
  88. ScrollDown: cfg.Keybinds.MessagesList.ScrollDown.Keybind,
  89. ScrollTop: cfg.Keybinds.MessagesList.ScrollTop.Keybind,
  90. ScrollBottom: cfg.Keybinds.MessagesList.ScrollBottom.Keybind,
  91. })
  92. ml.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility)
  93. ml.SetScrollBar(tview.NewScrollBar().
  94. SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style).
  95. SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style).
  96. SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet))
  97. return ml
  98. }
  99. func (ml *messagesList) reset() {
  100. ml.messages = nil
  101. ml.rows = nil
  102. ml.rowsDirty = false
  103. clear(ml.itemByID)
  104. clear(ml.expandedReplies)
  105. ml.
  106. Clear().
  107. SetBuilder(ml.buildItem).
  108. SetTitle("")
  109. }
  110. func (ml *messagesList) setTitle(channel discord.Channel) {
  111. title := ui.ChannelToString(channel, ml.cfg.Icons, ml.chatView.state)
  112. if topic := channel.Topic; topic != "" {
  113. title += " - " + topic
  114. }
  115. ml.SetTitle(title)
  116. }
  117. func (ml *messagesList) setMessages(messages []discord.Message) {
  118. ml.messages = slices.Clone(messages)
  119. slices.Reverse(ml.messages)
  120. ml.invalidateRows()
  121. // New channel payload / refetch: replace the cache wholesale to keep it in
  122. // lockstep with the current message slice.
  123. clear(ml.itemByID)
  124. }
  125. func (ml *messagesList) addMessage(message discord.Message) {
  126. ml.messages = append(ml.messages, message)
  127. ml.invalidateRows()
  128. // Defensive invalidation for ID reuse/edits delivered out-of-order.
  129. delete(ml.itemByID, message.ID)
  130. }
  131. func (ml *messagesList) setMessage(index int, message discord.Message) {
  132. if index < 0 || index >= len(ml.messages) {
  133. return
  134. }
  135. ml.messages[index] = message
  136. delete(ml.itemByID, message.ID)
  137. ml.invalidateRows()
  138. }
  139. func (ml *messagesList) deleteMessage(index int) {
  140. if index < 0 || index >= len(ml.messages) {
  141. return
  142. }
  143. delete(ml.itemByID, ml.messages[index].ID)
  144. ml.messages = append(ml.messages[:index], ml.messages[index+1:]...)
  145. ml.invalidateRows()
  146. }
  147. func (ml *messagesList) clearSelection() {
  148. ml.SetCursor(-1)
  149. }
  150. const wrapIndent = 2
  151. func (ml *messagesList) buildItem(index int, cursor int) list.Item {
  152. ml.ensureRows()
  153. // Invalidate cache when viewport width changes.
  154. _, _, innerWidth, _ := ml.InnerRect()
  155. if ml.lastWidth != 0 && ml.lastWidth != innerWidth {
  156. clear(ml.itemByID)
  157. }
  158. ml.lastWidth = innerWidth
  159. if index < 0 || index >= len(ml.rows) {
  160. return nil
  161. }
  162. row := ml.rows[index]
  163. if row.kind == messagesListRowSeparator {
  164. return ml.buildSeparatorItem(row.timestamp)
  165. }
  166. message := ml.messages[row.messageIndex]
  167. if index == cursor {
  168. lines := ml.renderMessage(message, ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style)
  169. return tview.NewTextView().
  170. SetWrap(false).
  171. SetLines(ml.indentWrappedLines(lines, innerWidth))
  172. }
  173. item, ok := ml.itemByID[message.ID]
  174. if !ok {
  175. lines := ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style)
  176. item = tview.NewTextView().
  177. SetWrap(false).
  178. SetLines(ml.indentWrappedLines(lines, innerWidth))
  179. ml.itemByID[message.ID] = item
  180. // Evict stale cache entries when the map grows too large.
  181. if len(ml.itemByID) > 500 {
  182. ml.evictStaleCache()
  183. }
  184. }
  185. return item
  186. }
  187. func (ml *messagesList) indentWrappedLines(lines []tview.Line, viewportWidth int) []tview.Line {
  188. if viewportWidth <= wrapIndent {
  189. return lines
  190. }
  191. wrapWidth := viewportWidth - wrapIndent
  192. indentStr := strings.Repeat(" ", wrapIndent)
  193. result := make([]tview.Line, 0, len(lines))
  194. for _, line := range lines {
  195. wrapped := wrapStyledLine(line, viewportWidth)
  196. if len(wrapped) <= 1 {
  197. result = append(result, wrapped...)
  198. continue
  199. }
  200. // First sub-line: no indent
  201. result = append(result, wrapped[0])
  202. // Re-wrap with reduced width for continuation lines
  203. remaining := make(tview.Line, 0)
  204. for _, w := range wrapped[1:] {
  205. remaining = append(remaining, w...)
  206. }
  207. rewrapped := wrapStyledLine(remaining, wrapWidth)
  208. for _, sub := range rewrapped {
  209. indented := make(tview.Line, 0, len(sub)+1)
  210. indented = append(indented, tview.NewSegment(indentStr, tcell.StyleDefault))
  211. indented = append(indented, sub...)
  212. result = append(result, indented)
  213. }
  214. }
  215. return result
  216. }
  217. func (ml *messagesList) evictStaleCache() {
  218. active := make(map[discord.MessageID]struct{}, len(ml.messages))
  219. for _, msg := range ml.messages {
  220. active[msg.ID] = struct{}{}
  221. }
  222. for id := range ml.itemByID {
  223. if _, ok := active[id]; !ok {
  224. delete(ml.itemByID, id)
  225. }
  226. }
  227. }
  228. func (ml *messagesList) renderMessage(message discord.Message, baseStyle tcell.Style) []tview.Line {
  229. builder := tview.NewLineBuilder()
  230. ml.writeMessage(builder, message, baseStyle)
  231. return builder.Finish()
  232. }
  233. func (ml *messagesList) buildSeparatorItem(ts discord.Timestamp) *tview.TextView {
  234. builder := tview.NewLineBuilder()
  235. ml.drawDateSeparator(builder, ts, ml.cfg.Theme.MessagesList.MessageStyle.Style)
  236. return tview.NewTextView().
  237. SetScrollable(false).
  238. SetWrap(false).
  239. SetWordWrap(false).
  240. SetLines(builder.Finish())
  241. }
  242. func (ml *messagesList) drawDateSeparator(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
  243. date := ts.Time().In(time.Local).Format(ml.cfg.DateSeparator.Format)
  244. label := " " + date + " "
  245. fillChar := ml.cfg.DateSeparator.Character
  246. dimStyle := baseStyle.Dim(true)
  247. _, _, width, _ := ml.InnerRect()
  248. if width <= 0 {
  249. builder.Write(strings.Repeat(fillChar, 8)+label+strings.Repeat(fillChar, 8), dimStyle)
  250. return
  251. }
  252. labelWidth := utf8.RuneCountInString(label)
  253. if width <= labelWidth {
  254. builder.Write(date, dimStyle)
  255. return
  256. }
  257. fillWidth := width - labelWidth
  258. left := fillWidth / 2
  259. right := fillWidth - left
  260. builder.Write(strings.Repeat(fillChar, left)+label+strings.Repeat(fillChar, right), dimStyle)
  261. }
  262. func (ml *messagesList) rebuildRows() {
  263. rows := make([]messagesListRow, 0, len(ml.messages)*2)
  264. for i := range ml.messages {
  265. // Always show a date separator before the first message, and between messages on different days.
  266. if ml.cfg.DateSeparator.Enabled && (i == 0 || !sameLocalDate(ml.messages[i-1].Timestamp, ml.messages[i].Timestamp)) {
  267. rows = append(rows, messagesListRow{
  268. kind: messagesListRowSeparator,
  269. timestamp: ml.messages[i].Timestamp,
  270. })
  271. }
  272. rows = append(rows, messagesListRow{
  273. kind: messagesListRowMessage,
  274. messageIndex: i,
  275. })
  276. }
  277. ml.rows = rows
  278. ml.rowsDirty = false
  279. }
  280. func (ml *messagesList) invalidateRows() {
  281. ml.rowsDirty = true
  282. ml.cachedHelpDirty = true
  283. }
  284. // ensureRows lazily rebuilds list rows. This avoids repeated O(n) row rebuild
  285. // work when multiple message mutations happen close together.
  286. func (ml *messagesList) ensureRows() {
  287. if !ml.rowsDirty {
  288. return
  289. }
  290. ml.rebuildRows()
  291. }
  292. func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool {
  293. ta := a.Time().In(time.Local)
  294. tb := b.Time().In(time.Local)
  295. return ta.Year() == tb.Year() && ta.YearDay() == tb.YearDay()
  296. }
  297. // Cursor returns the selected message index, skipping separator rows.
  298. func (ml *messagesList) Cursor() int {
  299. ml.ensureRows()
  300. rowIndex := ml.Model.Cursor()
  301. if rowIndex < 0 || rowIndex >= len(ml.rows) {
  302. return -1
  303. }
  304. row := ml.rows[rowIndex]
  305. if row.kind != messagesListRowMessage {
  306. return -1
  307. }
  308. return row.messageIndex
  309. }
  310. // SetCursor selects a message index and maps it to the corresponding row.
  311. func (ml *messagesList) SetCursor(index int) {
  312. ml.Model.SetCursor(ml.messageToRowIndex(index))
  313. }
  314. func (ml *messagesList) messageToRowIndex(messageIndex int) int {
  315. ml.ensureRows()
  316. if messageIndex < 0 || messageIndex >= len(ml.messages) {
  317. return -1
  318. }
  319. for i, row := range ml.rows {
  320. if row.kind == messagesListRowMessage && row.messageIndex == messageIndex {
  321. return i
  322. }
  323. }
  324. return -1
  325. }
  326. func (ml *messagesList) onRowCursorChanged(rowIndex int) {
  327. ml.ensureRows()
  328. if rowIndex < 0 || rowIndex >= len(ml.rows) || ml.rows[rowIndex].kind == messagesListRowMessage {
  329. return
  330. }
  331. target := ml.nearestMessageRowIndex(rowIndex)
  332. ml.Model.SetCursor(target)
  333. }
  334. // nearestMessageRowIndex expects rowIndex to be within bounds.
  335. func (ml *messagesList) nearestMessageRowIndex(rowIndex int) int {
  336. for i := rowIndex - 1; i >= 0; i-- {
  337. if ml.rows[i].kind == messagesListRowMessage {
  338. return i
  339. }
  340. }
  341. for i := rowIndex + 1; i < len(ml.rows); i++ {
  342. if ml.rows[i].kind == messagesListRowMessage {
  343. return i
  344. }
  345. }
  346. return -1
  347. }
  348. func (ml *messagesList) writeMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  349. if ml.cfg.HideBlockedUsers {
  350. isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID)
  351. if isBlocked {
  352. builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
  353. return
  354. }
  355. }
  356. switch message.Type {
  357. case discord.DefaultMessage:
  358. if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
  359. ml.drawForwardedMessage(builder, message, baseStyle)
  360. } else {
  361. ml.drawDefaultMessage(builder, message, baseStyle)
  362. }
  363. case discord.GuildMemberJoinMessage:
  364. ml.drawTimestamps(builder, message.Timestamp, baseStyle)
  365. ml.drawAuthor(builder, message, baseStyle)
  366. builder.Write("joined the server.", baseStyle)
  367. case discord.InlinedReplyMessage:
  368. ml.drawReplyMessage(builder, message, baseStyle)
  369. case discord.ChannelPinnedMessage:
  370. ml.drawPinnedMessage(builder, message, baseStyle)
  371. default:
  372. ml.drawTimestamps(builder, message.Timestamp, baseStyle)
  373. ml.drawAuthor(builder, message, baseStyle)
  374. }
  375. }
  376. func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
  377. return ts.Time().In(time.Local).Format(ml.cfg.Timestamps.Format)
  378. }
  379. func (ml *messagesList) drawTimestamps(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
  380. if ml.timestampsHidden {
  381. return
  382. }
  383. dimStyle := baseStyle.Dim(true)
  384. builder.Write(ml.formatTimestamp(ts)+" ", dimStyle)
  385. }
  386. func (ml *messagesList) drawAuthor(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  387. name := message.Author.DisplayOrUsername()
  388. foreground := tcell.ColorDefault
  389. if member := ml.memberForMessage(message); member != nil {
  390. if member.Nick != "" {
  391. name = member.Nick
  392. }
  393. color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
  394. r, _ := ml.chatView.state.Cabinet.Role(message.GuildID, id)
  395. return r
  396. })
  397. if ok {
  398. foreground = tcell.NewHexColor(int32(color))
  399. }
  400. }
  401. style := baseStyle.Foreground(foreground).Bold(true)
  402. builder.Write(name+" ", style)
  403. }
  404. func (ml *messagesList) memberForMessage(message discord.Message) *discord.Member {
  405. // Webhooks do not have nicknames or roles.
  406. if !message.GuildID.IsValid() || message.WebhookID.IsValid() {
  407. return nil
  408. }
  409. member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID)
  410. if err != nil {
  411. slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
  412. return nil
  413. }
  414. return member
  415. }
  416. func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  417. lines, root := ml.renderContentLines(message, baseStyle)
  418. if ml.chatView.cfg.Markdown.Enabled && builder.HasCurrentLine() {
  419. startsWithCodeBlock := false
  420. if root != nil {
  421. if first := root.FirstChild(); first != nil {
  422. _, startsWithCodeBlock = first.(*ast.FencedCodeBlock)
  423. }
  424. }
  425. if startsWithCodeBlock {
  426. // Keep code blocks visually separate from "timestamp + author".
  427. builder.NewLine()
  428. for len(lines) > 0 && len(lines[0]) == 0 {
  429. lines = lines[1:]
  430. }
  431. } else {
  432. for len(lines) > 1 && len(lines[0]) == 0 {
  433. lines = lines[1:]
  434. }
  435. }
  436. }
  437. builder.AppendLines(lines)
  438. }
  439. func (ml *messagesList) renderContentLines(message discord.Message, baseStyle tcell.Style) ([]tview.Line, ast.Node) {
  440. return ml.renderContentLinesWithMarkdown(message, baseStyle, false)
  441. }
  442. func (ml *messagesList) renderContentLinesWithMarkdown(message discord.Message, baseStyle tcell.Style, forceMarkdown bool) ([]tview.Line, ast.Node) {
  443. // Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both.
  444. if forceMarkdown || ml.chatView.cfg.Markdown.Enabled {
  445. c := []byte(message.Content)
  446. root := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false)
  447. return ml.renderer.RenderLines(c, root, baseStyle), root
  448. }
  449. b := tview.NewLineBuilder()
  450. b.Write(message.Content, baseStyle)
  451. return b.Finish(), nil
  452. }
  453. func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tcell.Style) {
  454. // Convert discord.MessageSnapshotMessage to discord.Message with common fields.
  455. message := discord.Message{
  456. Type: snapshot.Type,
  457. Content: snapshot.Content,
  458. Embeds: snapshot.Embeds,
  459. Attachments: snapshot.Attachments,
  460. Timestamp: snapshot.Timestamp,
  461. EditedTimestamp: snapshot.EditedTimestamp,
  462. Flags: snapshot.Flags,
  463. Mentions: snapshot.Mentions,
  464. MentionRoleIDs: snapshot.MentionRoleIDs,
  465. Stickers: snapshot.Stickers,
  466. Components: snapshot.Components,
  467. ChannelID: parent.ChannelID,
  468. GuildID: parent.GuildID,
  469. }
  470. ml.drawContent(builder, message, baseStyle)
  471. }
  472. func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  473. if ml.cfg.Timestamps.Enabled && !ml.timestampsHidden {
  474. ml.drawTimestamps(builder, message.Timestamp, baseStyle)
  475. }
  476. ml.drawAuthor(builder, message, baseStyle)
  477. ml.drawContent(builder, message, baseStyle)
  478. if message.EditedTimestamp.IsValid() {
  479. dimStyle := baseStyle.Dim(true)
  480. builder.Write(" (edited)", dimStyle)
  481. }
  482. ml.drawEmbeds(builder, message, baseStyle)
  483. attachmentStyle := ui.MergeStyle(baseStyle, ml.cfg.Theme.MessagesList.AttachmentStyle.Style)
  484. for _, a := range message.Attachments {
  485. builder.NewLine()
  486. builder.Write(ui.CdnDisplayName(a.Filename), attachmentStyle.Url(a.URL))
  487. }
  488. // Thread indicator
  489. if message.Thread != nil {
  490. thread := *message.Thread
  491. builder.NewLine()
  492. dimStyle := baseStyle.Dim(true)
  493. threadLabel := "Thread: " + thread.Name
  494. builder.Write(threadLabel, dimStyle)
  495. }
  496. ml.drawReactions(builder, message, baseStyle)
  497. }
  498. func (ml *messagesList) drawReactions(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  499. if len(message.Reactions) == 0 {
  500. return
  501. }
  502. builder.NewLine()
  503. dimStyle := baseStyle.Dim(true)
  504. boldStyle := baseStyle.Bold(true)
  505. for i, r := range message.Reactions {
  506. if i > 0 {
  507. builder.Write(" ", dimStyle)
  508. }
  509. name := r.Emoji.Name
  510. if !r.Emoji.ID.IsValid() {
  511. // Unicode emoji — use name directly
  512. } else if name != "" {
  513. name = ":" + name + ":"
  514. }
  515. style := dimStyle
  516. if r.Me {
  517. style = boldStyle
  518. }
  519. builder.Write(fmt.Sprintf("%s %d", name, r.Count), style)
  520. }
  521. }
  522. func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  523. if len(message.Embeds) == 0 {
  524. return
  525. }
  526. contentListURLs := extractURLs(message.Content)
  527. contentURLs := make(map[string]struct{}, len(contentListURLs))
  528. for _, u := range contentListURLs {
  529. contentURLs[u] = struct{}{}
  530. }
  531. lineStyles := embedLineStyles(baseStyle, ml.cfg.Theme.MessagesList.Embeds)
  532. defaultBarStyle := baseStyle.Dim(true)
  533. prefixText := " ▎ "
  534. prefixWidth := tview.TaggedStringWidth(prefixText)
  535. _, _, innerWidth, _ := ml.InnerRect()
  536. // Wrap against the current list viewport. This keeps embed wrapping stable even when sidebars/panes are resized.
  537. wrapWidth := max(innerWidth-prefixWidth, 1)
  538. for _, embed := range message.Embeds {
  539. // Skip embeds that are just link previews for URLs already shown in message content.
  540. if embed.URL != "" {
  541. if _, inContent := contentURLs[string(embed.URL)]; inContent && isLinkPreviewEmbed(embed) {
  542. continue
  543. }
  544. }
  545. lines := embedLines(embed, contentURLs)
  546. if len(lines) == 0 {
  547. continue
  548. }
  549. embedContentLines := make([]tview.Line, 0, len(lines)*2)
  550. barStyle := defaultBarStyle
  551. if embed.Color != discord.NullColor && embed.Color != 0 {
  552. barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color.Uint32())))
  553. }
  554. prefix := tview.NewSegment(prefixText, barStyle)
  555. builder.NewLine()
  556. for _, line := range lines {
  557. if strings.TrimSpace(line.Text) == "" {
  558. continue
  559. }
  560. msg := message
  561. msg.Content = line.Text
  562. lineStyle := lineStyles[line.Kind]
  563. // Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled.
  564. rendered, _ := ml.renderContentLinesWithMarkdown(msg, lineStyle, line.Kind == embedLineDescription)
  565. for _, renderedLine := range rendered {
  566. if line.URL != "" {
  567. renderedLine = lineWithURL(renderedLine, line.URL)
  568. }
  569. // Prefix must be applied after wrapping so every visual line keeps the embed bar marker ("▎"), not only the first logical line.
  570. for _, wrapped := range wrapStyledLine(renderedLine, wrapWidth) {
  571. prefixed := make(tview.Line, 0, len(wrapped)+1)
  572. prefixed = append(prefixed, prefix)
  573. prefixed = append(prefixed, wrapped...)
  574. embedContentLines = append(embedContentLines, prefixed)
  575. }
  576. }
  577. }
  578. if len(embedContentLines) > 0 {
  579. builder.AppendLines(embedContentLines)
  580. }
  581. }
  582. }
  583. func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  584. dimStyle := baseStyle.Dim(true)
  585. ml.drawTimestamps(builder, message.Timestamp, baseStyle)
  586. ml.drawAuthor(builder, message, baseStyle)
  587. builder.Write(ml.cfg.Theme.MessagesList.ForwardedIndicator+" ", dimStyle)
  588. ml.drawSnapshotContent(builder, message, message.MessageSnapshots[0].Message, baseStyle)
  589. builder.Write(" ("+ml.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle)
  590. }
  591. func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  592. _, expanded := ml.expandedReplies[message.ID]
  593. if ml.repliesCollapsed && !expanded {
  594. // Collapsed: show indicator only, then main message on same line
  595. replyStyle := baseStyle.Dim(true).Italic(true)
  596. builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", replyStyle)
  597. ml.drawDefaultMessage(builder, message, baseStyle)
  598. return
  599. }
  600. replyStyle := baseStyle.Dim(true).Italic(true)
  601. // indicator
  602. builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", replyStyle)
  603. if m := message.ReferencedMessage; m != nil {
  604. m.GuildID = message.GuildID
  605. ml.drawAuthor(builder, *m, replyStyle)
  606. ml.drawContent(builder, *m, replyStyle)
  607. } else {
  608. builder.Write("Original message was deleted", replyStyle)
  609. }
  610. builder.NewLine()
  611. // main
  612. ml.drawDefaultMessage(builder, message, baseStyle)
  613. }
  614. func (ml *messagesList) drawPinnedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
  615. builder.Write(message.Author.DisplayOrUsername(), baseStyle)
  616. builder.Write(" pinned a message.", baseStyle)
  617. }
  618. func (ml *messagesList) selectedMessage() (*discord.Message, error) {
  619. if len(ml.messages) == 0 {
  620. return nil, errors.New("no messages available")
  621. }
  622. cursor := ml.Cursor()
  623. if cursor == -1 || cursor >= len(ml.messages) {
  624. return nil, errors.New("no message is currently selected")
  625. }
  626. return &ml.messages[cursor], nil
  627. }
  628. func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
  629. switch event := event.(type) {
  630. case *tview.KeyEvent:
  631. switch {
  632. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind),
  633. keybind.Matches(event, ml.cfg.Keybinds.Back.Keybind):
  634. ml.clearSelection()
  635. if cmd := ml.chatView.focusGuildsTree(); cmd != nil {
  636. return cmd
  637. }
  638. return ml.chatView.focusMessageInput()
  639. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind),
  640. keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowUp.Keybind):
  641. return ml.selectUp()
  642. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind),
  643. keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowDown.Keybind):
  644. ml.selectDown()
  645. return nil
  646. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowLeft.Keybind),
  647. keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowRight.Keybind):
  648. if cmd := ml.chatView.focusGuildsTree(); cmd != nil {
  649. return cmd
  650. }
  651. return nil
  652. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectTop.Keybind):
  653. ml.selectTop()
  654. return nil
  655. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectBottom.Keybind):
  656. ml.selectBottom()
  657. return nil
  658. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectReply.Keybind):
  659. ml.selectReply()
  660. return nil
  661. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankID.Keybind):
  662. return ml.yankMessageID()
  663. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankContent.Keybind):
  664. return ml.yankContent()
  665. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankURL.Keybind):
  666. return ml.yankURL()
  667. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Open.Keybind):
  668. ml.open()
  669. return nil
  670. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SaveImage.Keybind):
  671. ml.saveImage()
  672. return nil
  673. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Reply.Keybind):
  674. ml.reply(false)
  675. return nil
  676. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ReplyMention.Keybind):
  677. ml.reply(true)
  678. return nil
  679. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Edit.Keybind):
  680. ml.editSelectedMessage()
  681. return nil
  682. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Delete.Keybind):
  683. return ml.deleteSelectedMessage()
  684. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind):
  685. ml.confirmDelete()
  686. return nil
  687. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.OpenThread.Keybind):
  688. ml.openThread()
  689. return nil
  690. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Search.Keybind):
  691. ml.chatView.openSearch()
  692. return nil
  693. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.UserInfo.Keybind):
  694. ml.showUserInfo()
  695. return nil
  696. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ToggleTimestamps.Keybind):
  697. ml.timestampsHidden = !ml.timestampsHidden
  698. clear(ml.itemByID)
  699. ml.invalidateRows()
  700. return nil
  701. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ToggleReplies.Keybind):
  702. ml.repliesCollapsed = !ml.repliesCollapsed
  703. if !ml.repliesCollapsed {
  704. clear(ml.expandedReplies)
  705. }
  706. clear(ml.itemByID)
  707. ml.invalidateRows()
  708. return nil
  709. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.AddReaction.Keybind):
  710. ml.addReaction()
  711. return nil
  712. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ViewReactions.Keybind):
  713. ml.showReactionViewer()
  714. return nil
  715. }
  716. return ml.Model.HandleEvent(event)
  717. case *olderMessagesLoadedEvent:
  718. selectedChannel := ml.chatView.SelectedChannel()
  719. if selectedChannel == nil || selectedChannel.ID != event.ChannelID {
  720. return nil
  721. }
  722. // Defensive invalidation if Discord returns overlapping windows.
  723. for _, message := range event.Older {
  724. delete(ml.itemByID, message.ID)
  725. }
  726. ml.messages = slices.Concat(event.Older, ml.messages)
  727. ml.invalidateRows()
  728. ml.SetCursor(len(event.Older) - 1)
  729. return nil
  730. }
  731. return ml.Model.HandleEvent(event)
  732. }
  733. func (ml *messagesList) selectUp() tview.Command {
  734. messages := ml.messages
  735. if len(messages) == 0 {
  736. return nil
  737. }
  738. cursor := ml.Cursor()
  739. switch {
  740. case cursor == -1:
  741. cursor = len(messages) - 1
  742. case cursor > 0:
  743. cursor--
  744. case cursor == 0:
  745. return ml.fetchOlderMessages()
  746. }
  747. ml.SetCursor(cursor)
  748. return nil
  749. }
  750. func (ml *messagesList) selectDown() {
  751. messages := ml.messages
  752. if len(messages) == 0 {
  753. return
  754. }
  755. cursor := ml.Cursor()
  756. switch {
  757. case cursor == -1:
  758. cursor = len(messages) - 1
  759. case cursor < len(messages)-1:
  760. cursor++
  761. }
  762. ml.SetCursor(cursor)
  763. }
  764. func (ml *messagesList) selectTop() {
  765. if len(ml.messages) == 0 {
  766. return
  767. }
  768. ml.SetCursor(0)
  769. }
  770. func (ml *messagesList) selectBottom() {
  771. if len(ml.messages) == 0 {
  772. return
  773. }
  774. ml.SetCursor(len(ml.messages) - 1)
  775. }
  776. func (ml *messagesList) selectReply() {
  777. messages := ml.messages
  778. if len(messages) == 0 {
  779. return
  780. }
  781. cursor := ml.Cursor()
  782. if cursor == -1 || cursor >= len(messages) {
  783. return
  784. }
  785. if ref := messages[cursor].ReferencedMessage; ref != nil {
  786. refIdx := slices.IndexFunc(messages, func(m discord.Message) bool {
  787. return m.ID == ref.ID
  788. })
  789. if refIdx != -1 {
  790. ml.SetCursor(refIdx)
  791. }
  792. }
  793. }
  794. func (ml *messagesList) fetchOlderMessages() tview.Command {
  795. selectedChannel := ml.chatView.SelectedChannel()
  796. if selectedChannel == nil {
  797. return nil
  798. }
  799. channelID := selectedChannel.ID
  800. before := ml.messages[0].ID
  801. limit := uint(ml.cfg.MessagesLimit)
  802. return func() tview.Event {
  803. messages, err := ml.chatView.state.MessagesBefore(channelID, before, limit)
  804. if err != nil {
  805. slog.Error("failed to fetch older messages", "err", err)
  806. return nil
  807. }
  808. if len(messages) == 0 {
  809. return nil
  810. }
  811. if guildID := selectedChannel.GuildID; guildID.IsValid() {
  812. ml.requestGuildMembers(guildID, messages)
  813. }
  814. older := slices.Clone(messages)
  815. slices.Reverse(older)
  816. return newOlderMessagesLoadedEvent(channelID, older)
  817. }
  818. }
  819. func (ml *messagesList) yankMessageID() tview.Command {
  820. msg, err := ml.selectedMessage()
  821. if err != nil {
  822. slog.Error("failed to get selected message", "err", err)
  823. return nil
  824. }
  825. return func() tview.Event {
  826. if err := clipboard.Write(clipboard.FmtText, []byte(msg.ID.String())); err != nil {
  827. slog.Error("failed to copy message id", "err", err)
  828. }
  829. return nil
  830. }
  831. }
  832. func (ml *messagesList) yankContent() tview.Command {
  833. msg, err := ml.selectedMessage()
  834. if err != nil {
  835. slog.Error("failed to get selected message", "err", err)
  836. return nil
  837. }
  838. return func() tview.Event {
  839. if err := clipboard.Write(clipboard.FmtText, []byte(msg.Content)); err != nil {
  840. slog.Error("failed to copy message content", "err", err)
  841. }
  842. return nil
  843. }
  844. }
  845. func (ml *messagesList) yankURL() tview.Command {
  846. msg, err := ml.selectedMessage()
  847. if err != nil {
  848. slog.Error("failed to get selected message", "err", err)
  849. return nil
  850. }
  851. return func() tview.Event {
  852. if err := clipboard.Write(clipboard.FmtText, []byte(msg.URL())); err != nil {
  853. slog.Error("failed to copy message url", "err", err)
  854. }
  855. return nil
  856. }
  857. }
  858. func (ml *messagesList) open() {
  859. msg, err := ml.selectedMessage()
  860. if err != nil {
  861. slog.Error("failed to get selected message", "err", err)
  862. return
  863. }
  864. urls := messageURLs(*msg)
  865. if len(urls) == 0 && len(msg.Attachments) == 0 {
  866. return
  867. }
  868. if len(urls)+len(msg.Attachments) == 1 {
  869. if len(urls) == 1 {
  870. go ml.openURL(urls[0])
  871. } else {
  872. attachment := msg.Attachments[0]
  873. if strings.HasPrefix(attachment.ContentType, "image/") {
  874. go ml.openAttachment(msg.Attachments[0])
  875. } else {
  876. go ml.openURL(attachment.URL)
  877. }
  878. }
  879. } else {
  880. ml.showAttachmentsList(urls, msg.Attachments)
  881. }
  882. }
  883. func (ml *messagesList) openThread() {
  884. msg, err := ml.selectedMessage()
  885. if err != nil {
  886. slog.Error("failed to get selected message", "err", err)
  887. return
  888. }
  889. if msg.Thread == nil {
  890. return
  891. }
  892. thread := *msg.Thread
  893. node := ml.chatView.guildsTree.findNodeByChannelID(thread.ID)
  894. if node == nil {
  895. return
  896. }
  897. ml.chatView.guildsTree.expandPathToNode(node)
  898. ml.chatView.guildsTree.SetCurrentNode(node)
  899. ml.chatView.guildsTree.onSelected(node)
  900. }
  901. func (ml *messagesList) reply(mention bool) {
  902. message, err := ml.selectedMessage()
  903. if err != nil {
  904. slog.Error("failed to get selected message", "err", err)
  905. return
  906. }
  907. name := message.Author.DisplayOrUsername()
  908. if member := ml.memberForMessage(*message); member != nil && member.Nick != "" {
  909. name = member.Nick
  910. }
  911. data := ml.chatView.messageInput.sendMessageData
  912. data.Reference = &discord.MessageReference{MessageID: message.ID}
  913. data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False}
  914. title := "Replying to "
  915. if mention {
  916. data.AllowedMentions.RepliedUser = option.True
  917. title = "[@] " + title
  918. }
  919. ml.chatView.messageInput.sendMessageData = data
  920. ml.chatView.messageInput.SetTitle(title + name)
  921. ml.chatView.app.SetFocus(ml.chatView.messageInput)
  922. }
  923. func (ml *messagesList) editSelectedMessage() {
  924. message, err := ml.selectedMessage()
  925. if err != nil {
  926. slog.Error("failed to get selected message", "err", err)
  927. return
  928. }
  929. me, err := ml.chatView.state.Cabinet.Me()
  930. if err != nil {
  931. slog.Error("failed to get current user", "err", err)
  932. return
  933. }
  934. if message.Author.ID != me.ID {
  935. slog.Error("failed to edit message; not the author", "channel_id", message.ChannelID, "message_id", message.ID)
  936. return
  937. }
  938. ml.chatView.messageInput.SetTitle("Editing")
  939. ml.chatView.messageInput.edit = true
  940. ml.chatView.messageInput.SetText(message.Content, true)
  941. ml.chatView.app.SetFocus(ml.chatView.messageInput)
  942. }
  943. func (ml *messagesList) confirmDelete() {
  944. onChoice := func(choice string) {
  945. if choice == "Yes" {
  946. if command := ml.deleteSelectedMessage(); command != nil {
  947. command()
  948. }
  949. }
  950. }
  951. ml.chatView.showConfirmModal(
  952. "Are you sure you want to delete this message?",
  953. []string{"Yes", "No"},
  954. onChoice,
  955. )
  956. }
  957. func (ml *messagesList) deleteSelectedMessage() tview.Command {
  958. selectedMessage, err := ml.selectedMessage()
  959. if err != nil {
  960. slog.Error("failed to get selected message", "err", err)
  961. return nil
  962. }
  963. return func() tview.Event {
  964. if selectedMessage.GuildID.IsValid() {
  965. me, err := ml.chatView.state.Cabinet.Me()
  966. if err != nil {
  967. slog.Error("failed to get current user", "err", err)
  968. return nil
  969. }
  970. if selectedMessage.Author.ID != me.ID && !ml.chatView.state.HasPermissions(selectedMessage.ChannelID, discord.PermissionManageMessages) {
  971. slog.Error("failed to delete message; missing relevant permissions", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID)
  972. return nil
  973. }
  974. }
  975. if err := ml.chatView.state.DeleteMessage(selectedMessage.ChannelID, selectedMessage.ID, ""); err != nil {
  976. slog.Error("failed to delete message", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID, "err", err)
  977. return nil
  978. }
  979. if err := ml.chatView.state.MessageRemove(selectedMessage.ChannelID, selectedMessage.ID); err != nil {
  980. slog.Error("failed to delete message", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID, "err", err)
  981. return nil
  982. }
  983. return nil
  984. }
  985. }
  986. func (ml *messagesList) addReaction() {
  987. msg, err := ml.selectedMessage()
  988. if err != nil {
  989. slog.Error("failed to get selected message", "err", err)
  990. return
  991. }
  992. selectedChannel := ml.chatView.SelectedChannel()
  993. if selectedChannel == nil {
  994. return
  995. }
  996. ml.chatView.openEmojiPicker(msg.ID, selectedChannel.ID)
  997. }
  998. func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {
  999. usersToFetch := make([]discord.UserID, 0, len(messages))
  1000. seen := make(map[discord.UserID]struct{}, len(messages))
  1001. for _, message := range messages {
  1002. // Do not fetch member for a webhook message.
  1003. if message.WebhookID.IsValid() {
  1004. continue
  1005. }
  1006. if member, _ := ml.chatView.state.Cabinet.Member(guildID, message.Author.ID); member == nil {
  1007. userID := message.Author.ID
  1008. if _, ok := seen[userID]; !ok {
  1009. seen[userID] = struct{}{}
  1010. usersToFetch = append(usersToFetch, userID)
  1011. }
  1012. }
  1013. }
  1014. if len(usersToFetch) > 0 {
  1015. err := ml.chatView.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{
  1016. GuildIDs: []discord.GuildID{guildID},
  1017. UserIDs: usersToFetch,
  1018. })
  1019. if err != nil {
  1020. slog.Error("failed to request guild members", "guild_id", guildID, "err", err)
  1021. return
  1022. }
  1023. ml.setFetchingChunk(true, 0)
  1024. ml.waitForChunkEvent()
  1025. }
  1026. }
  1027. func (ml *messagesList) setFetchingChunk(value bool, count uint) {
  1028. ml.fetchingMembers.mu.Lock()
  1029. defer ml.fetchingMembers.mu.Unlock()
  1030. if ml.fetchingMembers.value == value {
  1031. return
  1032. }
  1033. ml.fetchingMembers.value = value
  1034. if value {
  1035. ml.fetchingMembers.done = make(chan struct{})
  1036. } else {
  1037. ml.fetchingMembers.count = count
  1038. close(ml.fetchingMembers.done)
  1039. }
  1040. }
  1041. func (ml *messagesList) waitForChunkEvent() uint {
  1042. ml.fetchingMembers.mu.Lock()
  1043. if !ml.fetchingMembers.value {
  1044. ml.fetchingMembers.mu.Unlock()
  1045. return 0
  1046. }
  1047. ml.fetchingMembers.mu.Unlock()
  1048. <-ml.fetchingMembers.done
  1049. return ml.fetchingMembers.count
  1050. }
  1051. // cachedMessageURLs returns the URLs for the selected message, caching the
  1052. // result of the expensive messageURLs parse so that ShortHelp and FullHelp
  1053. // (both called every draw frame) only run the goldmark parser once per cursor
  1054. // position change.
  1055. func (ml *messagesList) cachedMessageURLs() ([]string, *discord.Message, bool) {
  1056. cursor := ml.Cursor()
  1057. if !ml.cachedHelpDirty && cursor == ml.cachedHelpCursor {
  1058. msg, err := ml.selectedMessage()
  1059. if err != nil {
  1060. return nil, nil, false
  1061. }
  1062. return ml.cachedHelpURLs, msg, true
  1063. }
  1064. msg, err := ml.selectedMessage()
  1065. if err != nil {
  1066. return nil, nil, false
  1067. }
  1068. ml.cachedHelpURLs = messageURLs(*msg)
  1069. ml.cachedHelpCursor = cursor
  1070. ml.cachedHelpDirty = false
  1071. return ml.cachedHelpURLs, msg, true
  1072. }
  1073. func (ml *messagesList) ShortHelp() []keybind.Keybind {
  1074. cfg := ml.cfg.Keybinds.MessagesList
  1075. help := []keybind.Keybind{
  1076. cfg.SelectUp.Keybind,
  1077. cfg.SelectDown.Keybind,
  1078. cfg.Cancel.Keybind,
  1079. cfg.Search.Keybind,
  1080. }
  1081. if urls, msg, ok := ml.cachedMessageURLs(); ok {
  1082. if me, err := ml.chatView.state.Cabinet.Me(); err == nil {
  1083. if msg.Author.ID != me.ID {
  1084. help = append(help, cfg.Reply.Keybind)
  1085. }
  1086. }
  1087. if len(urls) != 0 || len(msg.Attachments) != 0 {
  1088. help = append(help, cfg.Open.Keybind)
  1089. }
  1090. if msg.Thread != nil {
  1091. help = append(help, cfg.OpenThread.Keybind)
  1092. }
  1093. }
  1094. return help
  1095. }
  1096. func (ml *messagesList) FullHelp() [][]keybind.Keybind {
  1097. cfg := ml.cfg.Keybinds.MessagesList
  1098. canSelectReply := false
  1099. canReply := false
  1100. canEdit := false
  1101. canDelete := false
  1102. canOpen := false
  1103. canOpenThread := false
  1104. if urls, msg, ok := ml.cachedMessageURLs(); ok {
  1105. canSelectReply = msg.ReferencedMessage != nil
  1106. canOpen = len(urls) != 0 || len(msg.Attachments) != 0
  1107. canOpenThread = msg.Thread != nil
  1108. if me, err := ml.chatView.state.Cabinet.Me(); err == nil {
  1109. canReply = msg.Author.ID != me.ID
  1110. canEdit = msg.Author.ID == me.ID
  1111. canDelete = canEdit
  1112. }
  1113. if !canDelete {
  1114. selected := ml.chatView.SelectedChannel()
  1115. canDelete = selected != nil && ml.chatView.state.HasPermissions(selected.ID, discord.PermissionManageMessages)
  1116. }
  1117. }
  1118. actions := make([]keybind.Keybind, 0, 4)
  1119. if canReply {
  1120. actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind)
  1121. }
  1122. if canSelectReply {
  1123. actions = append(actions, cfg.SelectReply.Keybind)
  1124. }
  1125. actions = append(actions, cfg.Cancel.Keybind)
  1126. manage := make([]keybind.Keybind, 0, 4)
  1127. if canEdit {
  1128. manage = append(manage, cfg.Edit.Keybind)
  1129. }
  1130. if canDelete {
  1131. manage = append(manage, cfg.DeleteConfirm.Keybind, cfg.Delete.Keybind)
  1132. }
  1133. if canOpen {
  1134. manage = append(manage, cfg.Open.Keybind, cfg.SaveImage.Keybind)
  1135. }
  1136. if canOpenThread {
  1137. manage = append(manage, cfg.OpenThread.Keybind)
  1138. }
  1139. manage = append(manage, cfg.UserInfo.Keybind)
  1140. return [][]keybind.Keybind{
  1141. {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind},
  1142. {cfg.ScrollUp.Keybind, cfg.ScrollDown.Keybind, cfg.ScrollTop.Keybind, cfg.ScrollBottom.Keybind},
  1143. actions,
  1144. manage,
  1145. {cfg.YankContent.Keybind, cfg.YankURL.Keybind, cfg.YankID.Keybind, cfg.Search.Keybind},
  1146. {cfg.ToggleTimestamps.Keybind, cfg.ToggleReplies.Keybind, cfg.AddReaction.Keybind, cfg.ViewReactions.Keybind},
  1147. }
  1148. }