Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/messages/composer/composer.go
366 views
1
package composer
2
3
import (
4
"context"
5
"fmt"
6
"io"
7
"log/slog"
8
"os"
9
"strings"
10
"time"
11
"unicode"
12
13
"github.com/diamondburned/arikawa/v3/discord"
14
"github.com/diamondburned/arikawa/v3/gateway"
15
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
16
"github.com/diamondburned/gotk4/pkg/core/gioutil"
17
"github.com/diamondburned/gotk4/pkg/gio/v2"
18
"github.com/diamondburned/gotk4/pkg/glib/v2"
19
"github.com/diamondburned/gotk4/pkg/gtk/v4"
20
"github.com/diamondburned/gotk4/pkg/pango"
21
"github.com/diamondburned/gotkit/app"
22
"github.com/diamondburned/gotkit/app/locale"
23
"github.com/diamondburned/gotkit/app/prefs"
24
"github.com/diamondburned/gotkit/gtkutil"
25
"github.com/diamondburned/gotkit/gtkutil/cssutil"
26
"github.com/diamondburned/gotkit/gtkutil/mediautil"
27
"github.com/pkg/errors"
28
"libdb.so/dissent/internal/gtkcord"
29
)
30
31
const (
32
// MessageLengthLimitNonNitro is the maximum number of characters allowed in a message.
33
MessageLengthLimitNonNitro = 2000
34
// MessageLengthLimitNitro is the maximum number of characters allowed in a
35
// message if the user has Nitro.
36
MessageLengthLimitNitro = 4000
37
)
38
39
var showAllEmojis = prefs.NewBool(true, prefs.PropMeta{
40
Name: "Show All Emojis",
41
Section: "Composer",
42
Description: "Show (and autocomplete) all emojis even if the user doesn't have Nitro.",
43
})
44
45
// File contains the filename and a callback to open the file that's called
46
// asynchronously.
47
type File struct {
48
Name string
49
Type string // MIME type
50
Size int64
51
Open func() (io.ReadCloser, error)
52
}
53
54
const spoilerPrefix = "SPOILER_"
55
56
// IsSpoiler returns whether the file is spoilered or not.
57
func (f File) IsSpoiler() bool { return strings.HasPrefix(f.Name, spoilerPrefix) }
58
59
// SetSpoiler sets the spoilered state of the file.
60
func (f *File) SetSpoiler(spoiler bool) {
61
if spoiler {
62
if !f.IsSpoiler() {
63
f.Name = spoilerPrefix + f.Name
64
}
65
} else {
66
if f.IsSpoiler() {
67
f.Name = strings.TrimPrefix(f.Name, spoilerPrefix)
68
}
69
}
70
}
71
72
// SendingMessage is the message created to be sent.
73
type SendingMessage struct {
74
Content string
75
Files []*File
76
ReplyingTo discord.MessageID
77
ReplyMention bool
78
}
79
80
// Controller is the parent Controller for a View.
81
type Controller interface {
82
SendMessage(SendingMessage)
83
StopEditing()
84
StopReplying()
85
EditLastMessage() bool
86
AddReaction(discord.MessageID, discord.APIEmoji)
87
AddToast(*adw.Toast)
88
}
89
90
type typer struct {
91
Markup string
92
UserID discord.UserID
93
Time discord.UnixTimestamp
94
}
95
96
func findTyper(typers []typer, userID discord.UserID) *typer {
97
for i, t := range typers {
98
if t.UserID == userID {
99
return &typers[i]
100
}
101
}
102
return nil
103
}
104
105
const typerTimeout = 10 * time.Second
106
107
type replyingState uint8
108
109
const (
110
notReplying replyingState = iota
111
replyingMention
112
replyingNoMention
113
)
114
115
type View struct {
116
*gtk.Widget
117
118
Input *Input
119
Placeholder *gtk.Label
120
UploadTray *UploadTray
121
EmojiChooser *gtk.EmojiChooser
122
123
ctx context.Context
124
ctrl Controller
125
chID discord.ChannelID
126
127
bigBox *gtk.Box
128
topBox *gtk.Box
129
130
rightBox *gtk.Box
131
emojiButton *gtk.MenuButton
132
sendButton *gtk.Button
133
134
leftBox *gtk.Box
135
uploadButton *gtk.Button
136
137
msgLengthLabel *gtk.Label
138
msgLengthToast *adw.Toast
139
isOverLimit bool
140
141
state struct {
142
id discord.MessageID
143
editing bool
144
replying replyingState
145
}
146
}
147
148
var viewCSS = cssutil.Applier("composer-view", `
149
.composer-view * {
150
/* Fix spacing for certain GTK themes such as stock Adwaita. */
151
min-height: 0;
152
}
153
.composer-left-actions button,
154
.composer-right-actions button {
155
padding-top: 0.5em;
156
padding-bottom: 0.5em;
157
}
158
.composer-left-actions {
159
margin: 4px 0.65em;
160
}
161
.composer-right-actions button.toggle:checked {
162
background-color: alpha(@accent_color, 0.25);
163
color: @accent_color;
164
}
165
.composer-right-actions {
166
margin: 4px 0.65em 4px 0;
167
}
168
.composer-right-actions > *:not(:first-child) {
169
margin-left: 4px;
170
}
171
.composer-placeholder {
172
padding: 12px 2px;
173
color: alpha(@theme_fg_color, 0.65);
174
}
175
.composer-msg-length {
176
font-size: 0.8em;
177
margin: 0.25em 0.5em;
178
opacity: 0;
179
transition: opacity 0.1s;
180
}
181
.composer-msg-length.over-limit {
182
color: @destructive_color;
183
opacity: 1;
184
}
185
`)
186
187
const (
188
sendIcon = "paper-plane-symbolic"
189
emojiIcon = "sentiment-satisfied-symbolic"
190
editIcon = "document-edit-symbolic"
191
stopIcon = "edit-clear-all-symbolic"
192
replyIcon = "mail-reply-sender-symbolic"
193
uploadIcon = "list-add-symbolic"
194
)
195
196
func NewView(ctx context.Context, ctrl Controller, chID discord.ChannelID) *View {
197
v := &View{
198
ctx: ctx,
199
ctrl: ctrl,
200
chID: chID,
201
}
202
203
scroll := gtk.NewScrolledWindow()
204
scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
205
scroll.SetPropagateNaturalHeight(true)
206
scroll.SetMaxContentHeight(1000)
207
208
v.Placeholder = gtk.NewLabel("")
209
v.Placeholder.AddCSSClass("composer-placeholder")
210
v.Placeholder.SetVAlign(gtk.AlignStart)
211
v.Placeholder.SetHAlign(gtk.AlignFill)
212
v.Placeholder.SetXAlign(0)
213
v.Placeholder.SetEllipsize(pango.EllipsizeEnd)
214
215
revealer := gtk.NewRevealer()
216
revealer.SetChild(v.Placeholder)
217
revealer.SetCanTarget(false)
218
revealer.SetRevealChild(true)
219
revealer.SetTransitionType(gtk.RevealerTransitionTypeCrossfade)
220
revealer.SetTransitionDuration(75)
221
222
overlay := gtk.NewOverlay()
223
overlay.AddCSSClass("composer-placeholder-overlay")
224
overlay.SetChild(scroll)
225
overlay.AddOverlay(revealer)
226
overlay.SetClipOverlay(revealer, true)
227
228
middle := gtk.NewBox(gtk.OrientationVertical, 0)
229
middle.Append(overlay)
230
231
v.uploadButton = newActionButton(actionButtonData{
232
Name: "Upload File",
233
Icon: uploadIcon,
234
Func: v.upload,
235
})
236
237
v.leftBox = gtk.NewBox(gtk.OrientationHorizontal, 0)
238
v.leftBox.AddCSSClass("composer-left-actions")
239
v.leftBox.SetVAlign(gtk.AlignCenter)
240
241
v.EmojiChooser = gtk.NewEmojiChooser()
242
v.EmojiChooser.ConnectEmojiPicked(func(emoji string) { v.insertEmoji(emoji) })
243
244
v.emojiButton = gtk.NewMenuButton()
245
v.emojiButton.SetIconName(emojiIcon)
246
v.emojiButton.AddCSSClass("flat")
247
v.emojiButton.SetTooltipText(locale.Get("Choose Emoji"))
248
v.emojiButton.SetPopover(v.EmojiChooser)
249
250
v.sendButton = gtk.NewButtonFromIconName(sendIcon)
251
v.sendButton.AddCSSClass("composer-send")
252
v.sendButton.SetTooltipText(locale.Get("Send Message"))
253
v.sendButton.SetHasFrame(false)
254
v.sendButton.ConnectClicked(v.send)
255
256
v.rightBox = gtk.NewBox(gtk.OrientationHorizontal, 0)
257
v.rightBox.AddCSSClass("composer-right-actions")
258
v.rightBox.SetVAlign(gtk.AlignCenter)
259
260
v.resetAction()
261
262
v.topBox = gtk.NewBox(gtk.OrientationHorizontal, 0)
263
v.topBox.SetVAlign(gtk.AlignEnd)
264
v.topBox.Append(v.leftBox)
265
v.topBox.Append(middle)
266
v.topBox.Append(v.rightBox)
267
268
v.msgLengthLabel = gtk.NewLabel("")
269
v.msgLengthLabel.AddCSSClass("composer-msg-length")
270
v.msgLengthLabel.SetCanTarget(false)
271
v.msgLengthLabel.SetVAlign(gtk.AlignEnd)
272
v.msgLengthLabel.SetHAlign(gtk.AlignEnd)
273
274
topBoxOverlay := gtk.NewOverlay()
275
topBoxOverlay.SetChild(v.topBox)
276
topBoxOverlay.AddOverlay(v.msgLengthLabel)
277
278
v.bigBox = gtk.NewBox(gtk.OrientationVertical, 0)
279
v.bigBox.Append(topBoxOverlay)
280
281
v.Input = NewInput(ctx, inputControllerView{v}, chID)
282
scroll.SetChild(v.Input)
283
284
v.UploadTray = NewUploadTray()
285
v.bigBox.Append(v.UploadTray)
286
287
v.Widget = &v.bigBox.Widget
288
v.SetPlaceholderMarkup("")
289
290
// Show or hide the placeholder when the buffer is empty or not.
291
updatePlaceholderVisibility := func() {
292
start, end := v.Input.Buffer.Bounds()
293
// Reveal if the buffer has 0 length.
294
revealer.SetRevealChild(start.Offset() == end.Offset())
295
}
296
v.Input.Buffer.ConnectChanged(updatePlaceholderVisibility)
297
updatePlaceholderVisibility()
298
299
viewCSS(v)
300
return v
301
}
302
303
// SetPlaceholder sets the composer's placeholder. The default is used if an
304
// empty string is given.
305
func (v *View) SetPlaceholderMarkup(markup string) {
306
if markup == "" {
307
v.ResetPlaceholder()
308
return
309
}
310
311
v.Placeholder.SetMarkup(markup)
312
}
313
314
func (v *View) ResetPlaceholder() {
315
v.Placeholder.SetText("Message " + gtkcord.ChannelNameFromID(v.ctx, v.chID))
316
}
317
318
// actionButton is a button that is used in the composer bar.
319
type actionButton interface {
320
newButton() gtk.Widgetter
321
}
322
323
// existingActionButton is a button that already exists in the composer bar.
324
type existingActionButton struct{ gtk.Widgetter }
325
326
func (a existingActionButton) newButton() gtk.Widgetter { return a }
327
328
// actionButtonData is the data that the action button in the composer bar is
329
// currently doing.
330
type actionButtonData struct {
331
Name locale.Localized
332
Icon string
333
Func func()
334
}
335
336
func newActionButton(a actionButtonData) *gtk.Button {
337
button := gtk.NewButton()
338
button.AddCSSClass("composer-action")
339
button.SetHasFrame(false)
340
button.SetHAlign(gtk.AlignCenter)
341
button.SetSensitive(a.Func != nil)
342
button.SetIconName(a.Icon)
343
button.SetTooltipText(a.Name.String())
344
button.ConnectClicked(func() { a.Func() })
345
346
return button
347
}
348
349
func (a actionButtonData) newButton() gtk.Widgetter {
350
return newActionButton(a)
351
}
352
353
type actions struct {
354
left []actionButton
355
right []actionButton
356
}
357
358
// setAction sets the action of the button in the composer.
359
func (v *View) setActions(actions actions) {
360
gtkutil.RemoveChildren(v.leftBox)
361
gtkutil.RemoveChildren(v.rightBox)
362
363
for _, a := range actions.left {
364
v.leftBox.Append(a.newButton())
365
}
366
for _, a := range actions.right {
367
v.rightBox.Append(a.newButton())
368
}
369
}
370
371
func (v *View) resetAction() {
372
v.setActions(actions{
373
left: []actionButton{existingActionButton{v.uploadButton}},
374
right: []actionButton{existingActionButton{v.emojiButton}, existingActionButton{v.sendButton}},
375
})
376
}
377
378
func (v *View) upload() {
379
d := gtk.NewFileDialog()
380
d.SetTitle(app.FromContext(v.ctx).SuffixedTitle(locale.Get("Upload Files")))
381
d.OpenMultiple(v.ctx, app.GTKWindowFromContext(v.ctx), func(async gio.AsyncResulter) {
382
files, err := d.OpenMultipleFinish(async)
383
if err != nil {
384
return
385
}
386
v.addFiles(files)
387
})
388
}
389
390
func (v *View) addFiles(list gio.ListModeller) {
391
state := gtkcord.FromContext(v.ctx)
392
393
go func() {
394
var i uint
395
for v.ctx.Err() == nil {
396
obj := list.Item(i)
397
if obj == nil {
398
break
399
}
400
401
file := obj.Cast().(gio.Filer)
402
path := file.Path()
403
404
f := &File{
405
Name: file.Basename(),
406
Type: mediautil.FileMIME(v.ctx, file),
407
Size: mediautil.FileSize(v.ctx, file),
408
}
409
410
if path != "" {
411
f.Open = func() (io.ReadCloser, error) {
412
return os.Open(path)
413
}
414
} else {
415
f.Open = func() (io.ReadCloser, error) {
416
r, err := file.Read(v.ctx)
417
if err != nil {
418
return nil, err
419
}
420
return gioutil.Reader(v.ctx, r), nil
421
}
422
}
423
424
maxUploadSize := state.DetermineUploadSize(v.Input.GuildID())
425
glib.IdleAdd(func() {
426
v.UploadTray.SetMaxUploadSize(int64(maxUploadSize))
427
v.UploadTray.AddFile(v.ctx, f)
428
})
429
i++
430
}
431
}()
432
}
433
434
func (v *View) peekContent() (string, []*File) {
435
start, end := v.Input.Buffer.Bounds()
436
text := v.Input.Buffer.Text(start, end, false)
437
files := v.UploadTray.Files()
438
return text, files
439
}
440
441
func (v *View) commitContent() (string, []*File) {
442
start, end := v.Input.Buffer.Bounds()
443
text := v.Input.Buffer.Text(start, end, false)
444
v.Input.Buffer.Delete(start, end)
445
files := v.UploadTray.Clear()
446
return text, files
447
}
448
449
func (v *View) insertEmoji(emoji string) {
450
endIter := v.Input.Buffer.EndIter()
451
v.Input.Buffer.Insert(endIter, emoji)
452
}
453
454
func (v *View) send() {
455
if v.isOverLimit {
456
if v.msgLengthToast == nil {
457
v.msgLengthToast = adw.NewToast(locale.Get("Your message is too long."))
458
v.msgLengthToast.SetTimeout(0)
459
v.msgLengthToast.ConnectDismissed(func() { v.msgLengthToast = nil })
460
461
v.ctrl.AddToast(v.msgLengthToast)
462
}
463
return
464
} else {
465
if v.msgLengthToast != nil {
466
v.msgLengthToast.Dismiss()
467
}
468
}
469
470
if v.state.editing {
471
v.edit()
472
return
473
}
474
475
text, files := v.commitContent()
476
if text == "" && len(files) == 0 {
477
return
478
}
479
480
if len(files) == 0 && textBufferIsReaction(text) {
481
state := gtkcord.FromContext(v.ctx).Online()
482
483
var targetMessageID discord.MessageID
484
if v.state.replying != notReplying {
485
targetMessageID = v.state.id
486
} else {
487
msgs, _ := state.Cabinet.Messages(v.chID)
488
if len(msgs) > 0 {
489
targetMessageID = msgs[0].ID
490
}
491
}
492
493
if targetMessageID.IsValid() {
494
text = strings.TrimPrefix(text, "+")
495
text = strings.TrimSpace(text)
496
text = strings.Trim(text, "<>")
497
498
state := gtkcord.FromContext(v.ctx).Online()
499
emoji := discord.APIEmoji(text)
500
chID := v.chID
501
go func() {
502
if err := state.React(chID, targetMessageID, emoji); err != nil {
503
slog.Error(
504
"cannot react to message",
505
"channel", chID,
506
"message", targetMessageID,
507
"emoji", emoji,
508
"err", err)
509
app.Error(v.ctx, errors.Wrap(err, "cannot react to message"))
510
}
511
}()
512
513
v.ctrl.StopReplying()
514
return
515
}
516
}
517
518
v.ctrl.SendMessage(SendingMessage{
519
Content: text,
520
Files: files,
521
ReplyingTo: v.state.id,
522
ReplyMention: v.state.replying == replyingMention,
523
})
524
525
if v.state.replying != notReplying {
526
v.ctrl.StopReplying()
527
}
528
}
529
530
// textBufferIsReaction returns whether the text buffer is for adding a reaction.
531
// It is true if the input matches something like "+<emoji>".
532
func textBufferIsReaction(buffer string) bool {
533
buffer = strings.TrimRightFunc(buffer, unicode.IsSpace)
534
return strings.HasPrefix(buffer, "+") && !strings.ContainsFunc(buffer, unicode.IsSpace)
535
}
536
537
func (v *View) edit() {
538
editingID := v.state.id
539
text, _ := v.commitContent()
540
541
state := gtkcord.FromContext(v.ctx).Online()
542
543
gtkutil.Async(v.ctx, func() func() {
544
_, err := state.EditMessage(v.chID, editingID, text)
545
if err != nil {
546
err = errors.Wrap(err, "cannot edit message")
547
slog.Error(
548
"cannot edit message",
549
"err", err)
550
551
return func() {
552
toast := adw.NewToast(locale.Get("Cannot edit message"))
553
toast.SetTimeout(0)
554
toast.SetButtonLabel(locale.Get("Logs"))
555
toast.SetActionName("app.logs")
556
v.ctrl.AddToast(toast)
557
}
558
}
559
return nil
560
})
561
562
v.ctrl.StopEditing()
563
}
564
565
// StartEditing starts editing the given message. The message is edited once the
566
// user hits send.
567
func (v *View) StartEditing(msg *discord.Message) {
568
v.restart()
569
570
v.state.id = msg.ID
571
v.state.editing = true
572
573
v.Input.Buffer.SetText(msg.Content)
574
v.SetPlaceholderMarkup(locale.Get("Editing message"))
575
v.AddCSSClass("composer-editing")
576
v.setActions(actions{
577
left: []actionButton{
578
actionButtonData{
579
Name: "Stop Editing",
580
Icon: stopIcon,
581
Func: v.ctrl.StopEditing,
582
},
583
},
584
right: []actionButton{
585
actionButtonData{
586
Name: "Edit",
587
Icon: editIcon,
588
Func: v.edit,
589
},
590
},
591
})
592
}
593
594
// StopEditing stops editing.
595
func (v *View) StopEditing() {
596
if !v.state.editing {
597
return
598
}
599
600
v.state.id = 0
601
v.state.editing = false
602
start, end := v.Input.Buffer.Bounds()
603
v.Input.Buffer.Delete(start, end)
604
605
v.SetPlaceholderMarkup("")
606
v.RemoveCSSClass("composer-editing")
607
v.resetAction()
608
}
609
610
// StartReplyingTo starts replying to the given message. Visually, there is no
611
// difference except for the send button being different.
612
func (v *View) StartReplyingTo(msg *discord.Message) {
613
v.restart()
614
615
v.state.id = msg.ID
616
v.state.replying = replyingMention
617
618
v.AddCSSClass("composer-replying")
619
620
state := gtkcord.FromContext(v.ctx)
621
v.SetPlaceholderMarkup(fmt.Sprintf(
622
"Replying to %s",
623
state.AuthorMarkup(&gateway.MessageCreateEvent{Message: *msg}),
624
))
625
626
mentionToggle := gtk.NewToggleButton()
627
mentionToggle.AddCSSClass("composer-mention-toggle")
628
mentionToggle.SetIconName("online-symbolic")
629
mentionToggle.SetHasFrame(false)
630
mentionToggle.SetActive(true)
631
mentionToggle.SetHAlign(gtk.AlignCenter)
632
mentionToggle.SetVAlign(gtk.AlignCenter)
633
mentionToggle.ConnectToggled(func() {
634
if mentionToggle.Active() {
635
v.state.replying = replyingMention
636
} else {
637
v.state.replying = replyingNoMention
638
}
639
})
640
641
v.setActions(actions{
642
left: []actionButton{
643
existingActionButton{v.uploadButton},
644
},
645
right: []actionButton{
646
existingActionButton{v.emojiButton},
647
existingActionButton{mentionToggle},
648
actionButtonData{
649
Name: "Reply",
650
Icon: replyIcon,
651
Func: v.send,
652
},
653
},
654
})
655
}
656
657
// StopReplying undoes the start call.
658
func (v *View) StopReplying() {
659
if v.state.replying == 0 {
660
return
661
}
662
663
v.state.id = 0
664
v.state.replying = 0
665
666
v.SetPlaceholderMarkup("")
667
v.RemoveCSSClass("composer-replying")
668
v.resetAction()
669
}
670
671
func (v *View) restart() bool {
672
state := v.state
673
674
if v.state.editing {
675
v.ctrl.StopEditing()
676
}
677
if v.state.replying != notReplying {
678
v.ctrl.StopReplying()
679
}
680
681
return state.editing || state.replying != notReplying
682
}
683
684
func (v *View) UpdateMessageLength(length int) {
685
state := gtkcord.FromContext(v.ctx)
686
limit := MessageLengthLimitNonNitro
687
if state.EmojiState.HasNitro() {
688
limit = MessageLengthLimitNitro
689
}
690
691
if length > limit-100 {
692
// Hack to not update the label too often.
693
v.msgLengthLabel.SetText(fmt.Sprintf("%d / %d", length, limit))
694
}
695
696
overLimit := length > limit
697
if overLimit == v.isOverLimit {
698
return
699
}
700
701
v.isOverLimit = overLimit
702
if overLimit {
703
v.msgLengthLabel.AddCSSClass("over-limit")
704
} else {
705
v.msgLengthLabel.RemoveCSSClass("over-limit")
706
}
707
}
708
709
// inputControllerView implements InputController.
710
type inputControllerView struct {
711
*View
712
}
713
714
func (v inputControllerView) Send() { v.send() }
715
func (v inputControllerView) Escape() bool { return v.restart() }
716
717
func (v inputControllerView) EditLastMessage() bool {
718
return v.ctrl.EditLastMessage()
719
}
720
721
func (v inputControllerView) PasteClipboardFile(file *File) {
722
v.UploadTray.AddFile(v.ctx, file)
723
}
724
725
func (v inputControllerView) UpdateMessageLength(length int) {
726
v.View.UpdateMessageLength(length)
727
}
728
729