feat(ui/chat): render embeds (#766)

This commit is contained in:
ayn2op 2026-03-03 19:17:02 -05:00 • committed by GitHub
parent 37e99bf50d
commit 9dcd43475b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 424 additions and 34 deletions

View file

@ -224,6 +224,16 @@ attachment_style = { foreground = "yellow" }
message_style = {}
selected_message_style = { attributes = "reverse" }
[theme.messages_list.embeds]
provider_style = { attributes = ["dim", "italic"] }
author_style = { attributes = "italic" }
title_style = { foreground = "blue", attributes = "bold" }
description_style = { attributes = "dim" }
field_name_style = { attributes = ["bold", "underline"] }
field_value_style = {}
footer_style = { attributes = ["dim", "italic"] }
url_style = { foreground = "blue", underline = "solid" }
[theme.mentions_list]
# Note: width and height are capped to the avaliable space
# Minimum width

View file

@ -69,12 +69,18 @@ func (sw *StyleWrapper) UnmarshalTOML(v any) error {
case "underline":
if s, ok := val.(string); ok {
switch s {
case "": sw.Style = sw.Underline(tcell.UnderlineStyleNone)
case "solid": sw.Style = sw.Underline(tcell.UnderlineStyleSolid)
case "double": sw.Style = sw.Underline(tcell.UnderlineStyleDouble)
case "curly": sw.Style = sw.Underline(tcell.UnderlineStyleCurly)
case "dotted": sw.Style = sw.Underline(tcell.UnderlineStyleDotted)
case "dashed": sw.Style = sw.Underline(tcell.UnderlineStyleDashed)
case "":
sw.Style = sw.Underline(tcell.UnderlineStyleNone)
case "solid":
sw.Style = sw.Underline(tcell.UnderlineStyleSolid)
case "double":
sw.Style = sw.Underline(tcell.UnderlineStyleDouble)
case "curly":
sw.Style = sw.Underline(tcell.UnderlineStyleCurly)
case "dotted":
sw.Style = sw.Underline(tcell.UnderlineStyleDotted)
case "dashed":
sw.Style = sw.Underline(tcell.UnderlineStyleDashed)
}
}
case "underline_color":
@ -229,6 +235,19 @@ type (
MessageStyle StyleWrapper `toml:"message_style"`
SelectedMessageStyle StyleWrapper `toml:"selected_message_style"`
Embeds MessagesListEmbedsTheme `toml:"embeds"`
}
MessagesListEmbedsTheme struct {
ProviderStyle StyleWrapper `toml:"provider_style"`
AuthorStyle StyleWrapper `toml:"author_style"`
TitleStyle StyleWrapper `toml:"title_style"`
DescriptionStyle StyleWrapper `toml:"description_style"`
FieldNameStyle StyleWrapper `toml:"field_name_style"`
FieldValueStyle StyleWrapper `toml:"field_value_style"`
FooterStyle StyleWrapper `toml:"footer_style"`
URLStyle StyleWrapper `toml:"url_style"`
}
MentionsListTheme struct {

View file

@ -34,6 +34,7 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [
builder := tview.NewLineBuilder()
styleStack := []tcell.Style{base}
linkDepth := 0
currentStyle := func() tcell.Style {
return styleStack[len(styleStack)-1]
@ -76,12 +77,19 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [
}
case *ast.AutoLink:
if entering {
builder.Write(string(node.URL(source)), ui.MergeStyle(currentStyle(), theme.URLStyle.Style))
url := string(node.URL(source))
style := ui.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url)
builder.Write(url, style)
}
case *ast.Link:
if entering {
pushStyle(ui.MergeStyle(currentStyle(), theme.URLStyle.Style))
url := string(node.Destination)
linkDepth++
pushStyle(ui.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url))
} else {
if linkDepth > 0 {
linkDepth--
}
popStyle()
}
case *ast.List:
@ -112,7 +120,7 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [
}
case *discordmd.Inline:
if entering {
pushStyle(applyInlineAttr(currentStyle(), node.Attr))
pushStyle(applyInlineAttr(currentStyle(), node.Attr, linkDepth > 0))
} else {
popStyle()
}
@ -256,7 +264,7 @@ func mentionText(node *discordmd.Mention) string {
}
}
func applyInlineAttr(style tcell.Style, attr discordmd.Attribute) tcell.Style {
func applyInlineAttr(style tcell.Style, attr discordmd.Attribute, inLink bool) tcell.Style {
switch attr {
case discordmd.AttrBold:
return style.Bold(true)
@ -267,6 +275,11 @@ func applyInlineAttr(style tcell.Style, attr discordmd.Attribute) tcell.Style {
case discordmd.AttrStrikethrough:
return style.StrikeThrough(true)
case discordmd.AttrMonospace:
// Avoid reverse-video inside links. Link labels like `hash` should still
// look like links, not highlighted blocks.
if inLink {
return style
}
return style.Reverse(true)
}
return style

View file

@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
@ -32,6 +33,7 @@ import (
"github.com/diamondburned/ningen/v3/discordmd"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color"
"github.com/rivo/uniseg"
"github.com/skratchdot/open-golang/open"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
@ -408,32 +410,45 @@ func (ml *messagesList) memberForMessage(message discord.Message) *discord.Membe
}
func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
c := []byte(message.Content)
if ml.chatView.cfg.Markdown.Enabled {
root := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false)
lines := ml.renderer.RenderLines(c, root, baseStyle)
if builder.HasCurrentLine() {
startsWithCodeBlock := false
lines, root := ml.renderContentLines(message, baseStyle)
if ml.chatView.cfg.Markdown.Enabled && builder.HasCurrentLine() {
startsWithCodeBlock := false
if root != nil {
if first := root.FirstChild(); first != nil {
_, startsWithCodeBlock = first.(*ast.FencedCodeBlock)
}
}
if startsWithCodeBlock {
// Keep code blocks visually separate from "timestamp + author".
builder.NewLine()
for len(lines) > 0 && len(lines[0]) == 0 {
lines = lines[1:]
}
} else {
for len(lines) > 1 && len(lines[0]) == 0 {
lines = lines[1:]
}
if startsWithCodeBlock {
// Keep code blocks visually separate from "timestamp + author".
builder.NewLine()
for len(lines) > 0 && len(lines[0]) == 0 {
lines = lines[1:]
}
} else {
for len(lines) > 1 && len(lines[0]) == 0 {
lines = lines[1:]
}
}
builder.AppendLines(lines)
} else {
builder.Write(message.Content, baseStyle)
}
builder.AppendLines(lines)
}
func (ml *messagesList) renderContentLines(message discord.Message, baseStyle tcell.Style) ([]tview.Line, ast.Node) {
return ml.renderContentLinesWithMarkdown(message, baseStyle, false)
}
func (ml *messagesList) renderContentLinesWithMarkdown(message discord.Message, baseStyle tcell.Style, forceMarkdown bool) ([]tview.Line, ast.Node) {
// Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both.
if forceMarkdown || ml.chatView.cfg.Markdown.Enabled {
c := []byte(message.Content)
root := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false)
return ml.renderer.RenderLines(c, root, baseStyle), root
}
b := tview.NewLineBuilder()
b.Write(message.Content, baseStyle)
return b.Finish(), nil
}
func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tcell.Style) {
@ -469,6 +484,8 @@ func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message d
builder.Write(" (edited)", dimStyle)
}
ml.drawEmbeds(builder, message, baseStyle)
attachmentStyle := ui.MergeStyle(baseStyle, ml.cfg.Theme.MessagesList.AttachmentStyle.Style)
for _, a := range message.Attachments {
builder.NewLine()
@ -480,6 +497,304 @@ func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message d
}
}
func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
if len(message.Embeds) == 0 {
return
}
contentListURLs := extractURLs(message.Content)
contentURLs := make(map[string]struct{}, len(contentListURLs))
for _, u := range contentListURLs {
contentURLs[u] = struct{}{}
}
lineStyles := embedLineStyles(baseStyle, ml.cfg.Theme.MessagesList.Embeds)
defaultBarStyle := baseStyle.Dim(true)
prefixText := " ▎ "
prefixWidth := tview.TaggedStringWidth(prefixText)
_, _, innerWidth, _ := ml.GetInnerRect()
// Wrap against the current list viewport. This keeps embed wrapping stable even when sidebars/panes are resized.
wrapWidth := innerWidth - prefixWidth
if wrapWidth < 1 {
wrapWidth = 1
}
for _, embed := range message.Embeds {
lines := embedLines(embed, contentURLs)
if len(lines) == 0 {
continue
}
embedContentLines := make([]tview.Line, 0, len(lines)*2)
barStyle := defaultBarStyle
if embed.Color != discord.NullColor && embed.Color != 0 {
barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color.Uint32())))
}
prefix := tview.NewSegment(prefixText, barStyle)
builder.NewLine()
for _, line := range lines {
if strings.TrimSpace(line.Text) == "" {
continue
}
msg := message
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, _ := ml.renderContentLinesWithMarkdown(msg, lineStyle, line.Kind == embedLineDescription)
for _, renderedLine := range rendered {
if line.URL != "" {
renderedLine = lineWithURL(renderedLine, line.URL)
}
// Prefix must be applied after wrapping so every visual line keeps the embed bar marker ("▎"), not only the first logical line.
for _, wrapped := range wrapStyledLine(renderedLine, wrapWidth) {
prefixed := make(tview.Line, 0, len(wrapped)+1)
prefixed = append(prefixed, prefix)
prefixed = append(prefixed, wrapped...)
embedContentLines = append(embedContentLines, prefixed)
}
}
}
if len(embedContentLines) > 0 {
builder.AppendLines(embedContentLines)
}
}
}
func wrapStyledLine(line tview.Line, width int) []tview.Line {
if width <= 0 {
return []tview.Line{line}
}
if len(line) == 0 {
return []tview.Line{line}
}
lines := make([]tview.Line, 0, 2)
current := make(tview.Line, 0, len(line))
currentWidth := 0
pushSegment := func(text string, style tcell.Style) {
if text == "" {
return
}
if n := len(current); n > 0 && current[n-1].Style == style {
current[n-1].Text += text
return
}
current = append(current, tview.Segment{Text: text, Style: style})
}
flush := func() {
lineCopy := make(tview.Line, len(current))
copy(lineCopy, current)
lines = append(lines, lineCopy)
current = current[:0]
currentWidth = 0
}
for _, segment := range line {
state := -1
rest := segment.Text
for len(rest) > 0 {
cluster, nextRest, boundaries, nextState := uniseg.StepString(rest, state)
state = nextState
rest = nextRest
if cluster == "" {
continue
}
// Use grapheme width (not rune count) so wrapping stays correct with wide glyphs, emoji, and combining characters.
clusterWidth := graphemeClusterWidth(boundaries)
if currentWidth > 0 && currentWidth+clusterWidth > width {
flush()
}
pushSegment(cluster, segment.Style)
currentWidth += clusterWidth
if currentWidth >= width {
flush()
}
}
}
if len(current) > 0 {
flush()
}
if len(lines) == 0 {
return []tview.Line{{}}
}
return lines
}
func graphemeClusterWidth(boundaries int) int {
return boundaries >> uniseg.ShiftWidth
}
func lineWithURL(line tview.Line, rawURL string) tview.Line {
out := make(tview.Line, len(line))
for i, segment := range line {
out[i] = segment
out[i].Style = out[i].Style.Url(rawURL)
}
return out
}
type embedLine struct {
Text string
Kind embedLineKind
URL string
}
type embedLineKind uint8
const (
// Keep this ordering stable: drawEmbeds indexes precomputed style slots by this enum.
embedLineProvider embedLineKind = iota
embedLineAuthor
embedLineTitle
embedLineDescription
embedLineFieldName
embedLineFieldValue
embedLineFooter
embedLineURL
)
func embedLineStyles(baseStyle tcell.Style, theme config.MessagesListEmbedsTheme) [8]tcell.Style {
styles := [8]tcell.Style{}
styles[embedLineProvider] = ui.MergeStyle(baseStyle, theme.ProviderStyle.Style)
styles[embedLineAuthor] = ui.MergeStyle(baseStyle, theme.AuthorStyle.Style)
styles[embedLineTitle] = ui.MergeStyle(baseStyle, theme.TitleStyle.Style)
styles[embedLineDescription] = ui.MergeStyle(baseStyle, theme.DescriptionStyle.Style)
styles[embedLineFieldName] = ui.MergeStyle(baseStyle, theme.FieldNameStyle.Style)
styles[embedLineFieldValue] = ui.MergeStyle(baseStyle, theme.FieldValueStyle.Style)
styles[embedLineFooter] = ui.MergeStyle(baseStyle, theme.FooterStyle.Style)
styles[embedLineURL] = ui.MergeStyle(baseStyle, theme.URLStyle.Style)
return styles
}
type embedLineDedupKey struct {
kind embedLineKind
text string
}
func embedLines(embed discord.Embed, contentURLs map[string]struct{}) []embedLine {
lines := make([]embedLine, 0, 8)
seen := make(map[embedLineDedupKey]struct{}, 8)
appendUnique := func(s string, kind embedLineKind, rawURL string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
// Deduplicate by kind+text so the same value can intentionally appear in multiple semantic slots with different styles (e.g. title vs. field).
key := embedLineDedupKey{kind: kind, text: s}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
lines = append(lines, embedLine{
Text: s,
Kind: kind,
URL: rawURL,
})
}
appendURL := func(url discord.URL) {
u := strings.TrimSpace(string(url))
if u == "" {
return
}
// Avoid duplicating links that already appear in message body content.
if _, ok := contentURLs[u]; ok {
return
}
appendUnique(linkDisplayText(u), embedLineURL, u)
}
if embed.Provider != nil {
appendUnique(embed.Provider.Name, embedLineProvider, "")
}
if embed.Author != nil {
appendUnique(embed.Author.Name, embedLineAuthor, "")
}
appendUnique(embed.Title, embedLineTitle, string(embed.URL))
// Some Discord embeds include markdown-escaped punctuation in raw payload text (e.g. "\."), so normalize for display.
appendUnique(unescapeMarkdownEscapes(embed.Description), embedLineDescription, "")
for _, field := range embed.Fields {
switch {
case field.Name != "" && field.Value != "":
appendUnique(field.Name, embedLineFieldName, "")
appendUnique(field.Value, embedLineFieldValue, "")
case field.Name != "":
appendUnique(field.Name, embedLineFieldName, "")
default:
appendUnique(field.Value, embedLineFieldValue, "")
}
}
if embed.Footer != nil {
appendUnique(embed.Footer.Text, embedLineFooter, "")
}
// Prefer media URLs after textual fields so previews read top-to-bottom before jumping to link targets.
// When a title exists, embed.URL is represented by title Style.Url metadata instead of a separate URL row.
if embed.Title == "" {
appendURL(embed.URL)
}
if embed.Image != nil {
appendURL(embed.Image.URL)
}
if embed.Video != nil {
appendURL(embed.Video.URL)
}
return lines
}
func linkDisplayText(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return raw
}
path := strings.TrimSpace(parsed.EscapedPath())
switch {
case path == "", path == "/":
return parsed.Host
case len(path) > 48:
return parsed.Host + path[:45] + "..."
default:
return parsed.Host + path
}
}
func unescapeMarkdownEscapes(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) && isMarkdownEscapable(s[i+1]) {
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isMarkdownEscapable(c byte) bool {
switch c {
case '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '|', '>', '~':
return true
default:
return false
}
}
func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
dimStyle := baseStyle.Dim(true)
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
@ -740,10 +1055,7 @@ func (ml *messagesList) open() {
return
}
var urls []string
if msg.Content != "" {
urls = extractURLs(msg.Content)
}
urls := messageURLs(*msg)
if len(urls) == 0 && len(msg.Attachments) == 0 {
return
@ -788,6 +1100,42 @@ func extractURLs(content string) []string {
return urls
}
func extractEmbedURLs(embeds []discord.Embed) []string {
urls := make([]string, 0, len(embeds)*3)
for _, embed := range embeds {
if embed.URL != "" {
urls = append(urls, string(embed.URL))
}
if embed.Image != nil && embed.Image.URL != "" {
urls = append(urls, string(embed.Image.URL))
}
if embed.Video != nil && embed.Video.URL != "" {
urls = append(urls, string(embed.Video.URL))
}
}
return urls
}
func messageURLs(msg discord.Message) []string {
combined := extractURLs(msg.Content)
combined = append(combined, extractEmbedURLs(msg.Embeds)...)
urls := make([]string, 0, len(combined))
seen := make(map[string]struct{}, len(combined))
for _, u := range combined {
u = strings.TrimSpace(u)
if u == "" {
continue
}
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
urls = append(urls, u)
}
return urls
}
func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord.Attachment) {
var items []attachmentItem
for _, a := range attachments {
@ -1047,7 +1395,7 @@ func (ml *messagesList) FullHelp() [][]keybind.Keybind {
canOpen := false
if msg, err := ml.selectedMessage(); err == nil {
canSelectReply = msg.ReferencedMessage != nil
canOpen = len(extractURLs(msg.Content)) != 0 || len(msg.Attachments) != 0
canOpen = len(messageURLs(*msg)) != 0 || len(msg.Attachments) != 0
me, _ := ml.chatView.state.Cabinet.Me()
canReply = msg.Author.ID != me.ID