Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/messages/message.go
366 views
1
package messages
2
3
import (
4
"context"
5
"encoding/json"
6
"fmt"
7
"html"
8
9
"github.com/diamondburned/arikawa/v3/discord"
10
"github.com/diamondburned/arikawa/v3/gateway"
11
"github.com/diamondburned/chatkit/md/hl"
12
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
13
"github.com/diamondburned/gotk4/pkg/core/glib"
14
"github.com/diamondburned/gotk4/pkg/gio/v2"
15
"github.com/diamondburned/gotk4/pkg/gtk/v4"
16
"github.com/diamondburned/gotk4/pkg/pango"
17
"github.com/diamondburned/gotkit/app"
18
"github.com/diamondburned/gotkit/app/locale"
19
"github.com/diamondburned/gotkit/components/onlineimage"
20
"github.com/diamondburned/gotkit/gtkutil"
21
"github.com/diamondburned/gotkit/gtkutil/cssutil"
22
"github.com/diamondburned/gotkit/gtkutil/imgutil"
23
"github.com/diamondburned/gotkit/gtkutil/textutil"
24
"github.com/diamondburned/ningen/v3"
25
"libdb.so/dissent/internal/gtkcord"
26
)
27
28
var _ = cssutil.WriteCSS(`
29
.message-box {
30
border: 2px solid transparent;
31
transition: linear 150ms background-color;
32
}
33
row:focus .message-box,
34
row:hover .message-box {
35
transition: none;
36
}
37
row:focus .message-box {
38
background-color: alpha(@theme_fg_color, 0.125);
39
}
40
row:hover .message-box {
41
background-color: alpha(@theme_fg_color, 0.075);
42
}
43
.message-box.message-editing,
44
.message-box.message-replying {
45
background-color: alpha(@theme_selected_bg_color, 0.15);
46
border-color: alpha(@theme_selected_bg_color, 0.55);
47
}
48
.message-box.message-sending {
49
opacity: 0.65;
50
}
51
.message-box.message-first-prepended {
52
border-bottom: 1.5px dashed alpha(@theme_fg_color, 0.25);
53
padding-bottom: 2.5px;
54
}
55
.message-mentioned {
56
border-left: 2px solid @mentioned;
57
border-top: 0;
58
border-bottom: 0;
59
background: alpha(@mentioned, 0.075);
60
}
61
row:hover .message-mentioned {
62
background: alpha(@mentioned, 0.125);
63
}
64
`)
65
66
// ExtraMenuSetter is an interface for types that implement SetExtraMenu.
67
type ExtraMenuSetter interface {
68
SetExtraMenu(gio.MenuModeller)
69
}
70
71
// TODO: Implement BlockedMessage widget
72
// Message describes a Message widget.
73
type Message interface {
74
gtk.Widgetter
75
Update(*gateway.MessageCreateEvent)
76
Redact()
77
Content() *Content
78
Message() *discord.Message
79
AddCSSClass(string)
80
RemoveCSSClass(string)
81
}
82
83
var (
84
_ Message = (*cozyMessage)(nil)
85
_ Message = (*collapsedMessage)(nil)
86
)
87
88
// MessageWithUser extends Message for types that also show user information.
89
type MessageWithUser interface {
90
Message
91
UpdateMember(*discord.Member)
92
}
93
94
var (
95
_ MessageWithUser = (*cozyMessage)(nil)
96
)
97
98
var blockedCSS = cssutil.Applier("message-blocked", `
99
.message-blocked {
100
transition-property: all;
101
transition-duration: 100ms;
102
}
103
.message-blocked:not(:hover) {
104
opacity: 0.35;
105
}
106
`)
107
108
// message is a base that implements Message.
109
type message struct {
110
content *Content
111
message *discord.Message
112
menu *gio.Menu
113
}
114
115
func newMessage(ctx context.Context, v *View) message {
116
return message{
117
content: NewContent(ctx, v),
118
}
119
}
120
121
func (m *message) ctx() context.Context {
122
return m.content.ctx
123
}
124
125
// Message implements Message.
126
func (m *message) Message() *discord.Message {
127
return m.message
128
}
129
130
// Content implements Message.
131
func (m *message) Content() *Content {
132
return m.content
133
}
134
135
func (m *message) update(parent gtk.Widgetter, message *discord.Message) {
136
parentWidget := gtk.BaseWidget(parent)
137
parentWidget.AddCSSClass("message-box")
138
139
m.message = message
140
m.bind(parent)
141
m.content.Update(message)
142
143
state := gtkcord.FromContext(m.ctx())
144
145
if state.RelationshipState.IsBlocked(message.Author.ID) {
146
blockedCSS(parent)
147
148
parentRef := glib.NewWeakRef(parentWidget)
149
update := func() {
150
parentWidget := parentRef.Get()
151
parentWidget.SetVisible(showBlockedMessages.Value())
152
}
153
154
unbind := showBlockedMessages.Subscribe(update)
155
parentWidget.ConnectDestroy(unbind)
156
}
157
158
if state.MessageMentions(message).Has(ningen.MessageMentions) {
159
parentWidget.AddCSSClass("message-mentioned")
160
} else {
161
parentWidget.RemoveCSSClass("message-mentioned")
162
}
163
}
164
165
// Redact implements Message.
166
func (m *message) Redact() {
167
m.content.Redact()
168
}
169
170
func (m *message) view() *View {
171
return m.content.view
172
}
173
174
func (m *message) bind(parent gtk.Widgetter) *gio.Menu {
175
if m.menu != nil {
176
return m.menu
177
}
178
179
actions := map[string]func(){
180
"message.show-source": func() { m.ShowSource() },
181
"message.reply": func() { m.view().ReplyTo(m.message.ID) },
182
}
183
184
state := gtkcord.FromContext(m.ctx())
185
me, _ := state.Cabinet.Me()
186
channel, _ := state.Cabinet.Channel(m.message.ChannelID)
187
188
if me != nil && m.message.Author.ID == me.ID {
189
actions["message.edit"] = func() { m.view().Edit(m.message.ID) }
190
actions["message.delete"] = func() { m.view().Delete(m.message.ID) }
191
}
192
193
if state.Offline().HasPermissions(m.message.ChannelID, discord.PermissionManageMessages) {
194
actions["message.delete"] = func() { m.view().Delete(m.message.ID) }
195
}
196
197
if channel != nil && (channel.Type == discord.DirectMessage || channel.Type == discord.GroupDM) {
198
actions["message.add-reaction"] = func() { m.ShowEmojiChooser() }
199
}
200
201
if state.Offline().HasPermissions(m.message.ChannelID, discord.PermissionAddReactions) {
202
actions["message.add-reaction"] = func() { m.ShowEmojiChooser() }
203
}
204
205
menuItems := []gtkutil.PopoverMenuItem{
206
menuItemIfOK(actions, "Add _Reaction", "message.add-reaction"),
207
menuItemIfOK(actions, "_Reply", "message.reply"),
208
menuItemIfOK(actions, "_Edit", "message.edit"),
209
menuItemIfOK(actions, "_Delete", "message.delete"),
210
menuItemIfOK(actions, "Show _Source", "message.show-source"),
211
}
212
213
gtkutil.BindActionMap(parent, actions)
214
gtkutil.BindPopoverMenuCustom(parent, gtk.PosTop, menuItems)
215
216
m.menu = gtkutil.CustomMenu(menuItems)
217
m.content.SetExtraMenu(m.menu)
218
219
return m.menu
220
}
221
222
func menuItemIfOK(actions map[string]func(), label locale.Localized, action string) gtkutil.PopoverMenuItem {
223
_, ok := actions[action]
224
return gtkutil.MenuItem(label, action, ok)
225
}
226
227
var sourceCSS = cssutil.Applier("message-source", `
228
.message-source {
229
padding: 6px 4px;
230
font-family: monospace;
231
}
232
`)
233
234
// ShowEmojiChooser opens a Gtk.EmojiChooser popover.
235
func (m *message) ShowEmojiChooser() {
236
e := gtk.NewEmojiChooser()
237
e.SetParent(m.content)
238
e.SetHasArrow(false)
239
240
e.ConnectEmojiPicked(func(text string) {
241
emoji := discord.APIEmoji(text)
242
m.view().AddReaction(m.content.msgID, emoji)
243
})
244
245
e.Present()
246
e.Popup()
247
}
248
249
// ShowSource opens a dialog showing a JSON representation of the message.
250
func (m *message) ShowSource() {
251
d := adw.NewDialog()
252
d.SetTitle(locale.Get("View Source"))
253
d.SetContentWidth(500)
254
d.SetContentHeight(300)
255
256
h := adw.NewHeaderBar()
257
h.SetCenteringPolicy(adw.CenteringPolicyStrict)
258
259
toolbarView := adw.NewToolbarView()
260
toolbarView.SetTopBarStyle(adw.ToolbarFlat)
261
toolbarView.AddTopBar(h)
262
263
buf := gtk.NewTextBuffer(nil)
264
265
if raw, err := json.MarshalIndent(m.message, "", "\t"); err != nil {
266
buf.SetText("Error marshaing JSON: " + err.Error())
267
} else {
268
buf.SetText(string(raw))
269
hl.Highlight(m.ctx(), buf.StartIter(), buf.EndIter(), "json")
270
}
271
272
t := gtk.NewTextViewWithBuffer(buf)
273
t.SetEditable(false)
274
t.SetCursorVisible(false)
275
t.SetWrapMode(gtk.WrapWordChar)
276
sourceCSS(t)
277
textutil.SetTabSize(t)
278
279
s := gtk.NewScrolledWindow()
280
s.SetVExpand(true)
281
s.SetHExpand(true)
282
s.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
283
s.SetChild(t)
284
285
copyBtn := gtk.NewButtonFromIconName("edit-copy-symbolic")
286
copyBtn.SetTooltipText(locale.Get("Copy JSON"))
287
copyBtn.ConnectClicked(func() {
288
clipboard := m.view().Clipboard()
289
sourceText := buf.Text(buf.StartIter(), buf.EndIter(), false)
290
clipboard.SetText(sourceText)
291
})
292
h.PackStart(copyBtn)
293
294
box := gtk.NewBox(gtk.OrientationVertical, 0)
295
box.Append(h)
296
box.Append(s)
297
298
toolbarView.SetContent(box)
299
300
d.SetChild(toolbarView)
301
d.Present(app.GTKWindowFromContext(m.ctx()))
302
}
303
304
// cozyMessage is a large cozy message with an avatar.
305
type cozyMessage struct {
306
*gtk.Box
307
Avatar *onlineimage.Avatar
308
RightBox *gtk.Box
309
TopLabel *gtk.Label
310
311
message
312
tooltip string // markup
313
}
314
315
var _ MessageWithUser = (*cozyMessage)(nil)
316
317
var cozyCSS = cssutil.Applier("message-cozy", `
318
.message-cozy {
319
padding-top: 0.25em;
320
padding-bottom: 0.15em;
321
}
322
.message-cozy-header {
323
min-height: 1.75em;
324
margin-top: 2px;
325
font-size: 0.95em;
326
}
327
.message-cozy-avatar {
328
padding: 0 8px;
329
}
330
`)
331
332
// NewCozyMessage creates a new cozy message.
333
func NewCozyMessage(ctx context.Context, v *View) Message {
334
m := cozyMessage{
335
message: newMessage(ctx, v),
336
}
337
338
m.TopLabel = gtk.NewLabel("")
339
m.TopLabel.AddCSSClass("message-cozy-header")
340
m.TopLabel.SetXAlign(0)
341
m.TopLabel.SetEllipsize(pango.EllipsizeEnd)
342
m.TopLabel.SetSingleLineMode(true)
343
344
m.RightBox = gtk.NewBox(gtk.OrientationVertical, 0)
345
m.RightBox.SetHExpand(true)
346
m.RightBox.Append(m.TopLabel)
347
m.RightBox.Append(m.message.content)
348
349
m.Avatar = onlineimage.NewAvatar(ctx, imgutil.HTTPProvider, gtkcord.MessageAvatarSize)
350
m.Avatar.AddCSSClass("message-cozy-avatar")
351
m.Avatar.SetVAlign(gtk.AlignStart)
352
m.Avatar.EnableAnimation().OnHover()
353
354
m.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)
355
m.Box.Append(m.Avatar)
356
m.Box.Append(m.RightBox)
357
358
cozyCSS(m)
359
return &m
360
}
361
362
func (m *cozyMessage) Update(message *gateway.MessageCreateEvent) {
363
m.message.update(m, &message.Message)
364
m.updateAuthor(message)
365
366
tooltip := fmt.Sprintf(
367
"<b>%s</b>\n%s",
368
html.EscapeString(message.Author.Tag()),
369
html.EscapeString(locale.Time(message.Timestamp.Time(), true)),
370
)
371
372
// TODO: query tooltip
373
m.Avatar.SetTooltipMarkup(tooltip)
374
m.TopLabel.SetTooltipMarkup(tooltip)
375
}
376
377
func (m *cozyMessage) UpdateMember(member *discord.Member) {
378
if m.message.message == nil {
379
return
380
}
381
382
m.updateAuthor(&gateway.MessageCreateEvent{
383
Message: *m.message.message,
384
Member: member,
385
})
386
}
387
388
func (m *cozyMessage) updateAuthor(message *gateway.MessageCreateEvent) {
389
var avatarURL string
390
if message.Member != nil && message.Member.Avatar != "" {
391
avatarURL = message.Member.AvatarURL(message.GuildID)
392
} else {
393
avatarURL = message.Author.AvatarURL()
394
}
395
m.Avatar.SetFromURL(gtkcord.InjectAvatarSize(avatarURL))
396
397
state := gtkcord.FromContext(m.ctx())
398
399
markup := "<b>" + state.AuthorMarkup(message) + "</b>"
400
markup += ` <span alpha="75%" size="small">` +
401
locale.TimeAgo(message.Timestamp.Time()) +
402
"</span>"
403
404
m.TopLabel.SetMarkup(markup)
405
}
406
407
// collapsedMessage is a collapsed cozy message.
408
type collapsedMessage struct {
409
*gtk.Box
410
Timestamp *gtk.Label
411
412
message
413
}
414
415
var _ Message = (*collapsedMessage)(nil)
416
417
var collapsedCSS = cssutil.Applier("message-collapsed", `
418
.message-collapsed {
419
padding-bottom: 0.15em;
420
}
421
.message-collapsed-timestamp {
422
opacity: 0;
423
font-size: 0.7em;
424
min-height: calc(1em + 0.7rem);
425
}
426
.message-row:hover .message-collapsed-timestamp {
427
opacity: 1;
428
color: alpha(@theme_fg_color, 0.75);
429
}
430
`)
431
432
const collapsedTimestampWidth = (8 * 2) + (gtkcord.MessageAvatarSize)
433
434
// NewCollapsedMessage creates a new collapsed cozy message.
435
func NewCollapsedMessage(ctx context.Context, v *View) Message {
436
m := collapsedMessage{
437
message: newMessage(ctx, v),
438
}
439
440
m.Timestamp = gtk.NewLabel("")
441
m.Timestamp.AddCSSClass("message-collapsed-timestamp")
442
m.Timestamp.SetSizeRequest(collapsedTimestampWidth, -1)
443
444
// This widget will not ellipsize properly, so we're forced to wrap.
445
m.Timestamp.SetWrap(true)
446
m.Timestamp.SetWrapMode(pango.WrapWordChar)
447
m.Timestamp.SetNaturalWrapMode(gtk.NaturalWrapWord)
448
449
m.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)
450
m.Box.Append(m.Timestamp)
451
m.Box.Append(m.message.content)
452
453
collapsedCSS(m)
454
return &m
455
}
456
457
func (m *collapsedMessage) Update(message *gateway.MessageCreateEvent) {
458
m.message.update(m, &message.Message)
459
460
// view := m.view()
461
462
var timestampLabel string
463
464
switch collapsedMessageTimestamp.Value() {
465
case compactTimestampStyle:
466
timestampLabel = locale.Time(message.Timestamp.Time(), false)
467
// case relativeTimestampStyle:
468
// prevKey, _ := view.prevMessageKeyFromID(message.Message.ID)
469
// prev, ok := view.rows[prevKey]
470
// if ok {
471
// prevMsg := prev.message.Message()
472
// if prevMsg != nil {
473
// currTimestamp := message.Timestamp.Time()
474
// prevTimestamp := prevMsg.Timestamp.Time()
475
//
476
// delta := currTimestamp.Sub(prevTimestamp)
477
// switch {
478
// case delta < time.Second:
479
// // leave empty
480
// case delta < time.Minute:
481
// timestampLabel = "+" + locale.Sprintf("%ds", int(math.Round(delta.Seconds())))
482
// default:
483
// // This is always at most 10 minutes.
484
// timestampLabel = "+" + locale.Sprintf("%dm", int(math.Round(delta.Minutes())))
485
// }
486
// }
487
// }
488
}
489
490
m.Timestamp.SetLabel(timestampLabel)
491
m.Timestamp.SetTooltipText(locale.Time(message.Timestamp.Time(), true))
492
}
493
494