package ui import ( "cmp" "net/url" "path" "slices" "strings" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/tview" "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/ningen/v3" "github.com/gdamore/tcell/v3" ) // ConfigureBox configures the provided box according to the provided theme. func ConfigureBox(box *tview.Box, cfg *config.Theme) *tview.Box { border := cfg.Border normalBorderStyle, activeBorderStyle := border.NormalStyle.Style, border.ActiveStyle.Style normalBorderSet, activeBorderSet := border.NormalSet.BorderSet, border.ActiveSet.BorderSet title := cfg.Title normalTitleStyle, activeTitleStyle := title.NormalStyle.Style, title.ActiveStyle.Style footer := cfg.Footer normalFooterStyle, activeFooterStyle := footer.NormalStyle.Style, footer.ActiveStyle.Style padding := border.Padding box. SetBorderStyle(normalBorderStyle). SetBorderSet(normalBorderSet). SetBorderPadding(padding[0], padding[1], padding[2], padding[3]). SetTitleStyle(normalTitleStyle). SetTitleAlignment(title.Alignment.Alignment). SetFooterStyle(normalFooterStyle). SetFooterAlignment(footer.Alignment.Alignment). SetBlurFunc(func() { box. SetBorderStyle(normalBorderStyle). SetBorderSet(normalBorderSet) box.SetTitleStyle(normalTitleStyle).SetFooterStyle(normalFooterStyle) }). SetFocusFunc(func() { box. SetBorderStyle(activeBorderStyle). SetBorderSet(activeBorderSet) box.SetTitleStyle(activeTitleStyle).SetFooterStyle(activeFooterStyle) }) if border.Enabled { box.SetBorders(tview.BordersAll) } return box } // Centered creates a new grid with provided primitive aligned in the center. func Centered(m tview.Model, width, height int) tview.Model { return tview.NewGrid(). SetColumns(0, width, 0). SetRows(0, height, 0). AddItem(m, 1, 1, 1, 1, 0, 0, true) } func ChannelToString(channel discord.Channel, icons config.Icons, state *ningen.State) string { var icon string switch channel.Type { case discord.DirectMessage, discord.GroupDM: if channel.Name != "" { return channel.Name } recipients := make([]string, len(channel.DMRecipients)) for i, r := range channel.DMRecipients { if state != nil && channel.Type == discord.DirectMessage { if rel, ok := state.RelationshipState.FullRelationship(r.ID); ok && rel.Type == discord.FriendRelationship { if rel.Nickname != nil && *rel.Nickname != "" { recipients[i] = *rel.Nickname continue } } } recipients[i] = r.DisplayOrUsername() } return strings.Join(recipients, ", ") case discord.GuildCategory: icon = icons.GuildCategory case discord.GuildText: icon = icons.GuildText case discord.GuildVoice: icon = icons.GuildVoice case discord.GuildStageVoice: icon = icons.GuildStageVoice case discord.GuildAnnouncementThread: icon = icons.GuildAnnouncementThread case discord.GuildPublicThread: icon = icons.GuildPublicThread case discord.GuildPrivateThread: icon = icons.GuildPrivateThread case discord.GuildAnnouncement: icon = icons.GuildAnnouncement case discord.GuildForum: icon = icons.GuildForum case discord.GuildStore: icon = icons.GuildStore } return icon + channel.Name } func SortGuildChannels(channels []discord.Channel) { slices.SortFunc(channels, func(a, b discord.Channel) int { return cmp.Compare(a.Position, b.Position) }) } func SortPrivateChannels(channels []discord.Channel) { slices.SortFunc(channels, func(a, b discord.Channel) int { // Descending order return cmp.Compare(getMessageIDFromChannel(b), getMessageIDFromChannel(a)) }) } func getMessageIDFromChannel(channel discord.Channel) discord.MessageID { if channel.LastMessageID.IsValid() { return channel.LastMessageID } return discord.MessageID(channel.ID) } // LinkDisplayText returns a short, human-friendly label for a URL. // Known hosts get special treatment; everything else shows host + truncated path. func LinkDisplayText(raw string) string { parsed, err := url.Parse(raw) if err != nil || parsed.Host == "" { return raw } host := strings.ToLower(parsed.Host) p := strings.TrimRight(parsed.EscapedPath(), "/") segments := strings.Split(strings.TrimLeft(p, "/"), "/") // Discord CDN / media — show cleaned filename if host == "cdn.discordapp.com" || host == "media.discordapp.net" { if base := path.Base(p); base != "" && base != "." && base != "/" { return cdnDisplayName(base) } } // Tenor GIFs if host == "tenor.com" || strings.HasSuffix(host, ".tenor.com") { return "Tenor GIF" } // Substack: open.substack.com/pub/{author} or {author}.substack.com switch { case host == "open.substack.com" || host == "substack.com": if len(segments) >= 2 && segments[0] == "pub" && segments[1] != "" { return "Substack - " + segments[1] } return "Substack" case strings.HasSuffix(host, ".substack.com"): return "Substack - " + strings.TrimSuffix(host, ".substack.com") } // YouTube if host == "youtube.com" || host == "www.youtube.com" || host == "m.youtube.com" || host == "youtu.be" { return "YouTube" } // Twitter / X if host == "twitter.com" || host == "www.twitter.com" || host == "x.com" || host == "www.x.com" { return "X (Twitter)" } // Reddit if host == "reddit.com" || host == "www.reddit.com" || host == "old.reddit.com" { if len(segments) >= 2 && segments[0] == "r" { return "Reddit - r/" + segments[1] } return "Reddit" } // GitHub if host == "github.com" || host == "www.github.com" { if len(segments) >= 2 && segments[0] != "" && segments[1] != "" { return "GitHub - " + segments[0] + "/" + segments[1] } return "GitHub" } // Generic fallback: host + truncated path switch { case p == "", p == "/": return parsed.Host case len(p) > 48: return parsed.Host + p[:45] + "..." default: return parsed.Host + p } } // cdnDisplayName cleans up Discord CDN filenames for display. // Handles URL-encoded URLs used as filenames and UUID filenames. func cdnDisplayName(name string) string { ext := path.Ext(name) stem := strings.TrimSuffix(name, ext) // URL-encoded URL as filename (e.g., "https3A2F2F...512x512.png") if strings.HasPrefix(stem, "http") && strings.Contains(stem, "2F") { return "image" + ext } // UUID filename (e.g., "c02b97b3-813f-4c03-904a-0bd4240f2c10.jpg") if isUUID(stem) { return "image" + ext } // Truncate long filenames if len(name) > 40 { return name[:37] + "..." + ext } return name } func isUUID(s string) bool { if len(s) != 36 { return false } for i, c := range s { if i == 8 || i == 13 || i == 18 || i == 23 { if c != '-' { return false } } else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true } func MergeStyle(base, overlay tcell.Style) tcell.Style { fg := overlay.GetForeground() if fg == tcell.ColorDefault { fg = base.GetForeground() } bg := overlay.GetBackground() if bg == tcell.ColorDefault { bg = base.GetBackground() } style := base.Foreground(fg).Background(bg) style = style.Bold(base.HasBold() || overlay.HasBold()) style = style.Dim(base.HasDim() || overlay.HasDim()) style = style.Italic(base.HasItalic() || overlay.HasItalic()) style = style.Blink(base.HasBlink() || overlay.HasBlink()) style = style.Reverse(base.HasReverse() || overlay.HasReverse()) style = style.StrikeThrough(base.HasStrikeThrough() || overlay.HasStrikeThrough()) if base.HasUnderline() || overlay.HasUnderline() { style = style.Underline(true) } return style }