package chat import ( "net/url" "strings" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" "github.com/diamondburned/arikawa/v3/discord" "github.com/gdamore/tcell/v3" "github.com/rivo/uniseg" ) 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 embedLineKindCount ) func embedLineStyles(baseStyle tcell.Style, theme config.MessagesListEmbedsTheme) [embedLineKindCount]tcell.Style { styles := [embedLineKindCount]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 := range len(s) { 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 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 } 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 }