package messages
import (
"context"
"fmt"
"html"
"net/url"
"path"
"slices"
"strconv"
"strings"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/chatkit/components/embed"
"github.com/diamondburned/chatkit/md/mdrender"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"github.com/diamondburned/gotk4/pkg/pango"
"github.com/diamondburned/gotkit/app"
"github.com/diamondburned/gotkit/app/locale"
"github.com/diamondburned/gotkit/components/onlineimage"
"github.com/diamondburned/gotkit/gtkutil"
"github.com/diamondburned/gotkit/gtkutil/cssutil"
"github.com/diamondburned/gotkit/gtkutil/imgutil"
"github.com/diamondburned/ningen/v3/discordmd"
"github.com/dustin/go-humanize"
"libdb.so/dissent/internal/gtkcord"
)
var trustedCDNHosts = []string{
"cdn.discordapp.com",
}
var defaultEmbedOpts = embed.Opts{
Provider: imgutil.HTTPProvider,
IgnoreWidth: true,
}
func resizeURL(directURL, proxyURL string, w, h int) string {
if w == 0 || h == 0 {
return proxyURL
}
scale := gtkutil.ScaleFactor()
if scale == 1 {
scale = 2
}
u, err := url.Parse(proxyURL)
if err != nil {
return proxyURL
}
if direct, err := url.Parse(directURL); err == nil {
if slices.Contains(trustedCDNHosts, direct.Host) {
u = direct
}
}
q := u.Query()
if q.Has("size") {
if w > h {
q.Set("size", strconv.Itoa(w*scale))
} else {
q.Set("size", strconv.Itoa(h*scale))
}
} else {
q.Set("width", strconv.Itoa(w*scale))
q.Set("height", strconv.Itoa(h*scale))
}
u.RawQuery = q.Encode()
return u.String()
}
var stickerCSS = cssutil.Applier("message-sticker", `
.message-sticker {
border-radius: 0;
}
.message-sticker picture.thumbnail-embed-image {
background-color: transparent;
}
`)
func newSticker(ctx context.Context, sticker *discord.StickerItem) gtk.Widgetter {
switch sticker.FormatType {
case discord.StickerFormatAPNG, discord.StickerFormatPNG:
url := sticker.StickerURLWithType(discord.PNGImage)
image := embed.New(ctx, gtkcord.StickerSize, gtkcord.StickerSize, defaultEmbedOpts)
image.SetName(sticker.Name)
image.SetHAlign(gtk.AlignStart)
image.SetSizeRequest(gtkcord.StickerSize, gtkcord.StickerSize)
image.SetFromURL(url)
image.SetOpenURL(func() { app.OpenURI(ctx, url) })
stickerCSS(image)
return image
default:
msg := gtk.NewLabel(fmt.Sprintf("[Lottie sticker: %s]", sticker.Name))
msg.SetXAlign(0)
systemContentCSS(msg)
fixNatWrap(msg)
return msg
}
}
var _ = cssutil.WriteCSS(`
.message-richframe:not(:first-child) {
margin-top: 4px;
}
.message-embed-spoiler .onlineimage {
filter: blur(45px);
}
`)
var messageAttachmentCSS = cssutil.Applier("message-attachment", `
.message-attachment-filename {
padding-left: 0.35em;
padding-right: 0.35em;
}
.message-attachment-filesize {
color: alpha(@theme_fg_color, 0.75);
}
`)
func newAttachment(ctx context.Context, attachment *discord.Attachment) gtk.Widgetter {
var mimeType string
if attachment.ContentType != "" {
mimeType, _, _ = strings.Cut(attachment.ContentType, "/")
}
switch mimeType {
case "image", "video":
opts := defaultEmbedOpts
switch {
case attachment.ContentType == "image/gif":
opts.Type = embed.EmbedTypeGIF
case mimeType == "image":
opts.Type = embed.EmbedTypeImage
case mimeType == "video":
opts.Type = embed.EmbedTypeVideo
opts.Provider = imgutil.FFmpegProvider
}
name := fmt.Sprintf(
"%s (%s)",
attachment.Filename,
humanize.Bytes(attachment.Size),
)
image := embed.New(ctx, maxEmbedWidth.Value(), maxImageHeight.Value(), opts)
image.AddCSSClass("message-richframe")
image.SetHExpand(false)
image.SetVExpand(false)
image.SetHAlign(gtk.AlignStart)
image.SetName(name)
image.SetOpenURL(func() {
openViewer(ctx, attachment.URL, opts)
})
if strings.HasPrefix(attachment.Filename, "SPOILER_") {
image.AddCSSClass("message-embed-spoiler")
}
if attachment.Width > 0 && attachment.Height > 0 {
origW := int(attachment.Width)
origH := int(attachment.Height)
w, h := imgutil.MaxSize(
origW, origH,
maxEmbedWidth.Value(), maxImageHeight.Value(),
)
image.SetSizeRequest(w, h)
image.Thumbnail.Picture.SetSizeRequest(w, h)
if mimeType == "image" {
scale := gtkutil.ScaleFactor()
w *= scale
h *= scale
image.SetFromURL(resizeURL(
attachment.URL,
attachment.Proxy,
w, h,
))
} else {
image.SetFromURL(attachment.Proxy)
}
} else {
image.SetFromURL(attachment.Proxy)
}
return image
default:
icon := gtk.NewImageFromIconName(mimeIcon(mimeType))
icon.AddCSSClass("message-attachment-icon")
icon.SetIconSize(gtk.IconSizeNormal)
filename := gtk.NewLabel("")
filename.AddCSSClass("message-attachment-filename")
filename.SetMarkup(fmt.Sprintf(
`<a href="%s">%s</a>`,
html.EscapeString(attachment.URL),
html.EscapeString(attachment.Filename),
))
filename.SetEllipsize(pango.EllipsizeEnd)
filename.SetXAlign(0)
filesize := gtk.NewLabel(humanize.Bytes(attachment.Size))
filesize.AddCSSClass("message-attachment-filesize")
filesize.SetXAlign(0)
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
box.SetTooltipText(attachment.Filename)
box.Append(icon)
box.Append(filename)
box.Append(filesize)
messageAttachmentCSS(box)
return box
}
}
func mimeIcon(mimePrefix string) string {
switch mimePrefix {
case "audio":
return "audio-x-generic-symbolic"
case "image":
return "image-x-generic-symbolic"
case "video":
return "video-x-generic-symbolic"
case "application":
return "application-x-executable-symbolic"
default:
return "text-x-generic-symbolic"
}
}
var normalEmbedCSS = cssutil.Applier("message-normalembed", `
@define-color dissent_embed_background alpha(@theme_fg_color, 0.05);
.message-normalembed {
border: none;
border-radius: 8px;
padding: 10px;
background-color: @dissent_embed_background;
}
.message-normalembed-body > *:not(:last-child) {
margin-bottom: 0.5em;
}
.message-normalembed-body > .thumbnail-embed-bin {
margin-top: 0.5em;
}
.message-embed-author,
.message-embed-description {
font-size: 0.9em;
}
.message-embed-author-icon,
.message-embed-footer-icon {
margin-right: 0.5em;
}
.message-embed-footer {
opacity: 0.5;
font-size: 0.8em;
}
`)
const embedColorCSSf = `
.message-normalembed {
padding-left: 14px;
background: linear-gradient(to right,
%s 4px,
@dissent_embed_background 0px,
@dissent_embed_background 100%%
);
}
`
func newEmbed(ctx context.Context, msg *discord.Message, embed *discord.Embed) gtk.Widgetter {
return newNormalEmbed(ctx, msg, embed)
}
func newNormalEmbed(ctx context.Context, msg *discord.Message, msgEmbed *discord.Embed) gtk.Widgetter {
bodyBox := gtk.NewBox(gtk.OrientationVertical, 0)
bodyBox.SetHAlign(gtk.AlignFill)
bodyBox.SetHExpand(true)
bodyBox.AddCSSClass("message-normalembed-body")
hasBody := false
if msgEmbed.Author != nil {
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
box.AddCSSClass("message-embed-author")
if msgEmbed.Author.ProxyIcon != "" {
img := onlineimage.NewAvatar(ctx, imgutil.HTTPProvider, 18)
img.AddCSSClass("message-embed-author-icon")
img.SetFromURL(msgEmbed.Author.ProxyIcon)
box.Append(img)
}
if msgEmbed.Author.Name != "" {
author := gtk.NewLabel(msgEmbed.Author.Name)
author.SetUseMarkup(true)
author.SetSingleLineMode(true)
author.SetEllipsize(pango.EllipsizeEnd)
author.SetTooltipText(msgEmbed.Author.Name)
author.SetXAlign(0)
if msgEmbed.Author.URL != "" {
author.SetMarkup(fmt.Sprintf(
`<a href="%s">%s</a>`,
html.EscapeString(msgEmbed.Author.URL), html.EscapeString(msgEmbed.Author.Name),
))
}
box.Append(author)
}
bodyBox.Append(box)
hasBody = true
}
if msgEmbed.Title != "" {
title := `<span weight="heavy">` + html.EscapeString(msgEmbed.Title) + `</span>`
if msgEmbed.URL != "" {
title = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(msgEmbed.URL), title)
}
label := gtk.NewLabel("")
label.AddCSSClass("message-embed-title")
label.SetWrap(true)
label.SetWrapMode(pango.WrapWordChar)
label.SetXAlign(0)
label.SetMarkup(title)
fixNatWrap(label)
bodyBox.Append(label)
hasBody = true
}
if msgEmbed.Description != "" {
state := gtkcord.FromContext(ctx)
edesc := []byte(msgEmbed.Description)
mnode := discordmd.ParseWithMessage(edesc, *state.Cabinet, msg, false)
v := mdrender.NewMarkdownViewer(ctx, edesc, mnode)
v.AddCSSClass("message-embed-description")
v.SetHExpand(false)
bodyBox.Append(v)
hasBody = true
}
if len(msgEmbed.Fields) > 0 {
fields := gtk.NewGrid()
fields.AddCSSClass("message-embed-fields")
fields.SetRowSpacing(uint(7))
fields.SetColumnSpacing(uint(14))
bodyBox.Append(fields)
hasBody = true
col, row := 0, 0
for _, field := range msgEmbed.Fields {
text := gtk.NewLabel("")
text.SetEllipsize(pango.EllipsizeEnd)
text.SetXAlign(0.0)
text.SetMarkup(fmt.Sprintf(
`<span weight="heavy">%s</span>`+"\n"+`<span weight="light">%s</span>`,
html.EscapeString(field.Name),
html.EscapeString(field.Value),
))
text.SetTooltipText(field.Name + "\n" + field.Value)
if field.Inline && col < 3 {
fields.Attach(text, col, row, 1, 1)
col++
} else {
if col > 0 {
row++
}
col = 0
fields.Attach(text, col, row, 1, 1)
if !field.Inline {
row++
} else {
col++
}
}
}
}
if msgEmbed.Footer != nil || msgEmbed.Timestamp.IsValid() {
footer := gtk.NewBox(gtk.OrientationHorizontal, 0)
footer.AddCSSClass("message-embed-footer")
if msgEmbed.Footer != nil {
if msgEmbed.Footer.ProxyIcon != "" {
img := onlineimage.NewAvatar(ctx, imgutil.HTTPProvider, 18)
img.AddCSSClass("message-embed-footer-icon")
footer.Append(img)
}
if msgEmbed.Footer.Text != "" {
text := gtk.NewLabel(msgEmbed.Footer.Text)
text.SetVAlign(gtk.AlignStart)
text.SetSingleLineMode(true)
text.SetEllipsize(pango.EllipsizeEnd)
text.SetTooltipText(msgEmbed.Footer.Text)
text.SetXAlign(0)
footer.Append(text)
}
}
if msgEmbed.Timestamp.IsValid() {
time := locale.TimeAgo(msgEmbed.Timestamp.Time())
text := gtk.NewLabel(time)
text.AddCSSClass("message-embed-timestamp")
if msgEmbed.Footer != nil {
text.SetText(" - " + time)
}
footer.Append(text)
}
bodyBox.Append(footer)
hasBody = true
}
embedBox := bodyBox
if hasBody {
embedBox = gtk.NewBox(gtk.OrientationHorizontal, 0)
embedBox.SetHAlign(gtk.AlignStart)
embedBox.Append(bodyBox)
normalEmbedCSS(embedBox)
if msgEmbed.Color != discord.NullColor {
cssutil.Applyf(embedBox, embedColorCSSf, msgEmbed.Color.String())
}
}
if msgEmbed.Thumbnail != nil {
thumb := msgEmbed.Thumbnail
big := !hasBody ||
msgEmbed.Type == discord.GIFVEmbed ||
msgEmbed.Type == discord.ImageEmbed ||
msgEmbed.Type == discord.VideoEmbed ||
msgEmbed.Type == discord.ArticleEmbed
maxW := 80
maxH := 80
if big {
maxW = maxEmbedWidth.Value()
maxH = maxImageHeight.Value()
}
opts := defaultEmbedOpts
switch msgEmbed.Type {
case discord.NormalEmbed, discord.ImageEmbed:
opts.Type = embed.TypeFromURL(thumb.Proxy)
case discord.VideoEmbed:
opts.Type = embed.EmbedTypeVideo
case discord.GIFVEmbed:
opts.Type = embed.EmbedTypeGIFV
}
image := embed.New(ctx, maxW, maxH, opts)
image.SetVAlign(gtk.AlignStart)
if thumb.Width > 0 && thumb.Height > 0 {
image.ShrinkMaxSize(int(thumb.Width), int(thumb.Height))
image.SetSizeRequest(int(thumb.Width), int(thumb.Height))
}
image.SetFromURL(resizeURL(
thumb.URL,
thumb.Proxy,
int(thumb.Width),
int(thumb.Height),
))
switch {
case msgEmbed.Image != nil:
image.SetName(path.Base(msgEmbed.Image.URL))
case msgEmbed.Video != nil:
image.SetName(path.Base(msgEmbed.Video.URL))
default:
image.SetName(path.Base(thumb.URL))
}
switch {
case msgEmbed.Image != nil:
image.SetOpenURL(func() {
openViewer(ctx, msgEmbed.Image.Proxy, opts)
})
case msgEmbed.Video != nil:
image.SetOpenURL(func() {
if msgEmbed.Video.Proxy == "" {
app.OpenURI(ctx, msgEmbed.Video.URL)
return
}
switch opts.Type {
case embed.EmbedTypeGIFV:
image.SetFromURL(msgEmbed.Video.Proxy)
image.ActivateDefault()
image.SetOpenURL(func() {
openViewer(ctx, msgEmbed.Video.Proxy, opts)
})
case embed.EmbedTypeVideo:
openViewer(ctx, msgEmbed.Video.Proxy, opts)
}
})
default:
image.SetOpenURL(func() {
openViewer(ctx, msgEmbed.Thumbnail.Proxy, opts)
})
}
if big {
image.SetHAlign(gtk.AlignStart)
bodyBox.Append(image)
} else {
image.SetHAlign(gtk.AlignEnd)
embedBox.Append(image)
}
}
if msgEmbed.Thumbnail == nil && (msgEmbed.Image != nil || msgEmbed.Video != nil) {
opts := defaultEmbedOpts
var img discord.EmbedImage
switch {
case msgEmbed.Image != nil:
img = *msgEmbed.Image
opts.Type = embed.TypeFromURL(msgEmbed.Image.URL)
case msgEmbed.Video != nil:
img = (discord.EmbedImage)(*msgEmbed.Video)
opts.Type = embed.EmbedTypeVideo
opts.Provider = imgutil.FFmpegProvider
}
image := embed.New(ctx, maxEmbedWidth.Value(), maxImageHeight.Value(), opts)
image.SetSizeRequest(int(img.Width), int(img.Height))
image.SetOpenURL(func() {
openViewer(ctx, img.URL, opts)
})
if msgEmbed.Image != nil {
image.SetFromURL(resizeURL(
img.URL,
img.Proxy,
int(img.Width),
int(img.Height),
))
} else {
image.SetFromURL(img.Proxy)
}
bodyBox.Append(image)
}
embedBox.AddCSSClass("message-richframe")
return embedBox
}
func openViewer(ctx context.Context, uri string, opts embed.Opts) {
embedViewer, err := embed.NewViewer(ctx, uri, opts)
if err != nil {
app.Error(ctx, err)
return
}
embedViewer.Show()
}
func fixNatWrap(label *gtk.Label) {
if err := gtk.CheckVersion(4, 6, 0); err == "" {
label.SetObjectProperty("natural-wrap-mode", 1)
}
}