feat(messageslist): show custom emoji images in messages, with options to show their names
This commit is contained in:
parent
997a77c033
commit
b860df0a25
15 changed files with 260 additions and 79 deletions
|
|
@ -424,13 +424,27 @@ The longest time between two messages by the same author for them to share a hea
|
||||||
|
|
||||||
Default: `"10m0s"`
|
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
|
||||||
|
|
||||||
#### 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
|
#### 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`
|
Default: `false`
|
||||||
|
|
||||||
|
#### ui.messages_list.reactions.separator
|
||||||
|
|
||||||
|
The text between reactions.
|
||||||
|
|
||||||
|
Default: `" • "`
|
||||||
|
|
||||||
## ui.mentions_list
|
## ui.mentions_list
|
||||||
|
|
||||||
The list of mention suggestions above the composer. Its width and height are capped to the available space.
|
The list of mention suggestions above the composer. Its width and height are capped to the available space.
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -16,7 +16,7 @@ require (
|
||||||
github.com/alecthomas/chroma/v2 v2.27.0
|
github.com/alecthomas/chroma/v2 v2.27.0
|
||||||
github.com/ayn2op/arikawa/v3 v3.0.0-20261016043052-54a68a54e42e
|
github.com/ayn2op/arikawa/v3 v3.0.0-20261016043052-54a68a54e42e
|
||||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426
|
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/fhttp v0.6.9
|
||||||
github.com/bogdanfinn/tls-client v1.16.0
|
github.com/bogdanfinn/tls-client v1.16.0
|
||||||
github.com/gdamore/tcell/v3 v3.6.0
|
github.com/gdamore/tcell/v3 v3.6.0
|
||||||
|
|
|
||||||
4
go.sum
4
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/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 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0=
|
||||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks=
|
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-20261017205514-f83a3769b18a h1:gJyFlYT+igFchVDNUMapJdy8iM0cVA0/LxOqZ8n6FZc=
|
||||||
github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA=
|
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 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||||
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||||
|
|
|
||||||
|
|
@ -183,16 +183,24 @@ type (
|
||||||
Attachments AttachmentsUIConfig `toml:"attachments"`
|
Attachments AttachmentsUIConfig `toml:"attachments"`
|
||||||
Avatars AvatarsUIConfig `toml:"avatars"`
|
Avatars AvatarsUIConfig `toml:"avatars"`
|
||||||
Grouping GroupingUIConfig `toml:"grouping"`
|
Grouping GroupingUIConfig `toml:"grouping"`
|
||||||
|
Emojis EmojiUIConfig `toml:"emojis"`
|
||||||
Reactions ReactionsUIConfig `toml:"reactions"`
|
Reactions ReactionsUIConfig `toml:"reactions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactionsUIConfig struct {
|
// EmojiUIConfig is how custom emoji are shown: their image, then their name.
|
||||||
// The text between reactions.
|
EmojiUIConfig struct {
|
||||||
Separator string `toml:"separator"`
|
// 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:`.
|
// Whether the names of custom emoji are shown between colons, as in `:name:`.
|
||||||
ColonsAroundName bool `toml:"colons_around_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 shows consecutive messages by the same author under one header.
|
||||||
GroupingUIConfig struct {
|
GroupingUIConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
|
|
@ -261,3 +269,11 @@ func defaultUI() UIConfig {
|
||||||
MentionsList: MentionsListUIConfig{MinWidth: 20},
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ func NewRenderer(cfg *config.Config) *Renderer {
|
||||||
return &Renderer{cfg: cfg}
|
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)
|
builder := new(Builder)
|
||||||
styleStack := []tview.Style{base}
|
styleStack := []tview.Style{base}
|
||||||
linkDepth := 0
|
linkDepth := 0
|
||||||
|
|
@ -104,7 +105,16 @@ func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) []
|
||||||
case *md.Mention:
|
case *md.Mention:
|
||||||
builder.Write(mentionText(node), tview.MergeStyle(currentStyle(), theme.MentionStyle.Style))
|
builder.Write(mentionText(node), tview.MergeStyle(currentStyle(), theme.MentionStyle.Style))
|
||||||
case *md.Emoji:
|
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
|
return ast.WalkContinue, nil
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
md "github.com/ayn2op/arikawa/v3/markdown"
|
md "github.com/ayn2op/arikawa/v3/markdown"
|
||||||
"github.com/ayn2op/discordo/internal/config"
|
"github.com/ayn2op/discordo/internal/config"
|
||||||
|
"github.com/ayn2op/tview/text"
|
||||||
"github.com/gdamore/tcell/v3"
|
"github.com/gdamore/tcell/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ func TestRendererRenderText(t *testing.T) {
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
source := []byte(test.source)
|
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
|
var got strings.Builder
|
||||||
for _, span := range rendered {
|
for _, span := range rendered {
|
||||||
got.WriteString(span.Text)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,22 +20,17 @@ const embedPrefix = " ▎ "
|
||||||
// embedPrefixWidth is how many cells embedPrefix takes.
|
// embedPrefixWidth is how many cells embedPrefix takes.
|
||||||
var embedPrefixWidth = uucode.StringWidth(embedPrefix)
|
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 {
|
if len(message.Embeds) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed URLs are deduplicated against links already shown in the message body.
|
// 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.
|
contentListURLs := contentURLs(message.Content, root)
|
||||||
var contentListURLs []string
|
seenURLs := make(map[string]struct{}, len(contentListURLs))
|
||||||
if contentRoot != nil {
|
|
||||||
contentListURLs = urlsFromAST(contentRoot, contentSource)
|
|
||||||
} else {
|
|
||||||
contentListURLs = extractURLs(message.Content)
|
|
||||||
}
|
|
||||||
contentURLs := make(map[string]struct{}, len(contentListURLs))
|
|
||||||
for _, u := range contentListURLs {
|
for _, u := range contentListURLs {
|
||||||
contentURLs[u] = struct{}{}
|
seenURLs[u] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds)
|
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)
|
wrapWidth := max(width-embedPrefixWidth, 1)
|
||||||
|
|
||||||
for _, embed := range message.Embeds {
|
for _, embed := range message.Embeds {
|
||||||
lines := embedLines(embed, contentURLs)
|
lines := embedLines(embed, seenURLs)
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +58,7 @@ func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, ba
|
||||||
msg.Content = line.text
|
msg.Content = line.text
|
||||||
lineStyle := lineStyles[line.kind]
|
lineStyle := lineStyles[line.kind]
|
||||||
// Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled.
|
// 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 != "" {
|
if line.url != "" {
|
||||||
rendered = spansWithURL(rendered, line.url)
|
rendered = spansWithURL(rendered, line.url)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ func (m Model) ShortHelp() []keybind.Keybind {
|
||||||
if !ui.IsMe(m.state, selectedMessage.Author.ID) {
|
if !ui.IsMe(m.state, selectedMessage.Author.ID) {
|
||||||
help = append(help, cfg.Reply.Keybind)
|
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)
|
help = append(help, cfg.Open.Keybind)
|
||||||
}
|
}
|
||||||
if isVoiceMessage(*selectedMessage) {
|
if isVoiceMessage(*selectedMessage) {
|
||||||
|
|
@ -51,7 +51,7 @@ func (m Model) FullHelp() [][]keybind.Keybind {
|
||||||
manage = append(manage, cfg.Delete.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)
|
attachments = append(attachments, cfg.Open.Keybind, cfg.OpenInBrowser.Keybind)
|
||||||
}
|
}
|
||||||
if len(message.Attachments) != 0 {
|
if len(message.Attachments) != 0 {
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,13 @@ import (
|
||||||
"github.com/yuin/goldmark/v2/ast"
|
"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)
|
src := []byte(content)
|
||||||
node := md.Parse(src)
|
if root == nil {
|
||||||
return urlsFromAST(node, src)
|
root = md.Parse(src)
|
||||||
|
}
|
||||||
|
return urlsFromAST(root, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
// urlsFromAST collects link destinations from an already-parsed markdown AST.
|
// urlsFromAST collects link destinations from an already-parsed markdown AST.
|
||||||
|
|
@ -57,8 +60,9 @@ const (
|
||||||
schemeHTTPS = "https"
|
schemeHTTPS = "https"
|
||||||
)
|
)
|
||||||
|
|
||||||
func messageURLs(msg discord.Message) []string {
|
// messageURLs returns the web links of msg. root is its parsed content, or nil to parse it here.
|
||||||
combined := slices.Concat(extractURLs(msg.Content), extractEmbedURLs(msg.Embeds))
|
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))
|
urls := make([]string, 0, len(combined))
|
||||||
seen := make(map[string]struct{}, len(combined))
|
seen := make(map[string]struct{}, len(combined))
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,10 @@ import (
|
||||||
|
|
||||||
// messageViews returns the views of the text of message and of what it replies to.
|
// 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.
|
// 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
|
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 {
|
reply = &messageView{render: func(int) []text.Span {
|
||||||
builder := new(markdown.Builder)
|
builder := new(markdown.Builder)
|
||||||
m.drawReply(builder, message, style)
|
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 (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)
|
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.
|
// Without a header or content, the message starts with the line break that attachments and embeds start below.
|
||||||
return trimLeadingLineBreaks(builder.Finish())
|
return trimLeadingLineBreaks(builder.Finish())
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +114,7 @@ func trimLeadingLineBreaks(spans []text.Span) []text.Span {
|
||||||
return spans
|
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) {
|
if m.blocked(message.Author) {
|
||||||
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
|
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
|
||||||
return
|
return
|
||||||
|
|
@ -124,12 +125,12 @@ func (m Model) writeMessage(builder *markdown.Builder, message discord.Message,
|
||||||
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
|
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
|
||||||
m.drawForwardedMessage(builder, message, baseStyle, grouped)
|
m.drawForwardedMessage(builder, message, baseStyle, grouped)
|
||||||
} else {
|
} else {
|
||||||
m.drawDefaultMessage(builder, message, baseStyle, width, grouped)
|
m.drawDefaultMessage(builder, message, root, baseStyle, width, grouped)
|
||||||
}
|
}
|
||||||
case discord.GuildMemberJoinMessage:
|
case discord.GuildMemberJoinMessage:
|
||||||
m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) })
|
m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) })
|
||||||
case discord.InlinedReplyMessage:
|
case discord.InlinedReplyMessage:
|
||||||
m.drawDefaultMessage(builder, message, baseStyle, width, false)
|
m.drawDefaultMessage(builder, message, root, baseStyle, width, false)
|
||||||
case discord.ChannelPinnedMessage:
|
case discord.ChannelPinnedMessage:
|
||||||
drawPinnedMessage(builder, message, baseStyle)
|
drawPinnedMessage(builder, message, baseStyle)
|
||||||
default:
|
default:
|
||||||
|
|
@ -153,19 +154,24 @@ func (m Model) reactionsView(reactions []discord.Reaction) tview.Widget {
|
||||||
}
|
}
|
||||||
style = tview.MergeStyle(theme.MessageStyle.Style, style)
|
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
|
name := reaction.Emoji.Name
|
||||||
if reaction.Emoji.IsCustom() && cfg.ColonsAroundName {
|
if reaction.Emoji.IsCustom() {
|
||||||
name = ":" + name + ":"
|
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.
|
// Each reaction but the last ends with the separator, so a line never starts with one.
|
||||||
if i < len(reactions)-1 {
|
if i < len(reactions)-1 {
|
||||||
spans = append(spans, text.Span{Text: cfg.Separator, Style: separatorStyle})
|
spans = append(spans, text.Span{Text: cfg.Separator, Style: separatorStyle})
|
||||||
}
|
}
|
||||||
|
|
||||||
var chip tview.Widget = text.Rich(spans...)
|
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)
|
chip = row.New(emoji, chip).Spacing(1).Width(layout.Shrink).Height(layout.Shrink)
|
||||||
}
|
}
|
||||||
chips[i] = chip
|
chips[i] = chip
|
||||||
|
|
@ -273,17 +279,18 @@ func (m Model) memberForMessage(message discord.Message) *discord.Member {
|
||||||
return 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).
|
// drawContent renders the message body. root is its parsed content, or nil to parse it here.
|
||||||
// root is nil when markdown rendering is disabled.
|
func (m Model) drawContent(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style) {
|
||||||
func (m Model) drawContent(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) (ast.Node, []byte) {
|
if root == nil && m.cfg.Markdown.Enabled {
|
||||||
content, root, source := m.renderContent(message, baseStyle, false)
|
root = m.parse(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := m.renderContent(message, root, baseStyle, false)
|
||||||
if m.cfg.Markdown.Enabled {
|
if m.cfg.Markdown.Enabled {
|
||||||
startsWithCodeBlock := false
|
startsWithCodeBlock := false
|
||||||
if root != nil {
|
|
||||||
if first := root.FirstChild(); first != nil {
|
if first := root.FirstChild(); first != nil {
|
||||||
_, startsWithCodeBlock = first.(*ast.CodeBlock)
|
_, startsWithCodeBlock = first.(*ast.CodeBlock)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Keep code blocks visually separate from "timestamp + author".
|
// Keep code blocks visually separate from "timestamp + author".
|
||||||
if startsWithCodeBlock && !builder.LineEmpty() {
|
if startsWithCodeBlock && !builder.LineEmpty() {
|
||||||
|
|
@ -293,20 +300,22 @@ func (m Model) drawContent(builder *markdown.Builder, message discord.Message, b
|
||||||
content = trimLeadingLineBreaks(content)
|
content = trimLeadingLineBreaks(content)
|
||||||
}
|
}
|
||||||
builder.WriteSpans(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.
|
// 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 {
|
if forceMarkdown || m.cfg.Markdown.Enabled {
|
||||||
c := []byte(message.Content)
|
if root == nil {
|
||||||
root := md.ParseWithMessage(c, *m.state.Cabinet, &message)
|
root = m.parse(message)
|
||||||
return m.renderer.RenderText(c, root, baseStyle), root, c
|
}
|
||||||
|
|
||||||
|
return m.renderer.RenderText([]byte(message.Content), root, baseStyle, m.emojiLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
b := new(markdown.Builder)
|
b := new(markdown.Builder)
|
||||||
b.Write(message.Content, baseStyle)
|
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) {
|
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,
|
ChannelID: parent.ChannelID,
|
||||||
GuildID: parent.GuildID,
|
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) {
|
func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) {
|
||||||
var contentRoot ast.Node
|
content := func() { m.drawContent(builder, message, root, baseStyle) }
|
||||||
var contentSource []byte
|
|
||||||
content := func() { contentRoot, contentSource = m.drawContent(builder, message, baseStyle) }
|
|
||||||
if message.Content == "" {
|
if message.Content == "" {
|
||||||
content = nil
|
content = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m.drawLayout(builder, message, baseStyle, grouped, content)
|
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)
|
attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style)
|
||||||
// A voice message is shown as its player.
|
// 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
|
reference.GuildID = message.GuildID
|
||||||
m.drawAuthor(builder, *reference, dimStyle)
|
m.drawAuthor(builder, *reference, dimStyle)
|
||||||
builder.Write(" ", dimStyle)
|
builder.Write(" ", dimStyle)
|
||||||
m.drawContent(builder, *reference, dimStyle)
|
m.drawContent(builder, *reference, nil, dimStyle)
|
||||||
} else {
|
} else {
|
||||||
builder.Write("Original message was deleted", dimStyle)
|
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) {
|
func drawPinnedMessage(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) {
|
||||||
builder.Write(message.Author.DisplayOrUsername()+" pinned a message.", baseStyle)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"github.com/ayn2op/tview/scrollbar"
|
"github.com/ayn2op/tview/scrollbar"
|
||||||
"github.com/ayn2op/tview/spacer"
|
"github.com/ayn2op/tview/spacer"
|
||||||
"github.com/ayn2op/tview/viewport"
|
"github.com/ayn2op/tview/viewport"
|
||||||
|
"github.com/yuin/goldmark/v2/ast"
|
||||||
)
|
)
|
||||||
|
|
||||||
type messageItem struct {
|
type messageItem struct {
|
||||||
|
|
@ -33,6 +34,8 @@ type messageItem struct {
|
||||||
|
|
||||||
// A preview is blank while loading and empty if loading failed.
|
// A preview is blank while loading and empty if loading failed.
|
||||||
previews map[discord.URL]tviewimage.Widget
|
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 {
|
type Model struct {
|
||||||
|
|
@ -257,7 +260,7 @@ func (m Model) buildItem(index int) tview.Widget {
|
||||||
|
|
||||||
message := &item.message
|
message := &item.message
|
||||||
if item.text == nil {
|
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)
|
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
|
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.
|
// 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) {
|
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
switch msg := msg.(type) {
|
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 {
|
if _, ok := m.images[msg.proxy]; ok {
|
||||||
m.images[msg.proxy] = preview
|
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
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -601,6 +616,14 @@ func (m Model) canDeleteMessage(message discord.Message) bool {
|
||||||
|
|
||||||
func (m Model) InvalidateRendered() {
|
func (m Model) InvalidateRendered() {
|
||||||
for i := range m.items {
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ func TestWaveformRail(t *testing.T) {
|
||||||
func TestMessageURLs(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"}
|
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)
|
t.Errorf("got %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +223,7 @@ func TestModelDrawLayout(t *testing.T) {
|
||||||
cfg.UI.MessagesList.Compact = compact
|
cfg.UI.MessagesList.Compact = compact
|
||||||
|
|
||||||
var got strings.Builder
|
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)
|
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 {
|
if !ml.items[newest].grouped || ml.items[ml.messageIndex(-1, 1)].grouped {
|
||||||
t.Fatal("only the first message has a header")
|
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)
|
t.Fatalf("grouped message = %v, want only its content", got)
|
||||||
}
|
}
|
||||||
second := ml.messageIndex(newest, -1)
|
second := ml.messageIndex(newest, -1)
|
||||||
|
|
@ -386,8 +386,14 @@ func TestModelReactionsView(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("emoji image", func(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)
|
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 name.
|
// 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" {
|
if got := draw(); got != "ok 2 • cat 1" {
|
||||||
t.Fatalf("reactions = %q", got)
|
t.Fatalf("reactions = %q", got)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ func (m Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tview
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
urls := messageURLs(*selectedMessage)
|
urls := m.selectedURLs()
|
||||||
switch total := len(urls) + len(selectedMessage.Attachments); {
|
switch total := len(urls) + len(selectedMessage.Attachments); {
|
||||||
case total == 0:
|
case total == 0:
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,11 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/ayn2op/arikawa/v3/discord"
|
"github.com/ayn2op/arikawa/v3/discord"
|
||||||
|
md "github.com/ayn2op/arikawa/v3/markdown"
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
tviewimage "github.com/ayn2op/tview/image"
|
tviewimage "github.com/ayn2op/tview/image"
|
||||||
|
"github.com/ayn2op/tview/text"
|
||||||
|
"github.com/yuin/goldmark/v2/ast"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -88,7 +91,20 @@ func (m Model) loadPreviews() tview.Cmd {
|
||||||
|
|
||||||
cmds = append(cmds, m.loadAvatar(item.message.Author))
|
cmds = append(cmds, m.loadAvatar(item.message.Author))
|
||||||
for _, reaction := range item.message.Reactions {
|
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 {
|
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))
|
return m.loadImage(avatarURL(user), uint(cfg.Height))
|
||||||
}
|
}
|
||||||
|
|
||||||
func emojiURL(emoji discord.Emoji) discord.URL {
|
func emojiURL(id discord.EmojiID) discord.URL {
|
||||||
return emoji.EmojiURLWithType(discord.PNGImage)
|
return discord.Emoji{ID: id}.EmojiURLWithType(discord.PNGImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadEmoji loads the image of a custom emoji, a row tall.
|
// loadEmoji loads the image of the custom emoji with id, a row tall.
|
||||||
func (m Model) loadEmoji(emoji discord.Emoji) tview.Cmd {
|
func (m Model) loadEmoji(id discord.EmojiID) tview.Cmd {
|
||||||
if !emoji.IsCustom() {
|
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 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.
|
// loadImage loads the image at url in a square rows tall, unless it is loaded already.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/ayn2op/arikawa/v3/discord"
|
"github.com/ayn2op/arikawa/v3/discord"
|
||||||
|
tviewimage "github.com/ayn2op/tview/image"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPreviewSources(t *testing.T) {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue