Ver código fonte

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

ayn2op 1 mês atrás
pai
commit
9dcd43475b

+ 10 - 0
internal/config/config.toml

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

+ 25 - 6
internal/config/theme.go

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

+ 17 - 4
internal/markdown/renderer.go

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

+ 372 - 24
internal/ui/chat/messages_list.go

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