diff --git a/internal/config/config.toml b/internal/config/config.toml index e2bfaa9..1fe5956 100644 --- a/internal/config/config.toml +++ b/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 diff --git a/internal/config/theme.go b/internal/config/theme.go index 1320b79..7af25ba 100644 --- a/internal/config/theme.go +++ b/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 { diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index a2ca2f2..931e256 100644 --- a/internal/markdown/renderer.go +++ b/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 diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 683a160..4d9b4fc 100644 --- a/internal/ui/chat/messages_list.go +++ b/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