| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- 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
- }
|