util.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. package ui
  2. import (
  3. "cmp"
  4. "net/url"
  5. "path"
  6. "slices"
  7. "strings"
  8. "github.com/ayn2op/discordo/internal/config"
  9. "github.com/ayn2op/tview"
  10. "github.com/diamondburned/arikawa/v3/discord"
  11. "github.com/diamondburned/ningen/v3"
  12. "github.com/gdamore/tcell/v3"
  13. )
  14. // ConfigureBox configures the provided box according to the provided theme.
  15. func ConfigureBox(box *tview.Box, cfg *config.Theme) *tview.Box {
  16. border := cfg.Border
  17. normalBorderStyle, activeBorderStyle := border.NormalStyle.Style, border.ActiveStyle.Style
  18. normalBorderSet, activeBorderSet := border.NormalSet.BorderSet, border.ActiveSet.BorderSet
  19. title := cfg.Title
  20. normalTitleStyle, activeTitleStyle := title.NormalStyle.Style, title.ActiveStyle.Style
  21. footer := cfg.Footer
  22. normalFooterStyle, activeFooterStyle := footer.NormalStyle.Style, footer.ActiveStyle.Style
  23. padding := border.Padding
  24. box.
  25. SetBorderStyle(normalBorderStyle).
  26. SetBorderSet(normalBorderSet).
  27. SetBorderPadding(padding[0], padding[1], padding[2], padding[3]).
  28. SetTitleStyle(normalTitleStyle).
  29. SetTitleAlignment(title.Alignment.Alignment).
  30. SetFooterStyle(normalFooterStyle).
  31. SetFooterAlignment(footer.Alignment.Alignment).
  32. SetBlurFunc(func() {
  33. box.
  34. SetBorderStyle(normalBorderStyle).
  35. SetBorderSet(normalBorderSet)
  36. box.SetTitleStyle(normalTitleStyle).SetFooterStyle(normalFooterStyle)
  37. }).
  38. SetFocusFunc(func() {
  39. box.
  40. SetBorderStyle(activeBorderStyle).
  41. SetBorderSet(activeBorderSet)
  42. box.SetTitleStyle(activeTitleStyle).SetFooterStyle(activeFooterStyle)
  43. })
  44. if border.Enabled {
  45. box.SetBorders(tview.BordersAll)
  46. }
  47. return box
  48. }
  49. // Centered creates a new grid with provided primitive aligned in the center.
  50. func Centered(m tview.Model, width, height int) tview.Model {
  51. return tview.NewGrid().
  52. SetColumns(0, width, 0).
  53. SetRows(0, height, 0).
  54. AddItem(m, 1, 1, 1, 1, 0, 0, true)
  55. }
  56. func ChannelToString(channel discord.Channel, icons config.Icons, state *ningen.State) string {
  57. var icon string
  58. switch channel.Type {
  59. case discord.DirectMessage, discord.GroupDM:
  60. if channel.Name != "" {
  61. return channel.Name
  62. }
  63. recipients := make([]string, len(channel.DMRecipients))
  64. for i, r := range channel.DMRecipients {
  65. if state != nil && channel.Type == discord.DirectMessage {
  66. if rel, ok := state.RelationshipState.FullRelationship(r.ID); ok && rel.Type == discord.FriendRelationship {
  67. if rel.Nickname != nil && *rel.Nickname != "" {
  68. recipients[i] = *rel.Nickname
  69. continue
  70. }
  71. }
  72. }
  73. recipients[i] = r.DisplayOrUsername()
  74. }
  75. return strings.Join(recipients, ", ")
  76. case discord.GuildCategory:
  77. icon = icons.GuildCategory
  78. case discord.GuildText:
  79. icon = icons.GuildText
  80. case discord.GuildVoice:
  81. icon = icons.GuildVoice
  82. case discord.GuildStageVoice:
  83. icon = icons.GuildStageVoice
  84. case discord.GuildAnnouncementThread:
  85. icon = icons.GuildAnnouncementThread
  86. case discord.GuildPublicThread:
  87. icon = icons.GuildPublicThread
  88. case discord.GuildPrivateThread:
  89. icon = icons.GuildPrivateThread
  90. case discord.GuildAnnouncement:
  91. icon = icons.GuildAnnouncement
  92. case discord.GuildForum:
  93. icon = icons.GuildForum
  94. case discord.GuildStore:
  95. icon = icons.GuildStore
  96. }
  97. return icon + channel.Name
  98. }
  99. func SortGuildChannels(channels []discord.Channel) {
  100. slices.SortFunc(channels, func(a, b discord.Channel) int {
  101. return cmp.Compare(a.Position, b.Position)
  102. })
  103. }
  104. func SortPrivateChannels(channels []discord.Channel) {
  105. slices.SortFunc(channels, func(a, b discord.Channel) int {
  106. // Descending order
  107. return cmp.Compare(getMessageIDFromChannel(b), getMessageIDFromChannel(a))
  108. })
  109. }
  110. func getMessageIDFromChannel(channel discord.Channel) discord.MessageID {
  111. if channel.LastMessageID.IsValid() {
  112. return channel.LastMessageID
  113. }
  114. return discord.MessageID(channel.ID)
  115. }
  116. // LinkDisplayText returns a short, human-friendly label for a URL.
  117. // Known hosts get special treatment; everything else shows host + truncated path.
  118. func LinkDisplayText(raw string) string {
  119. parsed, err := url.Parse(raw)
  120. if err != nil || parsed.Host == "" {
  121. return raw
  122. }
  123. host := strings.ToLower(parsed.Host)
  124. p := strings.TrimRight(parsed.EscapedPath(), "/")
  125. segments := strings.Split(strings.TrimLeft(p, "/"), "/")
  126. // Discord CDN / media — show filename
  127. if host == "cdn.discordapp.com" || host == "media.discordapp.net" {
  128. if base := path.Base(p); base != "" && base != "." && base != "/" {
  129. return base
  130. }
  131. }
  132. // Tenor GIFs
  133. if host == "tenor.com" || strings.HasSuffix(host, ".tenor.com") {
  134. return "Tenor GIF"
  135. }
  136. // Substack: open.substack.com/pub/{author} or {author}.substack.com
  137. switch {
  138. case host == "open.substack.com" || host == "substack.com":
  139. if len(segments) >= 2 && segments[0] == "pub" && segments[1] != "" {
  140. return "Substack - " + segments[1]
  141. }
  142. return "Substack"
  143. case strings.HasSuffix(host, ".substack.com"):
  144. return "Substack - " + strings.TrimSuffix(host, ".substack.com")
  145. }
  146. // YouTube
  147. if host == "youtube.com" || host == "www.youtube.com" || host == "m.youtube.com" || host == "youtu.be" {
  148. return "YouTube"
  149. }
  150. // Twitter / X
  151. if host == "twitter.com" || host == "www.twitter.com" || host == "x.com" || host == "www.x.com" {
  152. return "X (Twitter)"
  153. }
  154. // Reddit
  155. if host == "reddit.com" || host == "www.reddit.com" || host == "old.reddit.com" {
  156. if len(segments) >= 2 && segments[0] == "r" {
  157. return "Reddit - r/" + segments[1]
  158. }
  159. return "Reddit"
  160. }
  161. // GitHub
  162. if host == "github.com" || host == "www.github.com" {
  163. if len(segments) >= 2 && segments[0] != "" && segments[1] != "" {
  164. return "GitHub - " + segments[0] + "/" + segments[1]
  165. }
  166. return "GitHub"
  167. }
  168. // Generic fallback: host + truncated path
  169. switch {
  170. case p == "", p == "/":
  171. return parsed.Host
  172. case len(p) > 48:
  173. return parsed.Host + p[:45] + "..."
  174. default:
  175. return parsed.Host + p
  176. }
  177. }
  178. func MergeStyle(base, overlay tcell.Style) tcell.Style {
  179. fg := overlay.GetForeground()
  180. if fg == tcell.ColorDefault {
  181. fg = base.GetForeground()
  182. }
  183. bg := overlay.GetBackground()
  184. if bg == tcell.ColorDefault {
  185. bg = base.GetBackground()
  186. }
  187. style := base.Foreground(fg).Background(bg)
  188. style = style.Bold(base.HasBold() || overlay.HasBold())
  189. style = style.Dim(base.HasDim() || overlay.HasDim())
  190. style = style.Italic(base.HasItalic() || overlay.HasItalic())
  191. style = style.Blink(base.HasBlink() || overlay.HasBlink())
  192. style = style.Reverse(base.HasReverse() || overlay.HasReverse())
  193. style = style.StrikeThrough(base.HasStrikeThrough() || overlay.HasStrikeThrough())
  194. if base.HasUnderline() || overlay.HasUnderline() {
  195. style = style.Underline(true)
  196. }
  197. return style
  198. }