embed_renderer.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. package chat
  2. import (
  3. "net/url"
  4. "strings"
  5. "github.com/ayn2op/discordo/internal/config"
  6. "github.com/ayn2op/discordo/internal/ui"
  7. "github.com/ayn2op/tview"
  8. "github.com/diamondburned/arikawa/v3/discord"
  9. "github.com/gdamore/tcell/v3"
  10. "github.com/rivo/uniseg"
  11. )
  12. type embedLine struct {
  13. Text string
  14. Kind embedLineKind
  15. URL string
  16. }
  17. type embedLineKind uint8
  18. const (
  19. // Keep this ordering stable: drawEmbeds indexes precomputed style slots by this enum.
  20. embedLineProvider embedLineKind = iota
  21. embedLineAuthor
  22. embedLineTitle
  23. embedLineDescription
  24. embedLineFieldName
  25. embedLineFieldValue
  26. embedLineFooter
  27. embedLineURL
  28. embedLineKindCount
  29. )
  30. func embedLineStyles(baseStyle tcell.Style, theme config.MessagesListEmbedsTheme) [embedLineKindCount]tcell.Style {
  31. styles := [embedLineKindCount]tcell.Style{}
  32. styles[embedLineProvider] = ui.MergeStyle(baseStyle, theme.ProviderStyle.Style)
  33. styles[embedLineAuthor] = ui.MergeStyle(baseStyle, theme.AuthorStyle.Style)
  34. styles[embedLineTitle] = ui.MergeStyle(baseStyle, theme.TitleStyle.Style)
  35. styles[embedLineDescription] = ui.MergeStyle(baseStyle, theme.DescriptionStyle.Style)
  36. styles[embedLineFieldName] = ui.MergeStyle(baseStyle, theme.FieldNameStyle.Style)
  37. styles[embedLineFieldValue] = ui.MergeStyle(baseStyle, theme.FieldValueStyle.Style)
  38. styles[embedLineFooter] = ui.MergeStyle(baseStyle, theme.FooterStyle.Style)
  39. styles[embedLineURL] = ui.MergeStyle(baseStyle, theme.URLStyle.Style)
  40. return styles
  41. }
  42. type embedLineDedupKey struct {
  43. kind embedLineKind
  44. text string
  45. }
  46. func embedLines(embed discord.Embed, contentURLs map[string]struct{}) []embedLine {
  47. lines := make([]embedLine, 0, 8)
  48. seen := make(map[embedLineDedupKey]struct{}, 8)
  49. appendUnique := func(s string, kind embedLineKind, rawURL string) {
  50. s = strings.TrimSpace(s)
  51. if s == "" {
  52. return
  53. }
  54. // Deduplicate by kind+text so the same value can intentionally appear in multiple semantic slots with different styles (e.g. title vs. field).
  55. key := embedLineDedupKey{kind: kind, text: s}
  56. if _, ok := seen[key]; ok {
  57. return
  58. }
  59. seen[key] = struct{}{}
  60. lines = append(lines, embedLine{
  61. Text: s,
  62. Kind: kind,
  63. URL: rawURL,
  64. })
  65. }
  66. appendURL := func(url discord.URL) {
  67. u := strings.TrimSpace(string(url))
  68. if u == "" {
  69. return
  70. }
  71. // Avoid duplicating links that already appear in message body content.
  72. if _, ok := contentURLs[u]; ok {
  73. return
  74. }
  75. appendUnique(linkDisplayText(u), embedLineURL, u)
  76. }
  77. if embed.Provider != nil {
  78. appendUnique(embed.Provider.Name, embedLineProvider, "")
  79. }
  80. if embed.Author != nil {
  81. appendUnique(embed.Author.Name, embedLineAuthor, "")
  82. }
  83. appendUnique(embed.Title, embedLineTitle, string(embed.URL))
  84. // Some Discord embeds include markdown-escaped punctuation in raw payload text (e.g. "\."), so normalize for display.
  85. appendUnique(unescapeMarkdownEscapes(embed.Description), embedLineDescription, "")
  86. for _, field := range embed.Fields {
  87. switch {
  88. case field.Name != "" && field.Value != "":
  89. appendUnique(field.Name, embedLineFieldName, "")
  90. appendUnique(field.Value, embedLineFieldValue, "")
  91. case field.Name != "":
  92. appendUnique(field.Name, embedLineFieldName, "")
  93. default:
  94. appendUnique(field.Value, embedLineFieldValue, "")
  95. }
  96. }
  97. if embed.Footer != nil {
  98. appendUnique(embed.Footer.Text, embedLineFooter, "")
  99. }
  100. // Prefer media URLs after textual fields so previews read top-to-bottom before jumping to link targets.
  101. // When a title exists, embed.URL is represented by title Style.Url metadata instead of a separate URL row.
  102. if embed.Title == "" {
  103. appendURL(embed.URL)
  104. }
  105. if embed.Image != nil {
  106. appendURL(embed.Image.URL)
  107. }
  108. if embed.Video != nil {
  109. appendURL(embed.Video.URL)
  110. }
  111. return lines
  112. }
  113. func linkDisplayText(raw string) string {
  114. parsed, err := url.Parse(raw)
  115. if err != nil || parsed.Host == "" {
  116. return raw
  117. }
  118. path := strings.TrimSpace(parsed.EscapedPath())
  119. switch {
  120. case path == "", path == "/":
  121. return parsed.Host
  122. case len(path) > 48:
  123. return parsed.Host + path[:45] + "..."
  124. default:
  125. return parsed.Host + path
  126. }
  127. }
  128. func unescapeMarkdownEscapes(s string) string {
  129. if !strings.ContainsRune(s, '\\') {
  130. return s
  131. }
  132. var b strings.Builder
  133. b.Grow(len(s))
  134. for i := range len(s) {
  135. if s[i] == '\\' && i+1 < len(s) && isMarkdownEscapable(s[i+1]) {
  136. continue
  137. }
  138. b.WriteByte(s[i])
  139. }
  140. return b.String()
  141. }
  142. func isMarkdownEscapable(c byte) bool {
  143. switch c {
  144. case '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '|', '>', '~':
  145. return true
  146. default:
  147. return false
  148. }
  149. }
  150. func lineWithURL(line tview.Line, rawURL string) tview.Line {
  151. out := make(tview.Line, len(line))
  152. for i, segment := range line {
  153. out[i] = segment
  154. out[i].Style = out[i].Style.Url(rawURL)
  155. }
  156. return out
  157. }
  158. func wrapStyledLine(line tview.Line, width int) []tview.Line {
  159. if width <= 0 {
  160. return []tview.Line{line}
  161. }
  162. if len(line) == 0 {
  163. return []tview.Line{line}
  164. }
  165. lines := make([]tview.Line, 0, 2)
  166. current := make(tview.Line, 0, len(line))
  167. currentWidth := 0
  168. pushSegment := func(text string, style tcell.Style) {
  169. if text == "" {
  170. return
  171. }
  172. if n := len(current); n > 0 && current[n-1].Style == style {
  173. current[n-1].Text += text
  174. return
  175. }
  176. current = append(current, tview.Segment{Text: text, Style: style})
  177. }
  178. flush := func() {
  179. lineCopy := make(tview.Line, len(current))
  180. copy(lineCopy, current)
  181. lines = append(lines, lineCopy)
  182. current = current[:0]
  183. currentWidth = 0
  184. }
  185. for _, segment := range line {
  186. state := -1
  187. rest := segment.Text
  188. for len(rest) > 0 {
  189. cluster, nextRest, boundaries, nextState := uniseg.StepString(rest, state)
  190. state = nextState
  191. rest = nextRest
  192. if cluster == "" {
  193. continue
  194. }
  195. // Use grapheme width (not rune count) so wrapping stays correct with wide glyphs, emoji, and combining characters.
  196. clusterWidth := graphemeClusterWidth(boundaries)
  197. if currentWidth > 0 && currentWidth+clusterWidth > width {
  198. flush()
  199. }
  200. pushSegment(cluster, segment.Style)
  201. currentWidth += clusterWidth
  202. if currentWidth >= width {
  203. flush()
  204. }
  205. }
  206. }
  207. if len(current) > 0 {
  208. flush()
  209. }
  210. if len(lines) == 0 {
  211. return []tview.Line{{}}
  212. }
  213. return lines
  214. }
  215. func graphemeClusterWidth(boundaries int) int {
  216. return boundaries >> uniseg.ShiftWidth
  217. }