Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/messages/embed.go
366 views
1
package messages
2
3
import (
4
"context"
5
"fmt"
6
"html"
7
"net/url"
8
"path"
9
"slices"
10
"strconv"
11
"strings"
12
13
"github.com/diamondburned/arikawa/v3/discord"
14
"github.com/diamondburned/chatkit/components/embed"
15
"github.com/diamondburned/chatkit/md/mdrender"
16
"github.com/diamondburned/gotk4/pkg/gtk/v4"
17
"github.com/diamondburned/gotk4/pkg/pango"
18
"github.com/diamondburned/gotkit/app"
19
"github.com/diamondburned/gotkit/app/locale"
20
"github.com/diamondburned/gotkit/components/onlineimage"
21
"github.com/diamondburned/gotkit/gtkutil"
22
"github.com/diamondburned/gotkit/gtkutil/cssutil"
23
"github.com/diamondburned/gotkit/gtkutil/imgutil"
24
"github.com/diamondburned/ningen/v3/discordmd"
25
"github.com/dustin/go-humanize"
26
"libdb.so/dissent/internal/gtkcord"
27
)
28
29
// TODO: allow disable fetching videos.
30
31
var trustedCDNHosts = []string{
32
"cdn.discordapp.com",
33
}
34
35
var defaultEmbedOpts = embed.Opts{
36
Provider: imgutil.HTTPProvider,
37
IgnoreWidth: true,
38
}
39
40
func resizeURL(directURL, proxyURL string, w, h int) string {
41
if w == 0 || h == 0 {
42
return proxyURL
43
}
44
45
// Grab the maximum scale factor that we've ever seen. Plugging in another
46
// monitor while we've already rendered will not update this, but it's good
47
// enough. We just don't want to abuse bandwidth for 1x or 2x people.
48
scale := gtkutil.ScaleFactor()
49
if scale == 1 {
50
// Fetching 2x shouldn't be too bad, though.
51
scale = 2
52
}
53
54
u, err := url.Parse(proxyURL)
55
if err != nil {
56
return proxyURL
57
}
58
59
if direct, err := url.Parse(directURL); err == nil {
60
// Special-case: sometimes, the URL is already a Discord CDN URL. In
61
// that case, we'll just use it directly.
62
if slices.Contains(trustedCDNHosts, direct.Host) {
63
u = direct
64
}
65
}
66
67
q := u.Query()
68
// Do we have a size parameter already? We might if the URL is one crafted
69
// by us to fetch an emoji.
70
if q.Has("size") {
71
// If we even have a size, then we can just assume that the size is
72
// the larger dimension.
73
if w > h {
74
q.Set("size", strconv.Itoa(w*scale))
75
} else {
76
q.Set("size", strconv.Itoa(h*scale))
77
}
78
} else {
79
q.Set("width", strconv.Itoa(w*scale))
80
q.Set("height", strconv.Itoa(h*scale))
81
}
82
83
u.RawQuery = q.Encode()
84
85
return u.String()
86
}
87
88
var stickerCSS = cssutil.Applier("message-sticker", `
89
.message-sticker {
90
border-radius: 0;
91
}
92
.message-sticker picture.thumbnail-embed-image {
93
background-color: transparent;
94
}
95
`)
96
97
func newSticker(ctx context.Context, sticker *discord.StickerItem) gtk.Widgetter {
98
switch sticker.FormatType {
99
case discord.StickerFormatAPNG, discord.StickerFormatPNG:
100
url := sticker.StickerURLWithType(discord.PNGImage)
101
102
// TODO: this is always round because we're using a GtkFrame. What the
103
// heck? How does this shit even work?!
104
image := embed.New(ctx, gtkcord.StickerSize, gtkcord.StickerSize, defaultEmbedOpts)
105
image.SetName(sticker.Name)
106
image.SetHAlign(gtk.AlignStart)
107
image.SetSizeRequest(gtkcord.StickerSize, gtkcord.StickerSize)
108
image.SetFromURL(url)
109
image.SetOpenURL(func() { app.OpenURI(ctx, url) }) // TODO: Add sticker info popover
110
stickerCSS(image)
111
return image
112
default:
113
// Fuck Lottie, whatever the fuck that is.
114
msg := gtk.NewLabel(fmt.Sprintf("[Lottie sticker: %s]", sticker.Name))
115
msg.SetXAlign(0)
116
systemContentCSS(msg)
117
fixNatWrap(msg)
118
return msg
119
}
120
}
121
122
var _ = cssutil.WriteCSS(`
123
.message-richframe:not(:first-child) {
124
margin-top: 4px;
125
}
126
.message-embed-spoiler .onlineimage {
127
filter: blur(45px);
128
}
129
`)
130
131
var messageAttachmentCSS = cssutil.Applier("message-attachment", `
132
.message-attachment-filename {
133
padding-left: 0.35em;
134
padding-right: 0.35em;
135
}
136
.message-attachment-filesize {
137
color: alpha(@theme_fg_color, 0.75);
138
}
139
`)
140
141
func newAttachment(ctx context.Context, attachment *discord.Attachment) gtk.Widgetter {
142
var mimeType string
143
if attachment.ContentType != "" {
144
mimeType, _, _ = strings.Cut(attachment.ContentType, "/")
145
}
146
147
switch mimeType {
148
case "image", "video":
149
// Make this attachment like an image embed.
150
opts := defaultEmbedOpts
151
152
switch {
153
case attachment.ContentType == "image/gif":
154
opts.Type = embed.EmbedTypeGIF
155
case mimeType == "image":
156
opts.Type = embed.EmbedTypeImage
157
case mimeType == "video":
158
opts.Type = embed.EmbedTypeVideo
159
// Use FFmpeg for video so we can get the thumbnail.
160
opts.Provider = imgutil.FFmpegProvider
161
}
162
163
name := fmt.Sprintf(
164
"%s (%s)",
165
attachment.Filename,
166
humanize.Bytes(attachment.Size),
167
)
168
169
image := embed.New(ctx, maxEmbedWidth.Value(), maxImageHeight.Value(), opts)
170
image.AddCSSClass("message-richframe")
171
image.SetHExpand(false)
172
image.SetVExpand(false)
173
image.SetHAlign(gtk.AlignStart)
174
image.SetName(name)
175
176
image.SetOpenURL(func() {
177
openViewer(ctx, attachment.URL, opts)
178
})
179
180
if strings.HasPrefix(attachment.Filename, "SPOILER_") {
181
image.AddCSSClass("message-embed-spoiler")
182
}
183
184
if attachment.Width > 0 && attachment.Height > 0 {
185
origW := int(attachment.Width)
186
origH := int(attachment.Height)
187
188
// Work around to prevent GTK from rendering the image at its
189
// original size, which tanks performance on Cairo renderers.
190
w, h := imgutil.MaxSize(
191
origW, origH,
192
maxEmbedWidth.Value(), maxImageHeight.Value(),
193
)
194
195
image.SetSizeRequest(w, h)
196
image.Thumbnail.Picture.SetSizeRequest(w, h)
197
198
if mimeType == "image" {
199
scale := gtkutil.ScaleFactor()
200
w *= scale
201
h *= scale
202
203
image.SetFromURL(resizeURL(
204
attachment.URL,
205
attachment.Proxy,
206
w, h,
207
))
208
} else {
209
image.SetFromURL(attachment.Proxy)
210
}
211
} else {
212
image.SetFromURL(attachment.Proxy)
213
}
214
215
return image
216
default:
217
icon := gtk.NewImageFromIconName(mimeIcon(mimeType))
218
icon.AddCSSClass("message-attachment-icon")
219
icon.SetIconSize(gtk.IconSizeNormal)
220
221
filename := gtk.NewLabel("")
222
filename.AddCSSClass("message-attachment-filename")
223
filename.SetMarkup(fmt.Sprintf(
224
`<a href="%s">%s</a>`,
225
html.EscapeString(attachment.URL),
226
html.EscapeString(attachment.Filename),
227
))
228
filename.SetEllipsize(pango.EllipsizeEnd)
229
filename.SetXAlign(0)
230
231
filesize := gtk.NewLabel(humanize.Bytes(attachment.Size))
232
filesize.AddCSSClass("message-attachment-filesize")
233
filesize.SetXAlign(0)
234
235
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
236
box.SetTooltipText(attachment.Filename)
237
box.Append(icon)
238
box.Append(filename)
239
box.Append(filesize)
240
messageAttachmentCSS(box)
241
242
return box
243
}
244
}
245
246
func mimeIcon(mimePrefix string) string {
247
switch mimePrefix {
248
case "audio":
249
return "audio-x-generic-symbolic"
250
case "image":
251
return "image-x-generic-symbolic"
252
case "video":
253
return "video-x-generic-symbolic"
254
case "application":
255
return "application-x-executable-symbolic"
256
default:
257
return "text-x-generic-symbolic"
258
}
259
}
260
261
var normalEmbedCSS = cssutil.Applier("message-normalembed", `
262
@define-color dissent_embed_background alpha(@theme_fg_color, 0.05);
263
264
.message-normalembed {
265
border: none;
266
border-radius: 8px;
267
padding: 10px;
268
background-color: @dissent_embed_background;
269
}
270
.message-normalembed-body > *:not(:last-child) {
271
margin-bottom: 0.5em;
272
}
273
.message-normalembed-body > .thumbnail-embed-bin {
274
margin-top: 0.5em;
275
}
276
.message-embed-author,
277
.message-embed-description {
278
font-size: 0.9em;
279
}
280
.message-embed-author-icon,
281
.message-embed-footer-icon {
282
margin-right: 0.5em;
283
}
284
.message-embed-footer {
285
opacity: 0.5;
286
font-size: 0.8em;
287
}
288
`)
289
290
const embedColorCSSf = `
291
.message-normalembed {
292
padding-left: 14px;
293
background: linear-gradient(to right,
294
%s 4px,
295
@dissent_embed_background 0px,
296
@dissent_embed_background 100%%
297
);
298
}
299
`
300
301
func newEmbed(ctx context.Context, msg *discord.Message, embed *discord.Embed) gtk.Widgetter {
302
return newNormalEmbed(ctx, msg, embed)
303
}
304
305
func newNormalEmbed(ctx context.Context, msg *discord.Message, msgEmbed *discord.Embed) gtk.Widgetter {
306
bodyBox := gtk.NewBox(gtk.OrientationVertical, 0)
307
bodyBox.SetHAlign(gtk.AlignFill)
308
bodyBox.SetHExpand(true)
309
bodyBox.AddCSSClass("message-normalembed-body")
310
311
// Track whether or not we have an embed body. An embed body should have any
312
// kind of text in it. If we don't have a body but do have a thumbnail, then
313
// the thumbnail should be big and on its own.
314
hasBody := false
315
316
if msgEmbed.Author != nil {
317
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
318
box.AddCSSClass("message-embed-author")
319
320
if msgEmbed.Author.ProxyIcon != "" {
321
img := onlineimage.NewAvatar(ctx, imgutil.HTTPProvider, 18)
322
img.AddCSSClass("message-embed-author-icon")
323
img.SetFromURL(msgEmbed.Author.ProxyIcon)
324
325
box.Append(img)
326
}
327
328
if msgEmbed.Author.Name != "" {
329
author := gtk.NewLabel(msgEmbed.Author.Name)
330
author.SetUseMarkup(true)
331
author.SetSingleLineMode(true)
332
author.SetEllipsize(pango.EllipsizeEnd)
333
author.SetTooltipText(msgEmbed.Author.Name)
334
author.SetXAlign(0)
335
336
if msgEmbed.Author.URL != "" {
337
author.SetMarkup(fmt.Sprintf(
338
`<a href="%s">%s</a>`,
339
html.EscapeString(msgEmbed.Author.URL), html.EscapeString(msgEmbed.Author.Name),
340
))
341
}
342
343
box.Append(author)
344
}
345
346
bodyBox.Append(box)
347
hasBody = true
348
}
349
350
if msgEmbed.Title != "" {
351
title := `<span weight="heavy">` + html.EscapeString(msgEmbed.Title) + `</span>`
352
if msgEmbed.URL != "" {
353
title = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(msgEmbed.URL), title)
354
}
355
356
label := gtk.NewLabel("")
357
label.AddCSSClass("message-embed-title")
358
label.SetWrap(true)
359
label.SetWrapMode(pango.WrapWordChar)
360
label.SetXAlign(0)
361
label.SetMarkup(title)
362
fixNatWrap(label)
363
364
bodyBox.Append(label)
365
hasBody = true
366
}
367
368
if msgEmbed.Description != "" {
369
state := gtkcord.FromContext(ctx)
370
edesc := []byte(msgEmbed.Description)
371
mnode := discordmd.ParseWithMessage(edesc, *state.Cabinet, msg, false)
372
373
v := mdrender.NewMarkdownViewer(ctx, edesc, mnode)
374
v.AddCSSClass("message-embed-description")
375
v.SetHExpand(false)
376
377
bodyBox.Append(v)
378
hasBody = true
379
}
380
381
if len(msgEmbed.Fields) > 0 {
382
fields := gtk.NewGrid()
383
fields.AddCSSClass("message-embed-fields")
384
fields.SetRowSpacing(uint(7))
385
fields.SetColumnSpacing(uint(14))
386
387
bodyBox.Append(fields)
388
hasBody = true
389
390
col, row := 0, 0
391
392
for _, field := range msgEmbed.Fields {
393
text := gtk.NewLabel("")
394
text.SetEllipsize(pango.EllipsizeEnd)
395
text.SetXAlign(0.0)
396
text.SetMarkup(fmt.Sprintf(
397
`<span weight="heavy">%s</span>`+"\n"+`<span weight="light">%s</span>`,
398
html.EscapeString(field.Name),
399
html.EscapeString(field.Value),
400
))
401
text.SetTooltipText(field.Name + "\n" + field.Value)
402
403
// I have no idea what this does. It's just improvised.
404
if field.Inline && col < 3 {
405
fields.Attach(text, col, row, 1, 1)
406
col++
407
} else {
408
if col > 0 {
409
row++
410
}
411
412
col = 0
413
fields.Attach(text, col, row, 1, 1)
414
415
if !field.Inline {
416
row++
417
} else {
418
col++
419
}
420
}
421
}
422
}
423
424
if msgEmbed.Footer != nil || msgEmbed.Timestamp.IsValid() {
425
footer := gtk.NewBox(gtk.OrientationHorizontal, 0)
426
footer.AddCSSClass("message-embed-footer")
427
428
if msgEmbed.Footer != nil {
429
if msgEmbed.Footer.ProxyIcon != "" {
430
img := onlineimage.NewAvatar(ctx, imgutil.HTTPProvider, 18)
431
img.AddCSSClass("message-embed-footer-icon")
432
433
footer.Append(img)
434
}
435
436
if msgEmbed.Footer.Text != "" {
437
text := gtk.NewLabel(msgEmbed.Footer.Text)
438
text.SetVAlign(gtk.AlignStart)
439
text.SetSingleLineMode(true)
440
text.SetEllipsize(pango.EllipsizeEnd)
441
text.SetTooltipText(msgEmbed.Footer.Text)
442
text.SetXAlign(0)
443
444
footer.Append(text)
445
}
446
}
447
448
if msgEmbed.Timestamp.IsValid() {
449
time := locale.TimeAgo(msgEmbed.Timestamp.Time())
450
451
text := gtk.NewLabel(time)
452
text.AddCSSClass("message-embed-timestamp")
453
if msgEmbed.Footer != nil {
454
text.SetText(" - " + time)
455
}
456
457
footer.Append(text)
458
}
459
460
bodyBox.Append(footer)
461
hasBody = true
462
}
463
464
embedBox := bodyBox
465
if hasBody {
466
// bodyBox.SetHAlign(gtk.AlignFill)
467
// bodyBox.SetHExpand(false)
468
469
embedBox = gtk.NewBox(gtk.OrientationHorizontal, 0)
470
embedBox.SetHAlign(gtk.AlignStart)
471
embedBox.Append(bodyBox)
472
normalEmbedCSS(embedBox)
473
474
if msgEmbed.Color != discord.NullColor {
475
cssutil.Applyf(embedBox, embedColorCSSf, msgEmbed.Color.String())
476
}
477
}
478
479
if msgEmbed.Thumbnail != nil {
480
thumb := msgEmbed.Thumbnail
481
big := !hasBody ||
482
msgEmbed.Type == discord.GIFVEmbed ||
483
msgEmbed.Type == discord.ImageEmbed ||
484
msgEmbed.Type == discord.VideoEmbed ||
485
msgEmbed.Type == discord.ArticleEmbed
486
487
maxW := 80
488
maxH := 80
489
if big {
490
maxW = maxEmbedWidth.Value()
491
maxH = maxImageHeight.Value()
492
}
493
494
opts := defaultEmbedOpts
495
switch msgEmbed.Type {
496
case discord.NormalEmbed, discord.ImageEmbed:
497
opts.Type = embed.TypeFromURL(thumb.Proxy)
498
case discord.VideoEmbed:
499
opts.Type = embed.EmbedTypeVideo
500
case discord.GIFVEmbed:
501
opts.Type = embed.EmbedTypeGIFV
502
}
503
504
image := embed.New(ctx, maxW, maxH, opts)
505
image.SetVAlign(gtk.AlignStart)
506
if thumb.Width > 0 && thumb.Height > 0 {
507
// Enforce this image's own dimensions if possible.
508
image.ShrinkMaxSize(int(thumb.Width), int(thumb.Height))
509
image.SetSizeRequest(int(thumb.Width), int(thumb.Height))
510
}
511
512
image.SetFromURL(resizeURL(
513
thumb.URL,
514
thumb.Proxy,
515
int(thumb.Width),
516
int(thumb.Height),
517
))
518
519
switch {
520
case msgEmbed.Image != nil:
521
image.SetName(path.Base(msgEmbed.Image.URL))
522
case msgEmbed.Video != nil:
523
image.SetName(path.Base(msgEmbed.Video.URL))
524
default:
525
image.SetName(path.Base(thumb.URL))
526
}
527
528
switch {
529
case msgEmbed.Image != nil:
530
// Open the Image proxy instead of the Thumbnail proxy. Honestly
531
// have no idea what the difference is.
532
image.SetOpenURL(func() {
533
openViewer(ctx, msgEmbed.Image.Proxy, opts)
534
})
535
case msgEmbed.Video != nil:
536
image.SetOpenURL(func() {
537
// Some video URLs don't have direct links, like YouTube.
538
if msgEmbed.Video.Proxy == "" {
539
app.OpenURI(ctx, msgEmbed.Video.URL)
540
return
541
}
542
543
switch opts.Type {
544
case embed.EmbedTypeGIFV:
545
// Play GIFVs in the embed. The image library will handle
546
// rendering the GIFV like a GIF.
547
image.SetFromURL(msgEmbed.Video.Proxy)
548
image.ActivateDefault()
549
// Override the next click to open the video in the viewer.
550
image.SetOpenURL(func() {
551
openViewer(ctx, msgEmbed.Video.Proxy, opts)
552
})
553
case embed.EmbedTypeVideo:
554
// Play videos in the viewer.
555
openViewer(ctx, msgEmbed.Video.Proxy, opts)
556
}
557
})
558
default:
559
image.SetOpenURL(func() {
560
openViewer(ctx, msgEmbed.Thumbnail.Proxy, opts)
561
})
562
}
563
564
if big {
565
image.SetHAlign(gtk.AlignStart)
566
bodyBox.Append(image)
567
} else {
568
image.SetHAlign(gtk.AlignEnd)
569
embedBox.Append(image)
570
}
571
}
572
573
if msgEmbed.Thumbnail == nil && (msgEmbed.Image != nil || msgEmbed.Video != nil) {
574
opts := defaultEmbedOpts
575
var img discord.EmbedImage
576
577
switch {
578
case msgEmbed.Image != nil:
579
img = *msgEmbed.Image
580
opts.Type = embed.TypeFromURL(msgEmbed.Image.URL)
581
582
case msgEmbed.Video != nil:
583
img = (discord.EmbedImage)(*msgEmbed.Video)
584
opts.Type = embed.EmbedTypeVideo
585
opts.Provider = imgutil.FFmpegProvider
586
}
587
588
image := embed.New(ctx, maxEmbedWidth.Value(), maxImageHeight.Value(), opts)
589
image.SetSizeRequest(int(img.Width), int(img.Height))
590
591
image.SetOpenURL(func() {
592
openViewer(ctx, img.URL, opts)
593
})
594
595
if msgEmbed.Image != nil {
596
// The server can only resize images.
597
image.SetFromURL(resizeURL(
598
img.URL,
599
img.Proxy,
600
int(img.Width),
601
int(img.Height),
602
))
603
} else {
604
image.SetFromURL(img.Proxy)
605
}
606
607
bodyBox.Append(image)
608
}
609
610
embedBox.AddCSSClass("message-richframe")
611
return embedBox
612
}
613
614
func openViewer(ctx context.Context, uri string, opts embed.Opts) {
615
embedViewer, err := embed.NewViewer(ctx, uri, opts)
616
if err != nil {
617
app.Error(ctx, err)
618
return
619
}
620
621
embedViewer.Show()
622
}
623
624
func fixNatWrap(label *gtk.Label) {
625
if err := gtk.CheckVersion(4, 6, 0); err == "" {
626
label.SetObjectProperty("natural-wrap-mode", 1) // NaturalWrapNone
627
}
628
}
629
630