package chat import ( "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(ui.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 } // isLinkPreviewEmbed returns true if the embed is a simple link preview // with no substantial content beyond provider/title/thumbnail. func isLinkPreviewEmbed(embed discord.Embed) bool { return embed.Description == "" && len(embed.Fields) == 0 && (embed.Footer == nil || embed.Footer.Text == "") } 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 // Word-wrap state: track the current word so we can break before it. wordLine := make(tview.Line, 0, 4) // segments in the current word wordWidth := 0 lineBeforeWord := make(tview.Line, 0, len(line)) // line content before the current word lineBeforeWordWidth := 0 pushSegment := func(dst *tview.Line, text string, style tcell.Style) { if text == "" { return } if n := len(*dst); n > 0 && (*dst)[n-1].Style == style { (*dst)[n-1].Text += text return } *dst = append(*dst, tview.Segment{Text: text, Style: style}) } commitWord := func() { for _, seg := range wordLine { pushSegment(¤t, seg.Text, seg.Style) } currentWidth += wordWidth // Save snapshot as potential break point. lineBeforeWord = append(lineBeforeWord[:0], current...) lineBeforeWordWidth = currentWidth wordLine = wordLine[:0] wordWidth = 0 } flush := func() { lineCopy := make(tview.Line, len(current)) copy(lineCopy, current) lines = append(lines, lineCopy) current = current[:0] currentWidth = 0 lineBeforeWord = lineBeforeWord[:0] lineBeforeWordWidth = 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 } clusterWidth := graphemeClusterWidth(boundaries) isSpace := cluster == " " || cluster == "\t" if isSpace { // Space: commit pending word, then add the space to the line. commitWord() if currentWidth+clusterWidth > width { flush() } else { pushSegment(¤t, cluster, segment.Style) currentWidth += clusterWidth lineBeforeWord = append(lineBeforeWord[:0], current...) lineBeforeWordWidth = currentWidth } continue } // Non-space: add to current word. if currentWidth+wordWidth+clusterWidth > width { if wordWidth > 0 && lineBeforeWordWidth > 0 { // Break before the current word: rewind to lineBeforeWord. current = append(current[:0], lineBeforeWord...) currentWidth = lineBeforeWordWidth flush() } else if currentWidth > 0 || wordWidth > 0 { // Word is at start of line or single long word: commit what we have and flush. commitWord() flush() } } pushSegment(&wordLine, cluster, segment.Style) wordWidth += clusterWidth } } // Flush remaining word and line. commitWord() if len(current) > 0 { flush() } if len(lines) == 0 { return []tview.Line{{}} } return lines } func graphemeClusterWidth(boundaries int) int { return boundaries >> uniseg.ShiftWidth }