Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/messages/content.go
366 views
1
package messages
2
3
import (
4
"context"
5
"errors"
6
"fmt"
7
"html"
8
"log/slog"
9
"strings"
10
11
"github.com/diamondburned/arikawa/v3/discord"
12
"github.com/diamondburned/arikawa/v3/state"
13
"github.com/diamondburned/chatkit/components/author"
14
"github.com/diamondburned/chatkit/md"
15
"github.com/diamondburned/chatkit/md/mdrender"
16
"github.com/diamondburned/gotk4/pkg/gio/v2"
17
"github.com/diamondburned/gotk4/pkg/gtk/v4"
18
"github.com/diamondburned/gotk4/pkg/pango"
19
"github.com/diamondburned/gotkit/app"
20
"github.com/diamondburned/gotkit/app/locale"
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
"libdb.so/ctxt"
26
"libdb.so/dissent/internal/gtkcord"
27
)
28
29
// Content is the message content widget.
30
type Content struct {
31
*gtk.Box
32
ctx context.Context
33
view *View
34
menu *gio.Menu
35
mdview *mdrender.MarkdownViewer
36
react *contentReactions
37
child []gtk.Widgetter
38
39
chID discord.ChannelID
40
msgID discord.MessageID
41
}
42
43
var contentCSS = cssutil.Applier("message-content-box", `
44
.message-content-box {
45
margin-right: 4px;
46
}
47
.message-content-box > *:not(:first-child) {
48
margin-top: 0.15em;
49
}
50
.message-content-box .thumbnail-embed {
51
border-width: 0;
52
border-radius: 8px; /* stolen from Discord mobile */
53
}
54
.message-header-blockquote {
55
margin-bottom: 0;
56
}
57
.message-header-blockquote > *,
58
.message-header-blockquote .mauthor-chip,
59
.message-reply-content link {
60
color: mix(@theme_bg_color, @theme_fg_color, 0.85);
61
}
62
.message-header-blockquote > * {
63
font-size: 0.9em;
64
}
65
.message-interaction-name {
66
margin-left: 0.25em;
67
font-family: monospace;
68
}
69
`)
70
71
// NewContent creates a new Content widget.
72
func NewContent(ctx context.Context, v *View) *Content {
73
c := Content{
74
ctx: ctx,
75
view: v,
76
child: make([]gtk.Widgetter, 0, 2),
77
chID: v.ChannelID(),
78
}
79
c.Box = gtk.NewBox(gtk.OrientationVertical, 0)
80
contentCSS(c.Box)
81
82
return &c
83
}
84
85
// MessageID returns the message ID.
86
func (c *Content) MessageID() discord.MessageID {
87
return c.msgID
88
}
89
90
// ChannelID returns the channel ID.
91
func (c *Content) ChannelID() discord.ChannelID {
92
return c.chID
93
}
94
95
// SetExtraMenu implements ExtraMenuSetter.
96
func (c *Content) SetExtraMenu(menu gio.MenuModeller) {
97
c.menu = gio.NewMenu()
98
c.menu.InsertSection(0, locale.Get("Message"), menu)
99
100
if c.mdview != nil {
101
c.setMenu()
102
}
103
}
104
105
type extraMenuSetter interface{ SetExtraMenu(gio.MenuModeller) }
106
107
var (
108
_ extraMenuSetter = (*gtk.TextView)(nil)
109
_ extraMenuSetter = (*gtk.Label)(nil)
110
)
111
112
func (c *Content) setMenu() {
113
var menu gio.MenuModeller
114
if c.menu != nil {
115
menu = c.menu // because a nil interface{} != nil *T
116
}
117
118
for _, child := range c.child {
119
// Manually check on child to allow certain widgets to override the
120
// method.
121
s, ok := child.(extraMenuSetter)
122
if ok {
123
s.SetExtraMenu(menu)
124
}
125
126
gtkutil.WalkWidget(c.Box, func(w gtk.Widgetter) bool {
127
s, ok := w.(extraMenuSetter)
128
if ok {
129
s.SetExtraMenu(menu)
130
}
131
return false
132
})
133
}
134
}
135
136
var systemContentCSS = cssutil.Applier("message-system-content", `
137
.message-system-content {
138
font-style: italic;
139
color: alpha(@theme_fg_color, 0.9);
140
}
141
`)
142
143
// Update replaces Content with the message.
144
func (c *Content) Update(m *discord.Message, customs ...gtk.Widgetter) {
145
c.msgID = m.ID
146
c.clear()
147
148
// Prevent the Markdown parser from crashing the client.
149
// See https://github.com/diamondburned/dissent/issues/275.
150
defer func() {
151
if r := recover(); r != nil {
152
slog.Error(
153
"recovered from a panic while parsing markdown",
154
"message_id", m.ID,
155
"content", m.Content,
156
"panic", r)
157
158
app.Error(c.ctx, errors.New(
159
locale.Get("Panic caught while parsing markdown, please check logs.")))
160
c.Redact()
161
}
162
}()
163
164
state := gtkcord.FromContext(c.ctx)
165
166
if m.Reference != nil {
167
w := c.newReplyBox(m)
168
c.append(w)
169
}
170
171
if m.Interaction != nil {
172
w := c.newInteractionBox(m)
173
c.append(w)
174
}
175
176
var messageMarkup string
177
switch m.Type {
178
case discord.GuildMemberJoinMessage:
179
messageMarkup = locale.Get("Joined the server.")
180
case discord.CallMessage:
181
messageMarkup = locale.Get("Calling you.")
182
case discord.ChannelIconChangeMessage:
183
messageMarkup = locale.Get("Changed the channel icon.")
184
case discord.ChannelNameChangeMessage:
185
messageMarkup = locale.Get("Changed the channel name to #%s.", html.EscapeString(m.Content))
186
case discord.ChannelPinnedMessage:
187
messageMarkup = locale.Get(`Pinned <a href="#message/%d">a message</a>.`, m.ID)
188
case discord.RecipientAddMessage, discord.RecipientRemoveMessage:
189
mentioned := state.MemberMarkup(m.GuildID, &m.Mentions[0], author.WithMinimal())
190
switch m.Type {
191
case discord.RecipientAddMessage:
192
messageMarkup = locale.Get("Added %s to the group.", mentioned)
193
case discord.RecipientRemoveMessage:
194
messageMarkup = locale.Get("Removed %s from the group.", mentioned)
195
}
196
case discord.NitroBoostMessage:
197
messageMarkup = locale.Get("Boosted the server!")
198
case discord.NitroTier1Message:
199
messageMarkup = locale.Get("The server is now Nitro Boosted to Tier 1.")
200
case discord.NitroTier2Message:
201
messageMarkup = locale.Get("The server is now Nitro Boosted to Tier 2.")
202
case discord.NitroTier3Message:
203
messageMarkup = locale.Get("The server is now Nitro Boosted to Tier 3.")
204
}
205
206
c.mdview = nil
207
208
switch {
209
case messageMarkup != "":
210
msg := gtk.NewLabel("")
211
msg.SetMarkup(messageMarkup)
212
msg.SetHExpand(true)
213
msg.SetXAlign(0)
214
msg.SetWrap(true)
215
msg.SetWrapMode(pango.WrapWordChar)
216
msg.ConnectActivateLink(func(uri string) bool {
217
if !strings.HasPrefix(uri, "#") {
218
return false // not our link
219
}
220
221
parts := strings.SplitN(uri, "/", 2)
222
if len(parts) != 2 {
223
return true // pretend we've handled this because of #
224
}
225
226
switch strings.TrimPrefix(parts[0], "#") {
227
case "message":
228
if id, _ := discord.ParseSnowflake(parts[1]); id.IsValid() {
229
c.view.ScrollToMessage(discord.MessageID(id))
230
}
231
}
232
233
return true
234
})
235
systemContentCSS(msg)
236
fixNatWrap(msg)
237
c.append(msg)
238
239
// We render a big content if the content itself is literally a Unicode
240
// emoji.
241
case m.Content != "" && md.IsUnicodeEmoji(m.Content):
242
l := gtk.NewLabel(m.Content)
243
l.SetAttributes(gtkcord.EmojiAttrs)
244
l.SetHExpand(true)
245
l.SetXAlign(0)
246
l.SetSelectable(true)
247
l.SetWrap(true)
248
l.SetWrapMode(pango.WrapWordChar)
249
c.append(l)
250
251
// We don't render the message content if all it is is the URL to the
252
// embedded image, because that's what the official client does.
253
case m.Content != "" &&
254
!(len(m.Embeds) == 1 && m.Embeds[0].Type == discord.ImageEmbed && m.Embeds[0].URL == m.Content):
255
256
src := []byte(m.Content)
257
node := discordmd.ParseWithMessage(src, *state.Cabinet, m, true)
258
259
c.mdview = mdrender.NewMarkdownViewer(
260
ctxt.With(c.ctx, newMarkdownState()),
261
src, node, renderers...)
262
c.append(c.mdview)
263
}
264
265
for i := range m.Stickers {
266
v := newSticker(c.ctx, &m.Stickers[i])
267
c.append(v)
268
}
269
270
for i := range m.Attachments {
271
v := newAttachment(c.ctx, &m.Attachments[i])
272
c.append(v)
273
}
274
275
for i := range m.Embeds {
276
v := newEmbed(c.ctx, m, &m.Embeds[i])
277
c.append(v)
278
}
279
280
for _, custom := range customs {
281
c.append(custom)
282
}
283
284
c.SetReactions(m.Reactions)
285
c.setMenu()
286
}
287
288
func (c *Content) newReplyBox(m *discord.Message) gtk.Widgetter {
289
box := gtk.NewBox(gtk.OrientationVertical, 0)
290
box.AddCSSClass("md-blockquote")
291
box.AddCSSClass("message-header-blockquote")
292
box.AddCSSClass("message-reply-box")
293
294
state := gtkcord.FromContext(c.ctx)
295
296
referencedMsg := m.ReferencedMessage
297
if referencedMsg == nil {
298
referencedMsg, _ = state.Cabinet.Message(m.Reference.ChannelID, m.Reference.MessageID)
299
}
300
301
if referencedMsg == nil {
302
slog.Warn(
303
"Cannot display message reference because the message is not found",
304
"channel_id", m.ChannelID,
305
"guild_id", m.GuildID,
306
"id", m.ID,
307
"id_reference", m.Reference.MessageID)
308
309
header := gtk.NewLabel("Unknown message.")
310
header.AddCSSClass("message-reply-header")
311
box.Append(header)
312
313
return box
314
}
315
316
if !showBlockedMessages.Value() && state.UserIsBlocked(referencedMsg.Author.ID) {
317
header := gtk.NewLabel("Blocked user.")
318
header.AddCSSClass("message-reply-header")
319
box.Append(header)
320
321
blockedCSS(box)
322
return box
323
}
324
325
member, _ := state.Cabinet.Member(m.Reference.GuildID, referencedMsg.Author.ID)
326
chip := newAuthorChip(c.ctx, m.GuildID, &discord.GuildUser{
327
User: referencedMsg.Author,
328
Member: member,
329
})
330
chip.SetHAlign(gtk.AlignStart)
331
chip.Unpad()
332
box.Append(chip)
333
334
if preview := state.MessagePreview(referencedMsg); preview != "" {
335
// Force single line.
336
preview = strings.ReplaceAll(preview, "\n", " ")
337
markup := fmt.Sprintf(
338
`<a href="dissent://reply">%s</a>`,
339
html.EscapeString(preview),
340
)
341
342
reply := gtk.NewLabel(markup)
343
reply.AddCSSClass("message-reply-content")
344
reply.SetUseMarkup(true)
345
reply.SetTooltipText(preview)
346
reply.SetEllipsize(pango.EllipsizeEnd)
347
reply.SetLines(1)
348
reply.SetXAlign(0)
349
reply.ConnectActivateLink(func(link string) bool {
350
slog.Debug(
351
"Activated message reference link",
352
"link", link,
353
"message_id", m.ID,
354
"reference_id", referencedMsg.ID)
355
356
if link != "dissent://reply" {
357
return false
358
}
359
360
if !c.ActivateAction("messages.scroll-to", gtkcord.NewMessageIDVariant(m.ID)) {
361
slog.Error(
362
"Failed to activate messages.scroll-to",
363
"id", m.ID)
364
}
365
366
return true
367
})
368
369
box.Append(reply)
370
}
371
372
if state.UserIsBlocked(referencedMsg.Author.ID) {
373
blockedCSS(box)
374
}
375
376
return box
377
}
378
379
func (c *Content) newInteractionBox(m *discord.Message) gtk.Widgetter {
380
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
381
box.AddCSSClass("md-blockquote")
382
box.AddCSSClass("message-header-blockquote")
383
box.AddCSSClass("message-interaction-box")
384
385
state := gtkcord.FromContext(c.ctx)
386
387
if !showBlockedMessages.Value() && state.UserIsBlocked(m.Interaction.User.ID) {
388
header := gtk.NewLabel("Blocked user.")
389
header.AddCSSClass("message-reply-header")
390
box.Append(header)
391
392
blockedCSS(box)
393
return box
394
}
395
396
chip := newAuthorChip(c.ctx, m.GuildID, &discord.GuildUser{
397
User: m.Interaction.User,
398
Member: m.Interaction.Member,
399
})
400
chip.SetHAlign(gtk.AlignStart)
401
chip.Unpad()
402
box.Append(chip)
403
404
nameLabel := gtk.NewLabel(m.Interaction.Name)
405
nameLabel.AddCSSClass("message-interaction-name")
406
if m.Interaction.Type == discord.CommandInteractionType {
407
nameLabel.SetText("/" + m.Interaction.Name)
408
nameLabel.AddCSSClass("message-interaction-command")
409
}
410
nameLabel.SetTooltipText(m.Interaction.Name)
411
nameLabel.SetEllipsize(pango.EllipsizeEnd)
412
nameLabel.SetXAlign(0)
413
box.Append(nameLabel)
414
415
if state.UserIsBlocked(m.Interaction.User.ID) {
416
blockedCSS(box)
417
}
418
419
return box
420
}
421
422
func (c *Content) append(w gtk.Widgetter) {
423
c.Box.Append(w)
424
c.child = append(c.child, w)
425
}
426
427
func (c *Content) SetCustomChild(child ...gtk.Widgetter) {
428
c.clear()
429
for _, w := range child {
430
c.append(w)
431
}
432
}
433
434
func (c *Content) clear() {
435
for i, child := range c.child {
436
c.Box.Remove(child)
437
c.child[i] = nil
438
}
439
c.child = c.child[:0]
440
}
441
442
var redactedContentCSS = cssutil.Applier("message-redacted-content", `
443
.message-redacted-content {
444
font-style: italic;
445
color: alpha(@theme_fg_color, 0.75);
446
}
447
`)
448
449
// Redact clears the content widget.
450
func (c *Content) Redact() {
451
c.clear()
452
453
red := gtk.NewLabel(locale.Get("Redacted."))
454
red.SetXAlign(0)
455
redactedContentCSS(red)
456
c.append(red)
457
}
458
459
// SetReactions sets the reactions inside the message.
460
func (c *Content) SetReactions(reactions []discord.Reaction) {
461
if c.react == nil {
462
if len(reactions) == 0 {
463
return
464
}
465
c.react = newContentReactions(c.ctx, c)
466
c.append(c.react)
467
}
468
c.react.SetReactions(reactions)
469
}
470
471
func newAuthorChip(ctx context.Context, guildID discord.GuildID, user *discord.GuildUser) *author.Chip {
472
name := user.DisplayOrUsername()
473
color := defaultMentionColor
474
475
if user.Member != nil {
476
if user.Member.Nick != "" {
477
name = user.Member.Nick
478
}
479
480
s := gtkcord.FromContext(ctx)
481
c, ok := state.MemberColor(user.Member, func(id discord.RoleID) *discord.Role {
482
r, _ := s.Cabinet.Role(guildID, id)
483
return r
484
})
485
if ok {
486
color = c.String()
487
}
488
}
489
490
chip := author.NewChip(ctx, imgutil.HTTPProvider)
491
chip.SetName(name)
492
chip.SetColor(color)
493
chip.SetAvatar(gtkcord.InjectAvatarSize(user.AvatarURL()))
494
495
return chip
496
}
497
498