feat(messageslist): show custom emoji images in messages, with options to show their names

This commit is contained in:
ayn2op 2026-10-17 17:07:53 -04:00
parent 997a77c033
commit b860df0a25
15 changed files with 260 additions and 79 deletions

View file

@ -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.

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -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
})

View file

@ -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)
}
})
}
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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))

View file

@ -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)
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() && cfg.ColonsAroundName {
name = ":" + 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,17 +279,18 @@ 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)
}
}
// Keep code blocks visually separate from "timestamp + author".
if startsWithCodeBlock && !builder.LineEmpty() {
@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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.

View file

@ -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")
}
}