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