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"`
|
||||
|
||||
### 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
2
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
|
||||
|
|
|
|||
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/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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue