diff --git a/docs/config.md b/docs/config.md index 6df789c..119ba8b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -424,13 +424,27 @@ The longest time between two messages by the same author for them to share a hea Default: `"10m0s"` +### ui.messages_list.emojis + +#### ui.messages_list.emojis.show_emoji_name + +Whether the name is shown after the image. Without an image, it always is. + +Default: `false` + +#### ui.messages_list.emojis.colons_around_name + +Whether the names of custom emoji are shown between colons, as in `:name:`. + +Default: `false` + ### ui.messages_list.reactions -#### ui.messages_list.reactions.separator +#### ui.messages_list.reactions.show_emoji_name -The text between reactions. +Whether the name is shown after the image. Without an image, it always is. -Default: `" • "` +Default: `false` #### ui.messages_list.reactions.colons_around_name @@ -438,6 +452,12 @@ Whether the names of custom emoji are shown between colons, as in `:name:`. Default: `false` +#### ui.messages_list.reactions.separator + +The text between reactions. + +Default: `" • "` + ## ui.mentions_list The list of mention suggestions above the composer. Its width and height are capped to the available space. diff --git a/go.mod b/go.mod index 9cc9c95..e9fb09b 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/alecthomas/chroma/v2 v2.27.0 github.com/ayn2op/arikawa/v3 v3.0.0-20261016043052-54a68a54e42e github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 - github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5 + github.com/ayn2op/tview v0.0.0-20261017205514-f83a3769b18a github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.6.0 diff --git a/go.sum b/go.sum index 3638462..76ea97b 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935 h1:oVxkY1/nVsVQ8Cn github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935/go.mod h1:7phoj3f/q54WuiTnCTlAhEoaVcdlruKkSzCSD47LzHk= github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0= github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks= -github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5 h1:IbG2cMS8rH6fEtktBc/k+HDyIt0Dosx+pB4sxtXXpRM= -github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA= +github.com/ayn2op/tview v0.0.0-20261017205514-f83a3769b18a h1:gJyFlYT+igFchVDNUMapJdy8iM0cVA0/LxOqZ8n6FZc= +github.com/ayn2op/tview v0.0.0-20261017205514-f83a3769b18a/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= diff --git a/internal/config/ui.go b/internal/config/ui.go index 4355375..37cff52 100644 --- a/internal/config/ui.go +++ b/internal/config/ui.go @@ -183,16 +183,24 @@ type ( Attachments AttachmentsUIConfig `toml:"attachments"` Avatars AvatarsUIConfig `toml:"avatars"` Grouping GroupingUIConfig `toml:"grouping"` + Emojis EmojiUIConfig `toml:"emojis"` Reactions ReactionsUIConfig `toml:"reactions"` } - ReactionsUIConfig struct { - // The text between reactions. - Separator string `toml:"separator"` + // EmojiUIConfig is how custom emoji are shown: their image, then their name. + EmojiUIConfig struct { + // Whether the name is shown after the image. Without an image, it always is. + ShowEmojiName bool `toml:"show_emoji_name"` // Whether the names of custom emoji are shown between colons, as in `:name:`. ColonsAroundName bool `toml:"colons_around_name"` } + ReactionsUIConfig struct { + EmojiUIConfig + // The text between reactions. + Separator string `toml:"separator"` + } + // GroupingUIConfig shows consecutive messages by the same author under one header. GroupingUIConfig struct { Enabled bool `toml:"enabled"` @@ -261,3 +269,11 @@ func defaultUI() UIConfig { MentionsList: MentionsListUIConfig{MinWidth: 20}, } } + +// Name returns the name of a custom emoji as it is shown. +func (c EmojiUIConfig) Name(name string) string { + if c.ColonsAroundName { + return ":" + name + ":" + } + return name +} diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index a9e5075..cef927b 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -25,7 +25,8 @@ func NewRenderer(cfg *config.Config) *Renderer { return &Renderer{cfg: cfg} } -func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) []text.Span { +// RenderText returns the spans of the markdown node. emoji returns the image of the custom emoji with an ID as a line of spans, or nil if it has none. +func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style, emoji func(id string) []text.Span) []text.Span { builder := new(Builder) styleStack := []tview.Style{base} linkDepth := 0 @@ -104,7 +105,16 @@ func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) [] case *md.Mention: builder.Write(mentionText(node), tview.MergeStyle(currentStyle(), theme.MentionStyle.Style)) case *md.Emoji: - builder.Write(":"+node.Name+":", tview.MergeStyle(currentStyle(), theme.EmojiStyle.Style)) + cfg := r.cfg.UI.MessagesList.Emojis + if image := emoji(node.ID); image != nil { + builder.WriteSpans(image...) + if !cfg.ShowEmojiName { + return ast.WalkContinue, nil + } + + builder.Write(" ", currentStyle()) + } + builder.Write(cfg.Name(node.Name), tview.MergeStyle(currentStyle(), theme.EmojiStyle.Style)) } return ast.WalkContinue, nil }) diff --git a/internal/markdown/renderer_test.go b/internal/markdown/renderer_test.go index a4108fb..964855a 100644 --- a/internal/markdown/renderer_test.go +++ b/internal/markdown/renderer_test.go @@ -6,6 +6,7 @@ import ( md "github.com/ayn2op/arikawa/v3/markdown" "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/tview/text" "github.com/gdamore/tcell/v3" ) @@ -25,7 +26,7 @@ func TestRendererRenderText(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { source := []byte(test.source) - rendered := NewRenderer(&config.Config{Markdown: config.MarkdownConfig{MaskSpoilers: test.mask}}).RenderText(source, md.Parse(source), tcell.StyleDefault) + rendered := NewRenderer(&config.Config{Markdown: config.MarkdownConfig{MaskSpoilers: test.mask}}).RenderText(source, md.Parse(source), tcell.StyleDefault, func(string) []text.Span { return nil }) var got strings.Builder for _, span := range rendered { got.WriteString(span.Text) @@ -36,3 +37,39 @@ func TestRendererRenderText(t *testing.T) { }) } } + +func TestRendererRenderTextEmoji(t *testing.T) { + source := []byte("hi <:cat:5> <:dog:6>") + // Only the emoji with ID 5 has an image. + emoji := func(id string) []text.Span { + if id == "5" { + return []text.Span{{Text: "I"}} + } + return nil + } + + for _, test := range []struct { + name string + colons bool + showName bool + want string + }{ + {"plain", false, true, "hi I cat dog"}, + {"colons", true, true, "hi I :cat: :dog:"}, + // The emoji without an image keeps its name. + {"image only", false, false, "hi I dog"}, + } { + t.Run(test.name, func(t *testing.T) { + var cfg config.Config + cfg.UI.MessagesList.Emojis.ColonsAroundName = test.colons + cfg.UI.MessagesList.Emojis.ShowEmojiName = test.showName + var got strings.Builder + for _, span := range NewRenderer(&cfg).RenderText(source, md.Parse(source), tcell.StyleDefault, emoji) { + got.WriteString(span.Text) + } + if got.String() != test.want { + t.Fatalf("got %q, want %q", got.String(), test.want) + } + }) + } +} diff --git a/internal/ui/chat/messageslist/embed.go b/internal/ui/chat/messageslist/embed.go index 44bb5e5..2a50cda 100644 --- a/internal/ui/chat/messageslist/embed.go +++ b/internal/ui/chat/messageslist/embed.go @@ -20,22 +20,17 @@ const embedPrefix = " ▎ " // embedPrefixWidth is how many cells embedPrefix takes. var embedPrefixWidth = uucode.StringWidth(embedPrefix) -func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) { +// root is the parsed content of message, or nil to parse it here. +func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, root ast.Node) { if len(message.Embeds) == 0 { return } // Embed URLs are deduplicated against links already shown in the message body. - // Reuse the body's parsed AST when markdown is enabled; only the markdown-disabled path (no AST) has to parse the content here. - var contentListURLs []string - if contentRoot != nil { - contentListURLs = urlsFromAST(contentRoot, contentSource) - } else { - contentListURLs = extractURLs(message.Content) - } - contentURLs := make(map[string]struct{}, len(contentListURLs)) + contentListURLs := contentURLs(message.Content, root) + seenURLs := make(map[string]struct{}, len(contentListURLs)) for _, u := range contentListURLs { - contentURLs[u] = struct{}{} + seenURLs[u] = struct{}{} } lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds) @@ -44,7 +39,7 @@ func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, ba wrapWidth := max(width-embedPrefixWidth, 1) for _, embed := range message.Embeds { - lines := embedLines(embed, contentURLs) + lines := embedLines(embed, seenURLs) if len(lines) == 0 { continue } @@ -63,7 +58,7 @@ func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, ba msg.Content = line.text lineStyle := lineStyles[line.kind] // Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled. - rendered, _, _ := m.renderContent(msg, lineStyle, line.kind == embedLineDescription) + rendered := m.renderContent(msg, nil, lineStyle, line.kind == embedLineDescription) if line.url != "" { rendered = spansWithURL(rendered, line.url) } diff --git a/internal/ui/chat/messageslist/keymap.go b/internal/ui/chat/messageslist/keymap.go index b7191db..2e0c0be 100644 --- a/internal/ui/chat/messageslist/keymap.go +++ b/internal/ui/chat/messageslist/keymap.go @@ -20,7 +20,7 @@ func (m Model) ShortHelp() []keybind.Keybind { if !ui.IsMe(m.state, selectedMessage.Author.ID) { help = append(help, cfg.Reply.Keybind) } - if len(selectedMessage.Attachments) != 0 || len(messageURLs(*selectedMessage)) != 0 { + if len(selectedMessage.Attachments) != 0 || len(m.selectedURLs()) != 0 { help = append(help, cfg.Open.Keybind) } if isVoiceMessage(*selectedMessage) { @@ -51,7 +51,7 @@ func (m Model) FullHelp() [][]keybind.Keybind { manage = append(manage, cfg.Delete.Keybind) } } - if len(message.Attachments) != 0 || len(messageURLs(*message)) != 0 { + if len(message.Attachments) != 0 || len(m.selectedURLs()) != 0 { attachments = append(attachments, cfg.Open.Keybind, cfg.OpenInBrowser.Keybind) } if len(message.Attachments) != 0 { diff --git a/internal/ui/chat/messageslist/links.go b/internal/ui/chat/messageslist/links.go index 5e11a2b..e24e297 100644 --- a/internal/ui/chat/messageslist/links.go +++ b/internal/ui/chat/messageslist/links.go @@ -10,10 +10,13 @@ import ( "github.com/yuin/goldmark/v2/ast" ) -func extractURLs(content string) []string { +// contentURLs returns the link destinations in content. root is its parsed markdown, or nil to parse it here. +func contentURLs(content string, root ast.Node) []string { src := []byte(content) - node := md.Parse(src) - return urlsFromAST(node, src) + if root == nil { + root = md.Parse(src) + } + return urlsFromAST(root, src) } // urlsFromAST collects link destinations from an already-parsed markdown AST. @@ -57,8 +60,9 @@ const ( schemeHTTPS = "https" ) -func messageURLs(msg discord.Message) []string { - combined := slices.Concat(extractURLs(msg.Content), extractEmbedURLs(msg.Embeds)) +// messageURLs returns the web links of msg. root is its parsed content, or nil to parse it here. +func messageURLs(msg discord.Message, root ast.Node) []string { + combined := slices.Concat(contentURLs(msg.Content, root), extractEmbedURLs(msg.Embeds)) urls := make([]string, 0, len(combined)) seen := make(map[string]struct{}, len(combined)) diff --git a/internal/ui/chat/messageslist/message.go b/internal/ui/chat/messageslist/message.go index 97d0d51..1804f5f 100644 --- a/internal/ui/chat/messageslist/message.go +++ b/internal/ui/chat/messageslist/message.go @@ -22,9 +22,10 @@ import ( // messageViews returns the views of the text of message and of what it replies to. // They capture the model and the message, so they are made here: capturing them in buildItem would copy both to the heap on every call, as the list builds every item each time it is laid out. -func (m Model) messageViews(message discord.Message, grouped bool) (content, reply *messageView) { +// root is the parsed content of message, or nil to parse it when it is rendered. +func (m Model) messageViews(message discord.Message, root ast.Node, grouped bool) (content, reply *messageView) { style := m.cfg.Theme.MessagesList.MessageStyle.Style - content = &messageView{render: func(width int) []text.Span { return m.renderMessage(message, style, width, grouped) }} + content = &messageView{render: func(width int) []text.Span { return m.renderMessage(message, root, style, width, grouped) }} reply = &messageView{render: func(int) []text.Span { builder := new(markdown.Builder) m.drawReply(builder, message, style) @@ -95,9 +96,9 @@ func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) { func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } -func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, width int, grouped bool) []text.Span { +func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) []text.Span { builder := new(markdown.Builder) - m.writeMessage(builder, message, baseStyle, width, grouped) + m.writeMessage(builder, message, root, baseStyle, width, grouped) // Without a header or content, the message starts with the line break that attachments and embeds start below. return trimLeadingLineBreaks(builder.Finish()) } @@ -113,7 +114,7 @@ func trimLeadingLineBreaks(spans []text.Span) []text.Span { return spans } -func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, grouped bool) { +func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) { if m.blocked(message.Author) { builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true)) return @@ -124,12 +125,12 @@ func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward { m.drawForwardedMessage(builder, message, baseStyle, grouped) } else { - m.drawDefaultMessage(builder, message, baseStyle, width, grouped) + m.drawDefaultMessage(builder, message, root, baseStyle, width, grouped) } case discord.GuildMemberJoinMessage: m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) }) case discord.InlinedReplyMessage: - m.drawDefaultMessage(builder, message, baseStyle, width, false) + m.drawDefaultMessage(builder, message, root, baseStyle, width, false) case discord.ChannelPinnedMessage: drawPinnedMessage(builder, message, baseStyle) default: @@ -153,19 +154,24 @@ func (m Model) reactionsView(reactions []discord.Reaction) tview.Widget { } style = tview.MergeStyle(theme.MessageStyle.Style, style) - name := reaction.Emoji.Name - if reaction.Emoji.IsCustom() && cfg.ColonsAroundName { - name = ":" + name + ":" + label := strconv.Itoa(reaction.Count) + emoji, hasImage := m.images[emojiURL(reaction.Emoji.ID)] + if !hasImage || cfg.ShowEmojiName { + name := reaction.Emoji.Name + if reaction.Emoji.IsCustom() { + name = cfg.Name(name) + } + label = name + " " + label } - spans := []text.Span{{Text: name + " " + strconv.Itoa(reaction.Count), Style: style}} + spans := []text.Span{{Text: label, Style: style}} // Each reaction but the last ends with the separator, so a line never starts with one. if i < len(reactions)-1 { spans = append(spans, text.Span{Text: cfg.Separator, Style: separatorStyle}) } var chip tview.Widget = text.Rich(spans...) - if emoji, ok := m.images[emojiURL(reaction.Emoji)]; ok { + if hasImage { chip = row.New(emoji, chip).Spacing(1).Width(layout.Shrink).Height(layout.Shrink) } chips[i] = chip @@ -273,16 +279,17 @@ func (m Model) memberForMessage(message discord.Message) *discord.Member { return member } -// drawContent renders the message body and returns the parsed markdown AST together with the source bytes it indexes into, so callers can reuse them instead of re-parsing the same content (see drawEmbeds). -// root is nil when markdown rendering is disabled. -func (m Model) drawContent(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) (ast.Node, []byte) { - content, root, source := m.renderContent(message, baseStyle, false) +// drawContent renders the message body. root is its parsed content, or nil to parse it here. +func (m Model) drawContent(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style) { + if root == nil && m.cfg.Markdown.Enabled { + root = m.parse(message) + } + + content := m.renderContent(message, root, baseStyle, false) if m.cfg.Markdown.Enabled { startsWithCodeBlock := false - if root != nil { - if first := root.FirstChild(); first != nil { - _, startsWithCodeBlock = first.(*ast.CodeBlock) - } + if first := root.FirstChild(); first != nil { + _, startsWithCodeBlock = first.(*ast.CodeBlock) } // Keep code blocks visually separate from "timestamp + author". @@ -293,20 +300,22 @@ func (m Model) drawContent(builder *markdown.Builder, message discord.Message, b content = trimLeadingLineBreaks(content) } builder.WriteSpans(content...) - return root, source } -func (m Model) renderContent(message discord.Message, baseStyle tview.Style, forceMarkdown bool) ([]text.Span, ast.Node, []byte) { +// root is the parsed content of message, or nil to parse it here. +func (m Model) renderContent(message discord.Message, root ast.Node, baseStyle tview.Style, forceMarkdown bool) []text.Span { // Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both. if forceMarkdown || m.cfg.Markdown.Enabled { - c := []byte(message.Content) - root := md.ParseWithMessage(c, *m.state.Cabinet, &message) - return m.renderer.RenderText(c, root, baseStyle), root, c + if root == nil { + root = m.parse(message) + } + + return m.renderer.RenderText([]byte(message.Content), root, baseStyle, m.emojiLine) } b := new(markdown.Builder) b.Write(message.Content, baseStyle) - return b.Finish(), nil, nil + return b.Finish() } func (m Model) drawSnapshotContent(builder *markdown.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tview.Style) { @@ -326,19 +335,17 @@ func (m Model) drawSnapshotContent(builder *markdown.Builder, parent discord.Mes ChannelID: parent.ChannelID, GuildID: parent.GuildID, } - m.drawContent(builder, message, baseStyle) + m.drawContent(builder, message, nil, baseStyle) } -func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, grouped bool) { - var contentRoot ast.Node - var contentSource []byte - content := func() { contentRoot, contentSource = m.drawContent(builder, message, baseStyle) } +func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) { + content := func() { m.drawContent(builder, message, root, baseStyle) } if message.Content == "" { content = nil } m.drawLayout(builder, message, baseStyle, grouped, content) - m.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource) + m.drawEmbeds(builder, message, baseStyle, width, root) attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style) // A voice message is shown as its player. @@ -375,7 +382,7 @@ func (m Model) drawReply(builder *markdown.Builder, message discord.Message, bas reference.GuildID = message.GuildID m.drawAuthor(builder, *reference, dimStyle) builder.Write(" ", dimStyle) - m.drawContent(builder, *reference, dimStyle) + m.drawContent(builder, *reference, nil, dimStyle) } else { builder.Write("Original message was deleted", dimStyle) } @@ -384,3 +391,8 @@ func (m Model) drawReply(builder *markdown.Builder, message discord.Message, bas func drawPinnedMessage(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) { builder.Write(message.Author.DisplayOrUsername()+" pinned a message.", baseStyle) } + +// parse returns the parsed markdown of the content of message, with its mentions resolved from the state. +func (m Model) parse(message discord.Message) ast.Node { + return md.ParseWithMessage([]byte(message.Content), *m.state.Cabinet, &message) +} diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index d3c838a..55a94fa 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -20,6 +20,7 @@ import ( "github.com/ayn2op/tview/scrollbar" "github.com/ayn2op/tview/spacer" "github.com/ayn2op/tview/viewport" + "github.com/yuin/goldmark/v2/ast" ) type messageItem struct { @@ -33,6 +34,8 @@ type messageItem struct { // A preview is blank while loading and empty if loading failed. previews map[discord.URL]tviewimage.Widget + // root is the parsed markdown of the message's content, or nil until it is needed and after the text is invalidated, as it resolves mentions when parsed. + root ast.Node } type Model struct { @@ -257,7 +260,7 @@ func (m Model) buildItem(index int) tview.Widget { message := &item.message if item.text == nil { - item.text, item.reply = m.messageViews(*message, item.grouped) + item.text, item.reply = m.messageViews(*message, m.contentRoot(item), item.grouped) } body := column.New(item.text).Height(layout.Shrink) @@ -378,6 +381,14 @@ func (m Model) selectedMessage() (*discord.Message, bool) { return &m.items[cursor].message, true } +// selectedURLs returns the web links of the selected message, from its saved markdown. +func (m Model) selectedURLs() []string { + if message, ok := m.selectedMessage(); ok { + return messageURLs(*message, m.contentRoot(&m.items[m.cursor])) + } + return nil +} + // Update returns ml changed in response to msg and a command to run, or nil. func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { switch msg := msg.(type) { @@ -459,6 +470,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { if _, ok := m.images[msg.proxy]; ok { m.images[msg.proxy] = preview + // An emoji is drawn within the text of the messages that use it, so the text is rendered again. + for i := range m.items { + m.items[i].text = nil + } return m, cmd } @@ -601,6 +616,14 @@ func (m Model) canDeleteMessage(message discord.Message) bool { func (m Model) InvalidateRendered() { for i := range m.items { - m.items[i].text = nil + m.items[i].text, m.items[i].root = nil, nil } } + +// contentRoot returns the parsed markdown of the content of the item's message, parsing it the first time, or nil if markdown is disabled. +func (m Model) contentRoot(item *messageItem) ast.Node { + if item.root == nil && m.cfg.Markdown.Enabled { + item.root = m.parse(item.message) + } + return item.root +} diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index ffb6f4c..e84a154 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -207,7 +207,7 @@ func TestWaveformRail(t *testing.T) { func TestMessageURLs(t *testing.T) { message := discord.Message{Content: "[a](file:///etc/passwd) [b](ms-msdt:id) [c](HTTPS://example.com) https://example.org"} - if got, want := messageURLs(message), []string{"HTTPS://example.com", "https://example.org"}; !slices.Equal(got, want) { + if got, want := messageURLs(message, nil), []string{"HTTPS://example.com", "https://example.org"}; !slices.Equal(got, want) { t.Errorf("got %q, want %q", got, want) } } @@ -223,7 +223,7 @@ func TestModelDrawLayout(t *testing.T) { cfg.UI.MessagesList.Compact = compact var got strings.Builder - for _, span := range ml.renderMessage(message, tview.Style{}, 80, false) { + for _, span := range ml.renderMessage(message, nil, tview.Style{}, 80, false) { got.WriteString(span.Text) } @@ -272,7 +272,7 @@ func TestModelShouldGroup(t *testing.T) { if !ml.items[newest].grouped || ml.items[ml.messageIndex(-1, 1)].grouped { t.Fatal("only the first message has a header") } - if got := ml.renderMessage(ml.items[newest].message, tview.Style{}, 80, true); len(got) != 1 || got[0].Text != "hi" { + if got := ml.renderMessage(ml.items[newest].message, nil, tview.Style{}, 80, true); len(got) != 1 || got[0].Text != "hi" { t.Fatalf("grouped message = %v, want only its content", got) } second := ml.messageIndex(newest, -1) @@ -386,8 +386,14 @@ func TestModelReactionsView(t *testing.T) { } }) t.Run("emoji image", func(t *testing.T) { - ml.images[emojiURL(custom)] = tviewimage.New(image.NewAlpha(image.Rect(0, 0, 2, 2))).Width(2) - // The blank image takes two cells before the name. + ml.images[emojiURL(custom.ID)] = tviewimage.New(image.NewAlpha(image.Rect(0, 0, 2, 2))).Width(2) + // The blank image takes two cells before the count. + if got := draw(); got != "ok 2 • 1" { + t.Fatalf("reactions = %q", got) + } + }) + t.Run("with name", func(t *testing.T) { + ml.cfg.UI.MessagesList.Reactions.ShowEmojiName = true if got := draw(); got != "ok 2 • cat 1" { t.Fatalf("reactions = %q", got) } diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index 27af36d..3a27371 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -210,7 +210,7 @@ func (m Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tview return nil } - urls := messageURLs(*selectedMessage) + urls := m.selectedURLs() switch total := len(urls) + len(selectedMessage.Attachments); { case total == 0: return nil diff --git a/internal/ui/chat/messageslist/preview.go b/internal/ui/chat/messageslist/preview.go index ea4c31b..9b3ec49 100644 --- a/internal/ui/chat/messageslist/preview.go +++ b/internal/ui/chat/messageslist/preview.go @@ -12,8 +12,11 @@ import ( "strconv" "github.com/ayn2op/arikawa/v3/discord" + md "github.com/ayn2op/arikawa/v3/markdown" "github.com/ayn2op/tview" tviewimage "github.com/ayn2op/tview/image" + "github.com/ayn2op/tview/text" + "github.com/yuin/goldmark/v2/ast" _ "golang.org/x/image/webp" ) @@ -88,7 +91,20 @@ func (m Model) loadPreviews() tview.Cmd { cmds = append(cmds, m.loadAvatar(item.message.Author)) for _, reaction := range item.message.Reactions { - cmds = append(cmds, m.loadEmoji(reaction.Emoji)) + if reaction.Emoji.IsCustom() { + cmds = append(cmds, m.loadEmoji(reaction.Emoji.ID)) + } + } + + if root := m.contentRoot(item); root != nil { + _ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if emoji, ok := node.(*md.Emoji); ok && entering { + if id, err := discord.ParseSnowflake(emoji.ID); err == nil { + cmds = append(cmds, m.loadEmoji(discord.EmojiID(id))) + } + } + return ast.WalkContinue, nil + }) } if !m.cfg.UI.MessagesList.Attachments.Preview { @@ -128,16 +144,31 @@ func (m Model) loadAvatar(user discord.User) tview.Cmd { return m.loadImage(avatarURL(user), uint(cfg.Height)) } -func emojiURL(emoji discord.Emoji) discord.URL { - return emoji.EmojiURLWithType(discord.PNGImage) +func emojiURL(id discord.EmojiID) discord.URL { + return discord.Emoji{ID: id}.EmojiURLWithType(discord.PNGImage) } -// loadEmoji loads the image of a custom emoji, a row tall. -func (m Model) loadEmoji(emoji discord.Emoji) tview.Cmd { - if !emoji.IsCustom() { +// loadEmoji loads the image of the custom emoji with id, a row tall. +func (m Model) loadEmoji(id discord.EmojiID) tview.Cmd { + return m.loadImage(emojiURL(id), 1) +} + +// emojiLine returns the image of the custom emoji with id as a line of spans, or nil if it is not loaded. +func (m Model) emojiLine(id string) []text.Span { + snowflake, err := discord.ParseSnowflake(id) + if err != nil { return nil } - return m.loadImage(emojiURL(emoji), 1) + + emoji, ok := m.images[emojiURL(discord.EmojiID(snowflake))] + if !ok { + return nil + } + + if lines := emoji.Lines(); len(lines) > 0 { + return lines[0] + } + return nil } // loadImage loads the image at url in a square rows tall, unless it is loaded already. diff --git a/internal/ui/chat/messageslist/preview_test.go b/internal/ui/chat/messageslist/preview_test.go index be927fd..dad4bc0 100644 --- a/internal/ui/chat/messageslist/preview_test.go +++ b/internal/ui/chat/messageslist/preview_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/ayn2op/arikawa/v3/discord" + tviewimage "github.com/ayn2op/tview/image" ) func TestPreviewSources(t *testing.T) { @@ -77,3 +78,29 @@ func TestSquare(t *testing.T) { } }) } + +func TestModelEmojiLine(t *testing.T) { + ml := newTestModel(t, testConfig(t)) + if got := ml.emojiLine("5"); got != nil { + t.Fatalf("line = %v, want nil before the emoji is loaded", got) + } + + ml.images[emojiURL(5)] = tviewimage.New(image.NewAlpha(image.Rect(0, 0, 2, 2))).Width(2) + if got := ml.emojiLine("5"); len(got) != 2 { + t.Fatalf("line = %v, want the two cells of the emoji", got) + } +} + +func TestModelLoadPreviews(t *testing.T) { + ml := newTestModel(t, testConfig(t)) + ml.setMessages([]discord.Message{{ID: 2, ChannelID: 1, Content: "hi <:cat:5>"}}) + ml.loadPreviews() + if _, ok := ml.images[emojiURL(5)]; !ok { + t.Fatal("the emoji in the content was not loaded") + } + + item := &ml.items[len(ml.items)-1] + if item.root == nil { + t.Fatal("the parsed content was not kept for rendering") + } +}