messages_list.go 36 KB

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