messages_list.go 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314
  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. ml.clearSelection()
  634. if cmd := ml.chatView.focusGuildsTree(); cmd != nil {
  635. return cmd
  636. }
  637. return ml.chatView.focusMessageInput()
  638. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind),
  639. keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowUp.Keybind):
  640. return ml.selectUp()
  641. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind),
  642. keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowDown.Keybind):
  643. ml.selectDown()
  644. return nil
  645. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowLeft.Keybind),
  646. keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowRight.Keybind):
  647. if cmd := ml.chatView.focusGuildsTree(); cmd != nil {
  648. return cmd
  649. }
  650. return nil
  651. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectTop.Keybind):
  652. ml.selectTop()
  653. return nil
  654. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectBottom.Keybind):
  655. ml.selectBottom()
  656. return nil
  657. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectReply.Keybind):
  658. ml.selectReply()
  659. return nil
  660. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankID.Keybind):
  661. return ml.yankMessageID()
  662. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankContent.Keybind):
  663. return ml.yankContent()
  664. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankURL.Keybind):
  665. return ml.yankURL()
  666. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Open.Keybind):
  667. ml.open()
  668. return nil
  669. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SaveImage.Keybind):
  670. ml.saveImage()
  671. return nil
  672. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Reply.Keybind):
  673. ml.reply(false)
  674. return nil
  675. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ReplyMention.Keybind):
  676. ml.reply(true)
  677. return nil
  678. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Edit.Keybind):
  679. ml.editSelectedMessage()
  680. return nil
  681. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Delete.Keybind):
  682. return ml.deleteSelectedMessage()
  683. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind):
  684. ml.confirmDelete()
  685. return nil
  686. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.OpenThread.Keybind):
  687. ml.openThread()
  688. return nil
  689. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Search.Keybind):
  690. ml.chatView.openSearch()
  691. return nil
  692. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.UserInfo.Keybind):
  693. ml.showUserInfo()
  694. return nil
  695. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ToggleTimestamps.Keybind):
  696. ml.timestampsHidden = !ml.timestampsHidden
  697. clear(ml.itemByID)
  698. ml.invalidateRows()
  699. return nil
  700. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ToggleReplies.Keybind):
  701. ml.repliesCollapsed = !ml.repliesCollapsed
  702. if !ml.repliesCollapsed {
  703. clear(ml.expandedReplies)
  704. }
  705. clear(ml.itemByID)
  706. ml.invalidateRows()
  707. return nil
  708. case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.AddReaction.Keybind):
  709. ml.addReaction()
  710. return nil
  711. }
  712. return ml.Model.HandleEvent(event)
  713. case *olderMessagesLoadedEvent:
  714. selectedChannel := ml.chatView.SelectedChannel()
  715. if selectedChannel == nil || selectedChannel.ID != event.ChannelID {
  716. return nil
  717. }
  718. // Defensive invalidation if Discord returns overlapping windows.
  719. for _, message := range event.Older {
  720. delete(ml.itemByID, message.ID)
  721. }
  722. ml.messages = slices.Concat(event.Older, ml.messages)
  723. ml.invalidateRows()
  724. ml.SetCursor(len(event.Older) - 1)
  725. return nil
  726. }
  727. return ml.Model.HandleEvent(event)
  728. }
  729. func (ml *messagesList) selectUp() tview.Command {
  730. messages := ml.messages
  731. if len(messages) == 0 {
  732. return nil
  733. }
  734. cursor := ml.Cursor()
  735. switch {
  736. case cursor == -1:
  737. cursor = len(messages) - 1
  738. case cursor > 0:
  739. cursor--
  740. case cursor == 0:
  741. return ml.fetchOlderMessages()
  742. }
  743. ml.SetCursor(cursor)
  744. return nil
  745. }
  746. func (ml *messagesList) selectDown() {
  747. messages := ml.messages
  748. if len(messages) == 0 {
  749. return
  750. }
  751. cursor := ml.Cursor()
  752. switch {
  753. case cursor == -1:
  754. cursor = len(messages) - 1
  755. case cursor < len(messages)-1:
  756. cursor++
  757. }
  758. ml.SetCursor(cursor)
  759. }
  760. func (ml *messagesList) selectTop() {
  761. if len(ml.messages) == 0 {
  762. return
  763. }
  764. ml.SetCursor(0)
  765. }
  766. func (ml *messagesList) selectBottom() {
  767. if len(ml.messages) == 0 {
  768. return
  769. }
  770. ml.SetCursor(len(ml.messages) - 1)
  771. }
  772. func (ml *messagesList) selectReply() {
  773. messages := ml.messages
  774. if len(messages) == 0 {
  775. return
  776. }
  777. cursor := ml.Cursor()
  778. if cursor == -1 || cursor >= len(messages) {
  779. return
  780. }
  781. if ref := messages[cursor].ReferencedMessage; ref != nil {
  782. refIdx := slices.IndexFunc(messages, func(m discord.Message) bool {
  783. return m.ID == ref.ID
  784. })
  785. if refIdx != -1 {
  786. ml.SetCursor(refIdx)
  787. }
  788. }
  789. }
  790. func (ml *messagesList) fetchOlderMessages() tview.Command {
  791. selectedChannel := ml.chatView.SelectedChannel()
  792. if selectedChannel == nil {
  793. return nil
  794. }
  795. channelID := selectedChannel.ID
  796. before := ml.messages[0].ID
  797. limit := uint(ml.cfg.MessagesLimit)
  798. return func() tview.Event {
  799. messages, err := ml.chatView.state.MessagesBefore(channelID, before, limit)
  800. if err != nil {
  801. slog.Error("failed to fetch older messages", "err", err)
  802. return nil
  803. }
  804. if len(messages) == 0 {
  805. return nil
  806. }
  807. if guildID := selectedChannel.GuildID; guildID.IsValid() {
  808. ml.requestGuildMembers(guildID, messages)
  809. }
  810. older := slices.Clone(messages)
  811. slices.Reverse(older)
  812. return newOlderMessagesLoadedEvent(channelID, older)
  813. }
  814. }
  815. func (ml *messagesList) yankMessageID() tview.Command {
  816. msg, err := ml.selectedMessage()
  817. if err != nil {
  818. slog.Error("failed to get selected message", "err", err)
  819. return nil
  820. }
  821. return func() tview.Event {
  822. if err := clipboard.Write(clipboard.FmtText, []byte(msg.ID.String())); err != nil {
  823. slog.Error("failed to copy message id", "err", err)
  824. }
  825. return nil
  826. }
  827. }
  828. func (ml *messagesList) yankContent() tview.Command {
  829. msg, err := ml.selectedMessage()
  830. if err != nil {
  831. slog.Error("failed to get selected message", "err", err)
  832. return nil
  833. }
  834. return func() tview.Event {
  835. if err := clipboard.Write(clipboard.FmtText, []byte(msg.Content)); err != nil {
  836. slog.Error("failed to copy message content", "err", err)
  837. }
  838. return nil
  839. }
  840. }
  841. func (ml *messagesList) yankURL() tview.Command {
  842. msg, err := ml.selectedMessage()
  843. if err != nil {
  844. slog.Error("failed to get selected message", "err", err)
  845. return nil
  846. }
  847. return func() tview.Event {
  848. if err := clipboard.Write(clipboard.FmtText, []byte(msg.URL())); err != nil {
  849. slog.Error("failed to copy message url", "err", err)
  850. }
  851. return nil
  852. }
  853. }
  854. func (ml *messagesList) open() {
  855. msg, err := ml.selectedMessage()
  856. if err != nil {
  857. slog.Error("failed to get selected message", "err", err)
  858. return
  859. }
  860. urls := messageURLs(*msg)
  861. if len(urls) == 0 && len(msg.Attachments) == 0 {
  862. return
  863. }
  864. if len(urls)+len(msg.Attachments) == 1 {
  865. if len(urls) == 1 {
  866. go ml.openURL(urls[0])
  867. } else {
  868. attachment := msg.Attachments[0]
  869. if strings.HasPrefix(attachment.ContentType, "image/") {
  870. go ml.openAttachment(msg.Attachments[0])
  871. } else {
  872. go ml.openURL(attachment.URL)
  873. }
  874. }
  875. } else {
  876. ml.showAttachmentsList(urls, msg.Attachments)
  877. }
  878. }
  879. func (ml *messagesList) openThread() {
  880. msg, err := ml.selectedMessage()
  881. if err != nil {
  882. slog.Error("failed to get selected message", "err", err)
  883. return
  884. }
  885. if msg.Thread == nil {
  886. return
  887. }
  888. thread := *msg.Thread
  889. node := ml.chatView.guildsTree.findNodeByChannelID(thread.ID)
  890. if node == nil {
  891. return
  892. }
  893. ml.chatView.guildsTree.expandPathToNode(node)
  894. ml.chatView.guildsTree.SetCurrentNode(node)
  895. ml.chatView.guildsTree.onSelected(node)
  896. }
  897. func (ml *messagesList) reply(mention bool) {
  898. message, err := ml.selectedMessage()
  899. if err != nil {
  900. slog.Error("failed to get selected message", "err", err)
  901. return
  902. }
  903. name := message.Author.DisplayOrUsername()
  904. if member := ml.memberForMessage(*message); member != nil && member.Nick != "" {
  905. name = member.Nick
  906. }
  907. data := ml.chatView.messageInput.sendMessageData
  908. data.Reference = &discord.MessageReference{MessageID: message.ID}
  909. data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False}
  910. title := "Replying to "
  911. if mention {
  912. data.AllowedMentions.RepliedUser = option.True
  913. title = "[@] " + title
  914. }
  915. ml.chatView.messageInput.sendMessageData = data
  916. ml.chatView.messageInput.SetTitle(title + name)
  917. ml.chatView.app.SetFocus(ml.chatView.messageInput)
  918. }
  919. func (ml *messagesList) editSelectedMessage() {
  920. message, err := ml.selectedMessage()
  921. if err != nil {
  922. slog.Error("failed to get selected message", "err", err)
  923. return
  924. }
  925. me, err := ml.chatView.state.Cabinet.Me()
  926. if err != nil {
  927. slog.Error("failed to get current user", "err", err)
  928. return
  929. }
  930. if message.Author.ID != me.ID {
  931. slog.Error("failed to edit message; not the author", "channel_id", message.ChannelID, "message_id", message.ID)
  932. return
  933. }
  934. ml.chatView.messageInput.SetTitle("Editing")
  935. ml.chatView.messageInput.edit = true
  936. ml.chatView.messageInput.SetText(message.Content, true)
  937. ml.chatView.app.SetFocus(ml.chatView.messageInput)
  938. }
  939. func (ml *messagesList) confirmDelete() {
  940. onChoice := func(choice string) {
  941. if choice == "Yes" {
  942. if command := ml.deleteSelectedMessage(); command != nil {
  943. command()
  944. }
  945. }
  946. }
  947. ml.chatView.showConfirmModal(
  948. "Are you sure you want to delete this message?",
  949. []string{"Yes", "No"},
  950. onChoice,
  951. )
  952. }
  953. func (ml *messagesList) deleteSelectedMessage() tview.Command {
  954. selectedMessage, err := ml.selectedMessage()
  955. if err != nil {
  956. slog.Error("failed to get selected message", "err", err)
  957. return nil
  958. }
  959. return func() tview.Event {
  960. if selectedMessage.GuildID.IsValid() {
  961. me, err := ml.chatView.state.Cabinet.Me()
  962. if err != nil {
  963. slog.Error("failed to get current user", "err", err)
  964. return nil
  965. }
  966. if selectedMessage.Author.ID != me.ID && !ml.chatView.state.HasPermissions(selectedMessage.ChannelID, discord.PermissionManageMessages) {
  967. slog.Error("failed to delete message; missing relevant permissions", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID)
  968. return nil
  969. }
  970. }
  971. if err := ml.chatView.state.DeleteMessage(selectedMessage.ChannelID, selectedMessage.ID, ""); err != nil {
  972. slog.Error("failed to delete message", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID, "err", err)
  973. return nil
  974. }
  975. if err := ml.chatView.state.MessageRemove(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. return nil
  980. }
  981. }
  982. func (ml *messagesList) addReaction() {
  983. msg, err := ml.selectedMessage()
  984. if err != nil {
  985. slog.Error("failed to get selected message", "err", err)
  986. return
  987. }
  988. selectedChannel := ml.chatView.SelectedChannel()
  989. if selectedChannel == nil {
  990. return
  991. }
  992. ml.chatView.openEmojiPicker(msg.ID, selectedChannel.ID)
  993. }
  994. func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {
  995. usersToFetch := make([]discord.UserID, 0, len(messages))
  996. seen := make(map[discord.UserID]struct{}, len(messages))
  997. for _, message := range messages {
  998. // Do not fetch member for a webhook message.
  999. if message.WebhookID.IsValid() {
  1000. continue
  1001. }
  1002. if member, _ := ml.chatView.state.Cabinet.Member(guildID, message.Author.ID); member == nil {
  1003. userID := message.Author.ID
  1004. if _, ok := seen[userID]; !ok {
  1005. seen[userID] = struct{}{}
  1006. usersToFetch = append(usersToFetch, userID)
  1007. }
  1008. }
  1009. }
  1010. if len(usersToFetch) > 0 {
  1011. err := ml.chatView.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{
  1012. GuildIDs: []discord.GuildID{guildID},
  1013. UserIDs: usersToFetch,
  1014. })
  1015. if err != nil {
  1016. slog.Error("failed to request guild members", "guild_id", guildID, "err", err)
  1017. return
  1018. }
  1019. ml.setFetchingChunk(true, 0)
  1020. ml.waitForChunkEvent()
  1021. }
  1022. }
  1023. func (ml *messagesList) setFetchingChunk(value bool, count uint) {
  1024. ml.fetchingMembers.mu.Lock()
  1025. defer ml.fetchingMembers.mu.Unlock()
  1026. if ml.fetchingMembers.value == value {
  1027. return
  1028. }
  1029. ml.fetchingMembers.value = value
  1030. if value {
  1031. ml.fetchingMembers.done = make(chan struct{})
  1032. } else {
  1033. ml.fetchingMembers.count = count
  1034. close(ml.fetchingMembers.done)
  1035. }
  1036. }
  1037. func (ml *messagesList) waitForChunkEvent() uint {
  1038. ml.fetchingMembers.mu.Lock()
  1039. if !ml.fetchingMembers.value {
  1040. ml.fetchingMembers.mu.Unlock()
  1041. return 0
  1042. }
  1043. ml.fetchingMembers.mu.Unlock()
  1044. <-ml.fetchingMembers.done
  1045. return ml.fetchingMembers.count
  1046. }
  1047. // cachedMessageURLs returns the URLs for the selected message, caching the
  1048. // result of the expensive messageURLs parse so that ShortHelp and FullHelp
  1049. // (both called every draw frame) only run the goldmark parser once per cursor
  1050. // position change.
  1051. func (ml *messagesList) cachedMessageURLs() ([]string, *discord.Message, bool) {
  1052. cursor := ml.Cursor()
  1053. if !ml.cachedHelpDirty && cursor == ml.cachedHelpCursor {
  1054. msg, err := ml.selectedMessage()
  1055. if err != nil {
  1056. return nil, nil, false
  1057. }
  1058. return ml.cachedHelpURLs, msg, true
  1059. }
  1060. msg, err := ml.selectedMessage()
  1061. if err != nil {
  1062. return nil, nil, false
  1063. }
  1064. ml.cachedHelpURLs = messageURLs(*msg)
  1065. ml.cachedHelpCursor = cursor
  1066. ml.cachedHelpDirty = false
  1067. return ml.cachedHelpURLs, msg, true
  1068. }
  1069. func (ml *messagesList) ShortHelp() []keybind.Keybind {
  1070. cfg := ml.cfg.Keybinds.MessagesList
  1071. help := []keybind.Keybind{
  1072. cfg.SelectUp.Keybind,
  1073. cfg.SelectDown.Keybind,
  1074. cfg.Cancel.Keybind,
  1075. cfg.Search.Keybind,
  1076. }
  1077. if urls, msg, ok := ml.cachedMessageURLs(); ok {
  1078. if me, err := ml.chatView.state.Cabinet.Me(); err == nil {
  1079. if msg.Author.ID != me.ID {
  1080. help = append(help, cfg.Reply.Keybind)
  1081. }
  1082. }
  1083. if len(urls) != 0 || len(msg.Attachments) != 0 {
  1084. help = append(help, cfg.Open.Keybind)
  1085. }
  1086. if msg.Thread != nil {
  1087. help = append(help, cfg.OpenThread.Keybind)
  1088. }
  1089. }
  1090. return help
  1091. }
  1092. func (ml *messagesList) FullHelp() [][]keybind.Keybind {
  1093. cfg := ml.cfg.Keybinds.MessagesList
  1094. canSelectReply := false
  1095. canReply := false
  1096. canEdit := false
  1097. canDelete := false
  1098. canOpen := false
  1099. canOpenThread := false
  1100. if urls, msg, ok := ml.cachedMessageURLs(); ok {
  1101. canSelectReply = msg.ReferencedMessage != nil
  1102. canOpen = len(urls) != 0 || len(msg.Attachments) != 0
  1103. canOpenThread = msg.Thread != nil
  1104. if me, err := ml.chatView.state.Cabinet.Me(); err == nil {
  1105. canReply = msg.Author.ID != me.ID
  1106. canEdit = msg.Author.ID == me.ID
  1107. canDelete = canEdit
  1108. }
  1109. if !canDelete {
  1110. selected := ml.chatView.SelectedChannel()
  1111. canDelete = selected != nil && ml.chatView.state.HasPermissions(selected.ID, discord.PermissionManageMessages)
  1112. }
  1113. }
  1114. actions := make([]keybind.Keybind, 0, 4)
  1115. if canReply {
  1116. actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind)
  1117. }
  1118. if canSelectReply {
  1119. actions = append(actions, cfg.SelectReply.Keybind)
  1120. }
  1121. actions = append(actions, cfg.Cancel.Keybind)
  1122. manage := make([]keybind.Keybind, 0, 4)
  1123. if canEdit {
  1124. manage = append(manage, cfg.Edit.Keybind)
  1125. }
  1126. if canDelete {
  1127. manage = append(manage, cfg.DeleteConfirm.Keybind, cfg.Delete.Keybind)
  1128. }
  1129. if canOpen {
  1130. manage = append(manage, cfg.Open.Keybind, cfg.SaveImage.Keybind)
  1131. }
  1132. if canOpenThread {
  1133. manage = append(manage, cfg.OpenThread.Keybind)
  1134. }
  1135. manage = append(manage, cfg.UserInfo.Keybind)
  1136. return [][]keybind.Keybind{
  1137. {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind},
  1138. {cfg.ScrollUp.Keybind, cfg.ScrollDown.Keybind, cfg.ScrollTop.Keybind, cfg.ScrollBottom.Keybind},
  1139. actions,
  1140. manage,
  1141. {cfg.YankContent.Keybind, cfg.YankURL.Keybind, cfg.YankID.Keybind, cfg.Search.Keybind},
  1142. {cfg.ToggleTimestamps.Keybind, cfg.ToggleReplies.Keybind, cfg.AddReaction.Keybind},
  1143. }
  1144. }