Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/messages/view.go
366 views
1
package messages
2
3
import (
4
"cmp"
5
"context"
6
"fmt"
7
"html"
8
"log/slog"
9
"slices"
10
"time"
11
12
"github.com/diamondburned/adaptive"
13
"github.com/diamondburned/arikawa/v3/api"
14
"github.com/diamondburned/arikawa/v3/discord"
15
"github.com/diamondburned/arikawa/v3/gateway"
16
"github.com/diamondburned/arikawa/v3/utils/sendpart"
17
"github.com/diamondburned/chatkit/components/author"
18
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
19
"github.com/diamondburned/gotk4/pkg/glib/v2"
20
"github.com/diamondburned/gotk4/pkg/gtk/v4"
21
"github.com/diamondburned/gotk4/pkg/pango"
22
"github.com/diamondburned/gotkit/app"
23
"github.com/diamondburned/gotkit/app/locale"
24
"github.com/diamondburned/gotkit/components/autoscroll"
25
"github.com/diamondburned/gotkit/gtkutil"
26
"github.com/diamondburned/gotkit/gtkutil/cssutil"
27
"github.com/pkg/errors"
28
"libdb.so/dissent/internal/components/hoverpopover"
29
"libdb.so/dissent/internal/gtkcord"
30
"libdb.so/dissent/internal/messages/composer"
31
)
32
33
type messageRow struct {
34
*gtk.ListBoxRow
35
message Message
36
info messageInfo
37
}
38
39
type messageInfo struct {
40
id discord.MessageID
41
author messageAuthor
42
timestamp discord.Timestamp
43
}
44
45
func newMessageInfo(msg *discord.Message) messageInfo {
46
return messageInfo{
47
id: msg.ID,
48
author: newMessageAuthor(&msg.Author),
49
timestamp: msg.Timestamp,
50
}
51
}
52
53
type messageAuthor struct {
54
userID discord.UserID
55
userTag string
56
}
57
58
func newMessageAuthor(author *discord.User) messageAuthor {
59
return messageAuthor{
60
userID: author.ID,
61
userTag: author.Tag(),
62
}
63
}
64
65
type viewState struct {
66
row messageRow
67
editing bool
68
replying bool
69
}
70
71
// View is a message view widget.
72
type View struct {
73
*adaptive.LoadablePage
74
focused gtk.Widgetter
75
76
ToastOverlay *adw.ToastOverlay
77
LoadMore *gtk.Button
78
Scroll *autoscroll.Window
79
List *gtk.ListBox
80
Composer *composer.View
81
TypingIndicator *TypingIndicator
82
83
rows map[messageKey]messageRow
84
chName string
85
guildID discord.GuildID
86
87
summaries map[discord.Snowflake]messageSummaryWidget
88
89
state viewState
90
91
ctx context.Context
92
chID discord.ChannelID
93
}
94
95
var viewCSS = cssutil.Applier("message-view", `
96
.message-list {
97
background: none;
98
}
99
.message-list > row {
100
box-shadow: none;
101
background: none;
102
background-image: none;
103
background-color: transparent;
104
padding: 0;
105
}
106
.message-show-more {
107
background: none;
108
border-radius: 0;
109
font-size: 0.85em;
110
opacity: 0.65;
111
}
112
.message-show-more:hover {
113
background: alpha(@theme_fg_color, 0.075);
114
}
115
.messages-typing-indicator {
116
margin-top: -1em;
117
}
118
.messages-typing-box {
119
background-color: @theme_bg_color;
120
}
121
.message-list,
122
.message-scroll scrollbar.vertical {
123
margin-bottom: 1em;
124
}
125
`)
126
127
const (
128
loadMoreBatch = 50 // load this many more messages on scroll
129
initialBatch = 15 // load this many messages on startup
130
idealMaxCount = 50 // ideally keep this many messages in the view
131
)
132
133
func applyViewClamp(clamp *adw.Clamp) {
134
clamp.SetMaximumSize(messagesWidth.Value())
135
// Set tightening threshold to 90% of the clamp's width.
136
clamp.SetTighteningThreshold(int(float64(messagesWidth.Value()) * 0.9))
137
}
138
139
// NewView creates a new View widget associated with the given channel ID. All
140
// methods call on it will act on that channel.
141
func NewView(ctx context.Context, chID discord.ChannelID) *View {
142
v := &View{
143
rows: make(map[messageKey]messageRow),
144
chID: chID,
145
ctx: ctx,
146
}
147
148
v.LoadMore = gtk.NewButton()
149
v.LoadMore.AddCSSClass("message-show-more")
150
v.LoadMore.SetLabel(locale.Get("Show More"))
151
v.LoadMore.SetHExpand(true)
152
v.LoadMore.SetVExpand(true) // hack to push the messages to the bottom
153
v.LoadMore.SetVAlign(gtk.AlignStart)
154
v.LoadMore.SetSensitive(true)
155
v.LoadMore.ConnectClicked(v.loadMore)
156
157
v.List = gtk.NewListBox()
158
v.List.AddCSSClass("message-list")
159
v.List.SetSelectionMode(gtk.SelectionNone)
160
v.List.SetVExpand(false)
161
v.List.SetVAlign(gtk.AlignEnd)
162
163
clampBox := gtk.NewBox(gtk.OrientationVertical, 0)
164
clampBox.SetHExpand(true)
165
clampBox.SetVExpand(true)
166
clampBox.Append(v.LoadMore)
167
clampBox.Append(v.List)
168
169
// Require 2 clamps, one inside the scroll view and another outside the
170
// scroll view. This way, the scrollbars will be on the far right rather
171
// than being stuck in the middle.
172
clampScroll := adw.NewClamp()
173
clampScroll.SetChild(clampBox)
174
applyViewClamp(clampScroll)
175
176
v.Scroll = autoscroll.NewWindow()
177
v.Scroll.AddCSSClass("message-scroll")
178
v.Scroll.SetPropagateNaturalWidth(true)
179
v.Scroll.SetPropagateNaturalHeight(true)
180
v.Scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
181
v.Scroll.SetChild(clampScroll)
182
v.Scroll.OnBottomed(v.onScrollBottomed)
183
184
scrollAdjustment := v.Scroll.VAdjustment()
185
scrollAdjustment.ConnectValueChanged(func() {
186
// Replicate adw.ToolbarView's behavior: if the user scrolls up, then
187
// show a small drop shadow at the bottom of the view. We're not using
188
// the actual widget, because it adds a WindowHandle at the bottom,
189
// which breaks double-clicking.
190
const undershootClass = "undershoot-bottom"
191
192
value := scrollAdjustment.Value()
193
upper := scrollAdjustment.Upper()
194
psize := scrollAdjustment.PageSize()
195
if value < upper-psize {
196
v.Scroll.AddCSSClass(undershootClass)
197
} else {
198
v.Scroll.RemoveCSSClass(undershootClass)
199
}
200
})
201
202
vp := v.Scroll.Viewport()
203
vp.SetScrollToFocus(true)
204
vp.SetVScrollPolicy(gtk.ScrollMinimum)
205
vp.SetHScrollPolicy(gtk.ScrollMinimum)
206
v.List.SetAdjustment(v.Scroll.VAdjustment())
207
208
v.Composer = composer.NewView(ctx, v, chID)
209
gtkutil.ForwardTyping(v.List, v.Composer.Input)
210
211
v.TypingIndicator = NewTypingIndicator(ctx, chID)
212
v.TypingIndicator.SetHExpand(true)
213
v.TypingIndicator.SetVAlign(gtk.AlignStart)
214
215
composerOverlay := gtk.NewOverlay()
216
composerOverlay.AddOverlay(v.TypingIndicator)
217
composerOverlay.SetChild(v.Composer)
218
219
composerClamp := adw.NewClamp()
220
composerClamp.SetChild(composerOverlay)
221
applyViewClamp(composerClamp)
222
223
outerBox := gtk.NewBox(gtk.OrientationVertical, 0)
224
outerBox.SetHExpand(true)
225
outerBox.SetVExpand(true)
226
outerBox.Append(v.Scroll)
227
outerBox.Append(composerClamp)
228
229
v.ToastOverlay = adw.NewToastOverlay()
230
v.ToastOverlay.SetVAlign(gtk.AlignStart)
231
232
toastOuterOverlay := gtk.NewOverlay()
233
toastOuterOverlay.SetChild(outerBox)
234
toastOuterOverlay.AddOverlay(v.ToastOverlay)
235
236
// This becomes the outermost widget.
237
v.focused = toastOuterOverlay
238
239
v.LoadablePage = adaptive.NewLoadablePage()
240
v.LoadablePage.SetTransitionDuration(125)
241
v.setPageToMain()
242
243
// If the window gains focus, try to carefully mark the channel as read.
244
var windowSignal glib.SignalHandle
245
v.ConnectMap(func() {
246
window := app.GTKWindowFromContext(ctx)
247
windowSignal = window.NotifyProperty("is-active", func() {
248
if v.IsActive() {
249
v.MarkRead()
250
}
251
})
252
})
253
// Immediately disconnect the signal when the widget is unmapped.
254
// This should prevent v from being referenced forever.
255
v.ConnectUnmap(func() {
256
window := app.GTKWindowFromContext(ctx)
257
window.HandlerDisconnect(windowSignal)
258
windowSignal = 0
259
})
260
261
state := gtkcord.FromContext(v.ctx)
262
if ch, err := state.Cabinet.Channel(v.chID); err == nil {
263
v.chName = ch.Name
264
v.guildID = ch.GuildID
265
}
266
267
state.BindWidget(v, func(ev gateway.Event) {
268
switch ev := ev.(type) {
269
case *gateway.MessageCreateEvent:
270
if ev.ChannelID != v.chID {
271
return
272
}
273
274
// Use this to update existing messages' members as well.
275
if ev.Member != nil {
276
v.updateMember(ev.Member)
277
}
278
279
if ev.Nonce != "" {
280
// Try and look up the nonce.
281
key := messageKeyNonce(ev.Nonce)
282
283
if msg, ok := v.rows[key]; ok {
284
delete(v.rows, key)
285
286
key = messageKeyID(ev.ID)
287
// Known sent message. Update this instead.
288
v.rows[key] = msg
289
290
msg.ListBoxRow.SetName(string(key))
291
msg.message.Update(ev)
292
return
293
}
294
}
295
296
if !v.ignoreMessage(&ev.Message) {
297
msg := v.upsertMessage(ev.ID, newMessageInfo(&ev.Message), 0)
298
msg.Update(ev)
299
}
300
301
case *gateway.MessageUpdateEvent:
302
if ev.ChannelID != v.chID {
303
return
304
}
305
306
m, err := state.Cabinet.Message(ev.ChannelID, ev.ID)
307
if err == nil && !v.ignoreMessage(&ev.Message) {
308
msg := v.upsertMessage(ev.ID, newMessageInfo(m), 0)
309
msg.Update(&gateway.MessageCreateEvent{
310
Message: *m,
311
Member: ev.Member,
312
})
313
}
314
315
case *gateway.MessageDeleteEvent:
316
if ev.ChannelID != v.chID {
317
return
318
}
319
320
v.deleteMessage(ev.ID)
321
322
case *gateway.MessageReactionAddEvent:
323
if ev.ChannelID != v.chID {
324
return
325
}
326
v.updateMessageReactions(ev.MessageID)
327
328
case *gateway.MessageReactionRemoveEvent:
329
if ev.ChannelID != v.chID {
330
return
331
}
332
v.updateMessageReactions(ev.MessageID)
333
334
case *gateway.MessageReactionRemoveAllEvent:
335
if ev.ChannelID != v.chID {
336
return
337
}
338
v.updateMessageReactions(ev.MessageID)
339
340
case *gateway.MessageReactionRemoveEmojiEvent:
341
if ev.ChannelID != v.chID {
342
return
343
}
344
v.updateMessageReactions(ev.MessageID)
345
346
case *gateway.MessageDeleteBulkEvent:
347
if ev.ChannelID != v.chID {
348
return
349
}
350
351
for _, id := range ev.IDs {
352
v.deleteMessage(id)
353
}
354
355
case *gateway.GuildMemberAddEvent:
356
slog.Debug(
357
"GuildMemberAddEvent not implemented",
358
"guildID", ev.GuildID,
359
"userID", ev.User.ID)
360
361
case *gateway.GuildMemberUpdateEvent:
362
if ev.GuildID != v.guildID {
363
return
364
}
365
366
member, _ := state.Cabinet.Member(ev.GuildID, ev.User.ID)
367
if member != nil {
368
v.updateMember(member)
369
}
370
371
case *gateway.GuildMemberRemoveEvent:
372
slog.Debug(
373
"GuildMemberRemoveEvent not implemented",
374
"guildID", ev.GuildID,
375
"userID", ev.User.ID)
376
377
case *gateway.GuildMembersChunkEvent:
378
// TODO: Discord isn't sending us this event. I'm not sure why.
379
// Their client has to work somehow. Maybe they use the right-side
380
// member list?
381
if ev.GuildID != v.guildID {
382
return
383
}
384
385
for i := range ev.Members {
386
v.updateMember(&ev.Members[i])
387
}
388
389
case *gateway.ConversationSummaryUpdateEvent:
390
if ev.ChannelID != v.chID {
391
return
392
}
393
394
v.updateSummaries(ev.Summaries)
395
}
396
})
397
398
gtkutil.BindActionCallbackMap(v.List, map[string]gtkutil.ActionCallback{
399
"messages.scroll-to": {
400
ArgType: gtkcord.SnowflakeVariant,
401
Func: func(args *glib.Variant) {
402
id := discord.MessageID(args.Int64())
403
404
msg, ok := v.rows[messageKeyID(id)]
405
if !ok {
406
slog.Warn(
407
"tried to scroll to non-existent message",
408
"id", id)
409
return
410
}
411
412
if !msg.ListBoxRow.GrabFocus() {
413
slog.Warn(
414
"failed to grab focus of message",
415
"id", id)
416
}
417
},
418
},
419
})
420
421
viewCSS(v)
422
return v
423
}
424
425
// HeaderButtons returns the header buttons widget for the message view.
426
// This widget is kept on the header bar for as long as the message view is
427
// active.
428
func (v *View) HeaderButtons() []gtk.Widgetter {
429
var buttons []gtk.Widgetter
430
431
if v.guildID.IsValid() {
432
summariesButton := hoverpopover.NewPopoverButton(v.initSummariesPopover)
433
summariesButton.SetIconName("speaker-notes-symbolic")
434
summariesButton.SetTooltipText(locale.Get("Message Summaries"))
435
buttons = append(buttons, summariesButton)
436
437
state := gtkcord.FromContext(v.ctx)
438
if len(state.SummaryState.Summaries(v.chID)) == 0 {
439
summariesButton.SetSensitive(false)
440
var unbind func()
441
unbind = state.AddHandlerForWidget(summariesButton, func(ev *gateway.ConversationSummaryUpdateEvent) {
442
if ev.ChannelID == v.chID && len(ev.Summaries) > 0 {
443
summariesButton.SetSensitive(true)
444
unbind()
445
}
446
})
447
}
448
449
infoButton := hoverpopover.NewPopoverButton(func(popover *gtk.Popover) bool {
450
popover.AddCSSClass("message-channel-info-popover")
451
popover.SetPosition(gtk.PosBottom)
452
453
label := gtk.NewLabel("")
454
label.AddCSSClass("popover-label")
455
popover.SetChild(label)
456
457
state := gtkcord.FromContext(v.ctx)
458
ch, _ := state.Offline().Channel(v.chID)
459
if ch == nil {
460
label.SetText(locale.Get("Channel information unavailable."))
461
return true
462
}
463
464
markup := fmt.Sprintf(
465
`<b>%s</b>`,
466
html.EscapeString(ch.Name))
467
468
if ch.NSFW {
469
markup += fmt.Sprintf(
470
"\n<i><small>%s</small></i>",
471
locale.Get("This channel is NSFW."))
472
}
473
474
if ch.Topic != "" {
475
markup += fmt.Sprintf(
476
"\n<small>%s</small>",
477
html.EscapeString(ch.Topic))
478
} else {
479
markup += fmt.Sprintf(
480
"\n<i><small>%s</small></i>",
481
locale.Get("No topic set."))
482
}
483
484
label.SetSizeRequest(100, -1)
485
label.SetMaxWidthChars(100)
486
label.SetWrap(true)
487
label.SetWrapMode(pango.WrapWordChar)
488
label.SetJustify(gtk.JustifyLeft)
489
label.SetXAlign(0)
490
label.SetMarkup(markup)
491
return true
492
})
493
infoButton.SetIconName("dialog-information-symbolic")
494
infoButton.SetTooltipText(locale.Get("Channel Info"))
495
buttons = append(buttons, infoButton)
496
}
497
498
return buttons
499
}
500
501
// GuildID returns the guild ID of the channel that the message view is
502
// displaying for.
503
func (v *View) GuildID() discord.GuildID {
504
return v.guildID
505
}
506
507
// ChannelID returns the channel ID of the message view.
508
func (v *View) ChannelID() discord.ChannelID {
509
return v.chID
510
}
511
512
// ChannelName returns the name of the channel that the message view is
513
// displaying for.
514
func (v *View) ChannelName() string {
515
return v.chName
516
}
517
518
// messageSummaries returns the message summaries for the channel that the
519
// message view is displaying for. If showSummaries is false, then nil is
520
// returned.
521
func (v *View) messageSummaries() map[discord.MessageID]gateway.ConversationSummary {
522
if !showSummaries.Value() {
523
return nil
524
}
525
526
state := gtkcord.FromContext(v.ctx)
527
summaries := state.SummaryState.Summaries(v.chID)
528
if len(summaries) == 0 {
529
return nil
530
}
531
532
summariesMap := make(map[discord.MessageID]gateway.ConversationSummary, len(summaries))
533
for _, summary := range summaries {
534
summariesMap[summary.EndID] = summary
535
}
536
537
return summariesMap
538
}
539
540
// FetchBacklog fetches the initial backlog of messages for the channel that the
541
// view belongs to.
542
func (v *View) FetchBacklog() {
543
slog.Debug(
544
"loading message view",
545
"channel", v.chID)
546
547
v.LoadablePage.SetLoading()
548
v.unload()
549
550
state := gtkcord.FromContext(v.ctx)
551
552
gtkutil.Async(v.ctx, func() func() {
553
msgs, err := state.Online().Messages(v.chID, 15)
554
if err != nil {
555
return func() { v.LoadablePage.SetError(err) }
556
}
557
558
return func() {
559
state := gtkcord.FromContext(v.ctx)
560
561
ch, _ := state.Cabinet.Channel(v.chID)
562
if ch == nil {
563
v.LoadablePage.SetError(fmt.Errorf("channel not found"))
564
return
565
}
566
567
if len(msgs) == 0 && ch.Type == discord.DirectMessage {
568
v.LoadablePage.SetError(errors.New(
569
"refusing to load DM: please send a message via the official client first"))
570
return
571
}
572
573
v.AddBacklog(msgs)
574
}
575
})
576
}
577
578
// AddBacklog adds the given messages to the message view as a backlog.
579
func (v *View) AddBacklog(msgs []discord.Message) {
580
slices.SortFunc(msgs, func(a, b discord.Message) int {
581
return cmp.Compare(a.ID, b.ID)
582
})
583
584
v.setPageToMain()
585
v.Scroll.ScrollToBottom()
586
587
summariesMap := v.messageSummaries()
588
589
for _, msg := range msgs {
590
w := v.upsertMessage(msg.ID, newMessageInfo(&msg), 0)
591
w.Update(&gateway.MessageCreateEvent{Message: msg})
592
if summary, ok := summariesMap[msg.ID]; ok {
593
v.appendSummary(summary)
594
}
595
}
596
}
597
598
func (v *View) loadMore() {
599
firstRow, ok := v.firstMessage()
600
if !ok {
601
return
602
}
603
604
firstID := firstRow.info.id
605
606
slog.Debug(
607
"loading more messages",
608
"channel", v.chID)
609
610
ctx := v.ctx
611
state := gtkcord.FromContext(ctx).Online()
612
613
// TODO: combine this with AddBacklog.
614
upsertMessages := func(msgs []discord.Message) {
615
unlock := v.Scroll.LockScroll()
616
glib.IdleAdd(unlock)
617
618
infos := make([]messageInfo, len(msgs))
619
for i := range msgs {
620
infos[i] = newMessageInfo(&msgs[i])
621
}
622
623
summariesMap := v.messageSummaries()
624
625
for i, msg := range msgs {
626
flags := 0 |
627
upsertFlagOverrideCollapse |
628
upsertFlagPrepend
629
630
// Manually prepend our own messages. This also requires us to
631
// manually check if we should collapse the message.
632
if i != len(msgs)-1 {
633
curr := infos[i]
634
last := infos[i+1]
635
if shouldBeCollapsed(curr, last) {
636
flags |= upsertFlagCollapsed
637
}
638
}
639
640
// These messages are prepended, so we insert the "end summary" of
641
// the message before it.
642
if summary, ok := summariesMap[msg.ID]; ok {
643
v.appendSummary(summary)
644
}
645
646
w := v.upsertMessage(msg.ID, infos[i], flags)
647
w.Update(&gateway.MessageCreateEvent{Message: msg})
648
}
649
650
// Style the first prepended message to add a visual indicator for the
651
// user.
652
first := v.rows[messageKeyID(msgs[0].ID)]
653
first.message.AddCSSClass("message-first-prepended")
654
655
// Remove this visual indicator after a short while.
656
glib.TimeoutSecondsAdd(10, func() {
657
first.message.RemoveCSSClass("message-first-prepended")
658
})
659
}
660
661
stateMessages, err := state.Cabinet.Messages(v.chID)
662
if err == nil && len(stateMessages) > 0 {
663
// State messages are ordered last first, so we can traverse them.
664
var found bool
665
for i, m := range stateMessages {
666
if m.ID < firstID {
667
slog.Debug(
668
"while loading more messages, found earlier message in state",
669
"message_id", m.ID,
670
"content", m.Content)
671
stateMessages = stateMessages[i:]
672
found = true
673
break
674
}
675
}
676
677
if found {
678
if len(stateMessages) > loadMoreBatch {
679
stateMessages = stateMessages[:loadMoreBatch]
680
}
681
upsertMessages(stateMessages)
682
return
683
}
684
}
685
686
gtkutil.Async(ctx, func() func() {
687
messages, err := state.MessagesBefore(v.chID, firstID, loadMoreBatch)
688
if err != nil {
689
app.Error(ctx, fmt.Errorf("failed to load more messages: %w", err))
690
return nil
691
}
692
693
return func() {
694
if len(messages) > 0 {
695
upsertMessages(messages)
696
}
697
698
if len(messages) < loadMoreBatch {
699
// We've reached the end of the channel's history.
700
// Disable the load more button.
701
v.LoadMore.SetSensitive(false)
702
}
703
}
704
})
705
}
706
707
func (v *View) setPageToMain() {
708
v.LoadablePage.SetChild(v.focused)
709
}
710
711
func (v *View) unload() {
712
for k, msg := range v.rows {
713
v.List.Remove(msg)
714
delete(v.rows, k)
715
}
716
}
717
718
func (v *View) ignoreMessage(msg *discord.Message) bool {
719
state := gtkcord.FromContext(v.ctx)
720
return showBlockedMessages.Value() && state.UserIsBlocked(msg.Author.ID)
721
}
722
723
type upsertFlags int
724
725
const (
726
upsertFlagCollapsed upsertFlags = 1 << iota
727
upsertFlagOverrideCollapse
728
upsertFlagPrepend
729
)
730
731
// upsertMessage inserts or updates a new message row.
732
// TODO: move boolean args to flags.
733
func (v *View) upsertMessage(id discord.MessageID, info messageInfo, flags upsertFlags) Message {
734
if flags&upsertFlagOverrideCollapse == 0 && v.shouldBeCollapsed(info) {
735
flags |= upsertFlagCollapsed
736
}
737
return v.upsertMessageKeyed(messageKeyID(id), info, flags)
738
}
739
740
// upsertMessageKeyed inserts or updates a new message row with the given key.
741
func (v *View) upsertMessageKeyed(key messageKey, info messageInfo, flags upsertFlags) Message {
742
if msg, ok := v.rows[key]; ok {
743
return msg.message
744
}
745
746
msg := v.createMessageKeyed(key, info, flags)
747
v.rows[key] = msg
748
749
if flags&upsertFlagPrepend != 0 {
750
v.List.Prepend(msg.ListBoxRow)
751
} else {
752
v.List.Append(msg.ListBoxRow)
753
}
754
755
v.List.SetFocusChild(msg.ListBoxRow)
756
return msg.message
757
}
758
759
func (v *View) createMessageKeyed(key messageKey, info messageInfo, flags upsertFlags) messageRow {
760
var message Message
761
if flags&upsertFlagCollapsed != 0 {
762
message = NewCollapsedMessage(v.ctx, v)
763
} else {
764
message = NewCozyMessage(v.ctx, v)
765
}
766
767
row := gtk.NewListBoxRow()
768
row.AddCSSClass("message-row")
769
row.SetName(string(key))
770
row.SetChild(message)
771
772
return messageRow{
773
ListBoxRow: row,
774
message: message,
775
info: info,
776
}
777
}
778
779
// resetMessage resets the message with the given messageRow.
780
// Its main point is to re-evaluate the collapsed state of the message.
781
func (v *View) resetMessage(key messageKey) {
782
row, ok := v.rows[key]
783
if !ok || row.message == nil {
784
return
785
}
786
787
var message Message
788
789
shouldBeCollapsed := v.shouldBeCollapsed(row.info)
790
if _, isCollapsed := row.message.(*collapsedMessage); shouldBeCollapsed == isCollapsed {
791
message = row.message
792
} else {
793
if shouldBeCollapsed {
794
message = NewCollapsedMessage(v.ctx, v)
795
} else {
796
message = NewCozyMessage(v.ctx, v)
797
}
798
}
799
800
message.Update(&gateway.MessageCreateEvent{
801
Message: *row.message.Message(),
802
})
803
804
row.message = message
805
row.ListBoxRow.SetChild(message)
806
807
v.rows[key] = row
808
}
809
810
// surroundingMessagesResetter creates a function that resets the messages
811
// surrounding the given message.
812
func (v *View) surroundingMessagesResetter(key messageKey) func() {
813
msg, ok := v.rows[key]
814
if !ok {
815
slog.Warn(
816
"useless surroundingMessagesResetter call on non-existent message",
817
"key", key)
818
return func() {}
819
}
820
821
// Just be really safe.
822
resets := make([]func(), 0, 2)
823
if key, ok := v.nextMessageKey(msg); ok {
824
resets = append(resets, func() { v.resetMessage(key) })
825
}
826
if key, ok := v.prevMessageKey(msg); ok {
827
resets = append(resets, func() { v.resetMessage(key) })
828
}
829
830
return func() {
831
for _, reset := range resets {
832
reset()
833
}
834
}
835
}
836
837
func (v *View) deleteMessage(id discord.MessageID) {
838
key := messageKeyID(id)
839
v.deleteMessageKeyed(key)
840
}
841
842
func (v *View) deleteMessageKeyed(key messageKey) {
843
msg, ok := v.rows[key]
844
if !ok {
845
return
846
}
847
848
if redactMessages.Value() && msg.message != nil {
849
msg.message.Redact()
850
return
851
}
852
853
reset := v.surroundingMessagesResetter(key)
854
defer reset()
855
856
v.List.Remove(msg)
857
delete(v.rows, key)
858
}
859
860
func (v *View) shouldBeCollapsed(info messageInfo) bool {
861
var last messageRow
862
var lastOK bool
863
864
if curr, ok := v.rows[messageKeyID(info.id)]; ok {
865
prev, ok := v.prevMessageKey(curr)
866
if ok {
867
last, lastOK = v.rows[prev]
868
}
869
} else {
870
slog.Debug(
871
"shouldBeCollapsed called on non-existent message, assuming last",
872
"id", info.id,
873
"author_id", info.author.userID,
874
"timestamp", info.timestamp.Time())
875
876
// Assume we're about to append a new message.
877
last, lastOK = v.lastMessage()
878
}
879
880
if !lastOK || last.message == nil {
881
return false
882
}
883
884
return shouldBeCollapsed(info, last.info)
885
}
886
887
func shouldBeCollapsed(curr, last messageInfo) bool {
888
return true &&
889
// same author
890
last.author == curr.author &&
891
last.author.userID.IsValid() &&
892
curr.author.userID.IsValid() &&
893
// within the last 10 minutes
894
last.timestamp.Time().Add(10*time.Minute).After(curr.timestamp.Time())
895
}
896
897
func (v *View) nextMessageKeyFromID(id discord.MessageID) (messageKey, bool) {
898
row, ok := v.rows[messageKeyID(id)]
899
if !ok {
900
return "", false
901
}
902
return v.nextMessageKey(row)
903
}
904
905
// nextMessageKey returns the key of the message after the given message.
906
func (v *View) nextMessageKey(row messageRow) (messageKey, bool) {
907
next, _ := row.NextSibling().(*gtk.ListBoxRow)
908
if next != nil {
909
return messageKeyRow(next), true
910
}
911
return "", false
912
}
913
914
func (v *View) prevMessageKeyFromID(id discord.MessageID) (messageKey, bool) {
915
row, ok := v.rows[messageKeyID(id)]
916
if !ok {
917
return "", false
918
}
919
return v.prevMessageKey(row)
920
}
921
922
// prevMessageKey returns the key of the message before the given message.
923
func (v *View) prevMessageKey(row messageRow) (messageKey, bool) {
924
prev, _ := row.PrevSibling().(*gtk.ListBoxRow)
925
if prev != nil {
926
return messageKeyRow(prev), true
927
}
928
return "", false
929
}
930
931
func (v *View) lastMessage() (messageRow, bool) {
932
row, _ := v.List.LastChild().(*gtk.ListBoxRow)
933
if row != nil {
934
msg, ok := v.rows[messageKeyRow(row)]
935
return msg, ok
936
}
937
938
return messageRow{}, false
939
}
940
941
func (v *View) lastUserMessage() Message {
942
state := gtkcord.FromContext(v.ctx)
943
me, _ := state.Me()
944
if me == nil {
945
return nil
946
}
947
948
var msg Message
949
v.eachMessageFromUser(me.ID, func(row messageRow) bool {
950
msg = row.message
951
return true
952
})
953
954
return msg
955
}
956
957
func (v *View) firstMessage() (messageRow, bool) {
958
row, _ := v.List.FirstChild().(*gtk.ListBoxRow)
959
if row != nil {
960
msg, ok := v.rows[messageKeyRow(row)]
961
return msg, ok
962
}
963
964
return messageRow{}, false
965
}
966
967
// eachMessage iterates over each message in the view, starting from the bottom.
968
// If the callback returns true, the loop will break.
969
func (v *View) eachMessage(f func(messageRow) bool) {
970
row, _ := v.List.LastChild().(*gtk.ListBoxRow)
971
for row != nil {
972
key := messageKey(row.Name())
973
974
m, ok := v.rows[key]
975
if ok {
976
if f(m) {
977
break
978
}
979
}
980
981
// This repeats until index is -1, at which the loop will break.
982
row, _ = row.PrevSibling().(*gtk.ListBoxRow)
983
}
984
}
985
986
func (v *View) eachMessageFromUser(id discord.UserID, f func(messageRow) bool) {
987
v.eachMessage(func(row messageRow) bool {
988
if row.info.author.userID == id {
989
return f(row)
990
}
991
return false
992
})
993
}
994
995
func (v *View) updateMember(member *discord.Member) {
996
v.eachMessageFromUser(member.User.ID, func(msg messageRow) bool {
997
m, ok := msg.message.(MessageWithUser)
998
if ok {
999
m.UpdateMember(member)
1000
}
1001
return false // keep looping
1002
})
1003
}
1004
1005
func (v *View) updateMessageReactions(id discord.MessageID) {
1006
widget, ok := v.rows[messageKeyID(id)]
1007
if !ok || widget.message == nil {
1008
return
1009
}
1010
1011
state := gtkcord.FromContext(v.ctx)
1012
1013
msg, _ := state.Cabinet.Message(v.chID, id)
1014
if msg == nil {
1015
return
1016
}
1017
1018
content := widget.message.Content()
1019
content.SetReactions(msg.Reactions)
1020
}
1021
1022
// SendMessage implements composer.Controller.
1023
func (v *View) SendMessage(sendingMsg composer.SendingMessage) {
1024
state := gtkcord.FromContext(v.ctx)
1025
1026
me, _ := state.Cabinet.Me()
1027
if me == nil {
1028
// Risk of leaking Files is too high. Just explode. This realistically
1029
// never happens anyway.
1030
panic("missing state.Cabinet.Me")
1031
}
1032
1033
info := messageInfo{
1034
author: newMessageAuthor(me),
1035
timestamp: discord.Timestamp(time.Now()),
1036
}
1037
1038
var flags upsertFlags
1039
if v.shouldBeCollapsed(info) {
1040
flags |= upsertFlagCollapsed
1041
}
1042
1043
key := messageKeyLocal()
1044
msg := v.upsertMessageKeyed(key, info, flags)
1045
1046
m := discord.Message{
1047
ChannelID: v.chID,
1048
GuildID: v.guildID,
1049
Content: sendingMsg.Content,
1050
Timestamp: discord.NowTimestamp(),
1051
Author: *me,
1052
}
1053
1054
if sendingMsg.ReplyingTo.IsValid() {
1055
m.Reference = &discord.MessageReference{
1056
ChannelID: v.chID,
1057
GuildID: v.guildID,
1058
MessageID: sendingMsg.ReplyingTo,
1059
}
1060
}
1061
1062
msg.AddCSSClass("message-sending")
1063
msg.Update(&gateway.MessageCreateEvent{Message: m})
1064
1065
uploading := newUploadingLabel(v.ctx, len(sendingMsg.Files))
1066
uploading.SetVisible(len(sendingMsg.Files) > 0)
1067
1068
content := msg.Content()
1069
content.Update(&m, uploading)
1070
1071
// Use the Background context so things keep getting updated when we switch
1072
// away.
1073
gtkutil.Async(context.Background(), func() func() {
1074
sendData := api.SendMessageData{
1075
Content: m.Content,
1076
Reference: m.Reference,
1077
Nonce: key.Nonce(),
1078
AllowedMentions: &api.AllowedMentions{
1079
RepliedUser: &sendingMsg.ReplyMention,
1080
Parse: []api.AllowedMentionType{
1081
api.AllowUserMention,
1082
api.AllowRoleMention,
1083
api.AllowEveryoneMention,
1084
},
1085
},
1086
}
1087
1088
// Ensure that we open ALL files and defer-close them. Otherwise, we'll
1089
// leak files.
1090
for _, file := range sendingMsg.Files {
1091
f, err := file.Open()
1092
if err != nil {
1093
glib.IdleAdd(func() { uploading.AppendError(err) })
1094
continue
1095
}
1096
1097
// This defer executes once we return (like all defers do).
1098
defer f.Close()
1099
1100
sendData.Files = append(sendData.Files, sendpart.File{
1101
Name: file.Name,
1102
Reader: wrappedReader{f, uploading},
1103
})
1104
}
1105
1106
state := state.Online()
1107
_, err := state.SendMessageComplex(m.ChannelID, sendData)
1108
1109
return func() {
1110
msg.RemoveCSSClass("message-sending")
1111
1112
if err != nil {
1113
uploading.AppendError(err)
1114
}
1115
1116
// We'll let the gateway echo back our own event that's identified
1117
// using the nonce.
1118
uploading.SetVisible(uploading.HasErrored())
1119
}
1120
})
1121
}
1122
1123
// ScrollToMessage scrolls to the message with the given ID.
1124
func (v *View) ScrollToMessage(id discord.MessageID) {
1125
if !v.List.ActivateAction("messages.scroll-to", gtkcord.NewMessageIDVariant(id)) {
1126
slog.Error(
1127
"cannot emit messages.scroll-to signal",
1128
"id", id)
1129
}
1130
}
1131
1132
// AddReaction adds an reaction to the message with the given ID.
1133
func (v *View) AddReaction(id discord.MessageID, emoji discord.APIEmoji) {
1134
state := gtkcord.FromContext(v.ctx)
1135
1136
emoji = discord.APIEmoji(gtkcord.SanitizeEmoji(string(emoji)))
1137
1138
gtkutil.Async(v.ctx, func() func() {
1139
if err := state.React(v.chID, id, emoji); err != nil {
1140
err = errors.Wrap(err, "Failed to react:")
1141
return func() {
1142
toast := adw.NewToast(locale.Get("Cannot react to message"))
1143
toast.SetTimeout(0)
1144
toast.SetButtonLabel(locale.Get("Logs"))
1145
toast.SetActionName("")
1146
}
1147
}
1148
return nil
1149
})
1150
}
1151
1152
// AddToast adds a toast to the message view.
1153
func (v *View) AddToast(toast *adw.Toast) {
1154
v.ToastOverlay.AddToast(toast)
1155
}
1156
1157
// ReplyTo starts replying to the message with the given ID.
1158
func (v *View) ReplyTo(id discord.MessageID) {
1159
v.stopEditingOrReplying()
1160
1161
row, ok := v.rows[messageKeyID(id)]
1162
if !ok || row.message == nil || row.message.Message() == nil {
1163
return
1164
}
1165
1166
v.state.row = row
1167
v.state.replying = true
1168
1169
row.message.AddCSSClass("message-replying")
1170
v.Composer.StartReplyingTo(row.message.Message())
1171
}
1172
1173
// Edit starts editing the message with the given ID.
1174
func (v *View) Edit(id discord.MessageID) {
1175
v.stopEditingOrReplying()
1176
1177
row, ok := v.rows[messageKeyID(id)]
1178
if !ok || row.message == nil || row.message.Message() == nil {
1179
return
1180
}
1181
1182
v.state.row = row
1183
v.state.editing = true
1184
1185
row.message.AddCSSClass("message-editing")
1186
v.Composer.StartEditing(row.message.Message())
1187
}
1188
1189
// StopEditing implements composer.Controller.
1190
func (v *View) StopEditing() {
1191
v.stopEditingOrReplying()
1192
}
1193
1194
// StopReplying implements composer.Controller.
1195
func (v *View) StopReplying() {
1196
v.stopEditingOrReplying()
1197
}
1198
1199
func (v *View) stopEditingOrReplying() {
1200
if v.state == (viewState{}) {
1201
return
1202
}
1203
1204
if v.state.editing {
1205
v.Composer.StopEditing()
1206
v.state.row.message.RemoveCSSClass("message-editing")
1207
}
1208
1209
if v.state.replying {
1210
v.Composer.StopReplying()
1211
v.state.row.message.RemoveCSSClass("message-replying")
1212
}
1213
1214
v.state = viewState{}
1215
}
1216
1217
// EditLastMessage implements composer.Controller.
1218
func (v *View) EditLastMessage() bool {
1219
msg := v.lastUserMessage()
1220
if msg == nil || msg.Message() == nil {
1221
return false
1222
}
1223
1224
v.Edit(msg.Message().ID)
1225
return true
1226
}
1227
1228
// Delete deletes the message with the given ID. It may prompt the user to
1229
// confirm the deletion.
1230
func (v *View) Delete(id discord.MessageID) {
1231
if !askBeforeDelete.Value() {
1232
v.delete(id)
1233
return
1234
}
1235
1236
user := "?" // juuust in case
1237
1238
row, ok := v.rows[messageKeyID(id)]
1239
if ok {
1240
message := row.message.Message()
1241
state := gtkcord.FromContext(v.ctx)
1242
user = state.AuthorMarkup(&gateway.MessageCreateEvent{Message: *message},
1243
author.WithMinimal())
1244
user = "<b>" + user + "</b>"
1245
}
1246
1247
window := app.GTKWindowFromContext(v.ctx)
1248
dialog := adw.NewMessageDialog(window,
1249
locale.Get("Delete Message"),
1250
locale.Sprintf("Are you sure you want to delete %s's message?", user))
1251
dialog.SetBodyUseMarkup(true)
1252
dialog.AddResponse("cancel", locale.Get("_Cancel"))
1253
dialog.AddResponse("delete", locale.Get("_Delete"))
1254
dialog.SetResponseAppearance("delete", adw.ResponseDestructive)
1255
dialog.SetDefaultResponse("cancel")
1256
dialog.SetCloseResponse("cancel")
1257
dialog.ConnectResponse(func(response string) {
1258
switch response {
1259
case "delete":
1260
v.delete(id)
1261
}
1262
})
1263
dialog.Present()
1264
}
1265
1266
func (v *View) delete(id discord.MessageID) {
1267
if msg, ok := v.rows[messageKeyID(id)]; ok {
1268
// Visual indicator.
1269
msg.SetSensitive(false)
1270
}
1271
1272
state := gtkcord.FromContext(v.ctx)
1273
go func() {
1274
// This is a fairly important operation, so ensure it goes through even
1275
// if the user switches away.
1276
state = state.WithContext(context.Background())
1277
1278
if err := state.DeleteMessage(v.chID, id, ""); err != nil {
1279
app.Error(v.ctx, errors.Wrap(err, "cannot delete message"))
1280
}
1281
}()
1282
}
1283
1284
func (v *View) onScrollBottomed() {
1285
if v.IsActive() {
1286
v.MarkRead()
1287
}
1288
1289
// Try to clean up the top messages.
1290
// Fast path: check our cache first.
1291
if len(v.rows) > idealMaxCount {
1292
var count int
1293
1294
row, _ := v.List.LastChild().(*gtk.ListBoxRow)
1295
for row != nil {
1296
next, _ := row.PrevSibling().(*gtk.ListBoxRow)
1297
1298
if count < idealMaxCount {
1299
count++
1300
} else {
1301
// Start purging messages.
1302
v.List.Remove(row)
1303
delete(v.rows, messageKeyRow(row))
1304
}
1305
1306
row = next
1307
}
1308
}
1309
}
1310
1311
// MarkRead marks the view's latest messages as read.
1312
func (v *View) MarkRead() {
1313
state := gtkcord.FromContext(v.ctx)
1314
// Grab the last message from the state cache, since we sometimes don't even
1315
// render blocked messages.
1316
msgs, _ := state.Cabinet.Messages(v.ChannelID())
1317
if len(msgs) == 0 {
1318
return
1319
}
1320
1321
state.ReadState.MarkRead(v.ChannelID(), msgs[0].ID)
1322
1323
readState := state.ReadState.ReadState(v.ChannelID())
1324
if readState != nil {
1325
slog.Debug(
1326
"marked messages as read",
1327
"channel", v.ChannelID(),
1328
"last_message", msgs[0].ID,
1329
)
1330
}
1331
}
1332
1333
// IsActive returns true if View is active and visible. This implies that the
1334
// window is focused.
1335
func (v *View) IsActive() bool {
1336
win := app.GTKWindowFromContext(v.ctx)
1337
return win.IsActive() && v.Mapped()
1338
}
1339
1340