Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/messages/composer/input.go
366 views
1
package composer
2
3
import (
4
"context"
5
"io"
6
"mime"
7
"strings"
8
"sync"
9
"sync/atomic"
10
"time"
11
12
"github.com/diamondburned/arikawa/v3/discord"
13
"github.com/diamondburned/chatkit/components/autocomplete"
14
"github.com/diamondburned/chatkit/md/mdrender"
15
"github.com/diamondburned/gotk4/pkg/core/gioutil"
16
"github.com/diamondburned/gotk4/pkg/core/glib"
17
"github.com/diamondburned/gotk4/pkg/gdk/v4"
18
"github.com/diamondburned/gotk4/pkg/gio/v2"
19
"github.com/diamondburned/gotk4/pkg/gtk/v4"
20
"github.com/diamondburned/gotkit/app"
21
"github.com/diamondburned/gotkit/app/prefs"
22
"github.com/diamondburned/gotkit/gtkutil"
23
"github.com/diamondburned/gotkit/gtkutil/cssutil"
24
"github.com/diamondburned/gotkit/utils/osutil"
25
"github.com/pkg/errors"
26
"libdb.so/dissent/internal/gtkcord"
27
)
28
29
var persistInput = prefs.NewBool(true, prefs.PropMeta{
30
Name: "Persist Input",
31
Section: "Composer",
32
Description: "Persist the input message between sessions (to disk). " +
33
"If disabled, the input is only persisted for the current session on memory.",
34
})
35
36
// InputController is the parent controller that Input controls.
37
type InputController interface {
38
// Send sends or edits everything in the current message buffer state.
39
Send()
40
// Escape is called when the Escape key is pressed. It is meant to stop any
41
// ongoing action and return true, or false if no action.
42
Escape() bool
43
// EditLastMessage is called when the user wants to edit their last message.
44
// False is returned if no messages can be found.
45
EditLastMessage() bool
46
// PasteClipboardFile is called everytime the user pastes a file from their
47
// clipboard. The file is usually (but not always) an image.
48
PasteClipboardFile(*File)
49
// UpdateMessageLength updates the message length counter.
50
UpdateMessageLength(int)
51
}
52
53
// Input is the text field of the composer.
54
type Input struct {
55
*gtk.TextView
56
Buffer *gtk.TextBuffer
57
ac *autocomplete.Autocompleter
58
59
ctx context.Context
60
ctrl InputController
61
chID discord.ChannelID
62
guildID discord.GuildID
63
}
64
65
var inputCSS = cssutil.Applier("composer-input", `
66
.composer-input,
67
.composer-input text {
68
background-color: inherit;
69
}
70
.composer-input {
71
padding: 12px 2px;
72
margin-top: 0px;
73
}
74
.composer-input .autocomplete-row label {
75
margin: 0;
76
}
77
`)
78
79
var inputWYSIWYG = prefs.NewBool(true, prefs.PropMeta{
80
Name: "Rich Preview",
81
Section: "Composer",
82
Description: "Enable a semi-WYSIWYG feature that decorates the input Markdown text.",
83
})
84
85
// inputStateKey is the app state that stores the last input message.
86
var inputStateKey = app.NewStateKey[string]("input-state")
87
88
var inputStateMemory sync.Map // map[discord.ChannelID]string
89
90
// initializedInput contains a subset of Input.
91
// This stays here for as long as the dynexport cap on Windows is an issue,
92
// which should be fixed by Go 1.24.
93
type initializedInput struct {
94
View *gtk.TextView
95
Buffer *gtk.TextBuffer
96
}
97
98
// NewInput creates a new Input widget.
99
func NewInput(ctx context.Context, ctrl InputController, chID discord.ChannelID) *Input {
100
i := Input{
101
ctx: ctx,
102
ctrl: ctrl,
103
chID: chID,
104
}
105
106
inputState := inputStateKey.Acquire(ctx)
107
input := initializeInput()
108
109
input.Buffer.ConnectChanged(func() {
110
// Do rough WYSIWYG rendering.
111
if inputWYSIWYG.Value() {
112
mdrender.RenderWYSIWYG(ctx, input.Buffer)
113
}
114
115
// Check for message length limit.
116
ctrl.UpdateMessageLength(input.Buffer.CharCount())
117
118
// Handle autocompletion.
119
i.ac.Autocomplete()
120
121
start, end := i.Buffer.Bounds()
122
123
// Persist input.
124
if end.Offset() == 0 {
125
if persistInput.Value() {
126
inputState.Delete(chID.String())
127
} else {
128
inputStateMemory.Delete(chID)
129
}
130
} else {
131
text := i.Buffer.Text(start, end, false)
132
if persistInput.Value() {
133
inputState.Set(chID.String(), text)
134
} else {
135
inputStateMemory.Store(chID, text)
136
}
137
}
138
})
139
140
i.Buffer = input.Buffer
141
142
i.TextView = input.View
143
i.TextView.SetWrapMode(gtk.WrapWordChar)
144
i.TextView.SetAcceptsTab(true)
145
i.TextView.SetHExpand(true)
146
i.TextView.ConnectPasteClipboard(i.readClipboard)
147
i.TextView.SetInputHints(0 |
148
gtk.InputHintEmoji |
149
gtk.InputHintSpellcheck |
150
gtk.InputHintWordCompletion |
151
gtk.InputHintUppercaseSentences,
152
)
153
// textutil.SetTabSize(i.TextView)
154
inputCSS(i)
155
156
i.ac = autocomplete.New(ctx, i.TextView)
157
i.ac.AddSelectedFunc(i.onAutocompleted)
158
i.ac.SetCancelOnChange(false)
159
i.ac.SetMinLength(2)
160
i.ac.SetTimeout(time.Second)
161
162
state := gtkcord.FromContext(ctx)
163
if ch, err := state.Cabinet.Channel(chID); err == nil {
164
i.guildID = ch.GuildID
165
i.ac.Use(
166
NewEmojiCompleter(i.guildID), // :
167
NewMemberCompleter(chID), // @
168
)
169
}
170
171
enterKeyer := gtk.NewEventControllerKey()
172
enterKeyer.ConnectKeyPressed(i.onKey)
173
i.AddController(enterKeyer)
174
175
inputState.Get(chID.String(), func(text string) {
176
i.Buffer.SetText(text)
177
})
178
179
return &i
180
}
181
182
// ChannelID returns the channel ID of the channel that this input is in.
183
func (i *Input) ChannelID() discord.ChannelID {
184
return i.chID
185
}
186
187
// GuildID returns the guild ID of the channel that this input is in.
188
func (i *Input) GuildID() discord.GuildID {
189
return i.guildID
190
}
191
192
func (i *Input) onAutocompleted(row autocomplete.SelectedData) bool {
193
i.Buffer.BeginUserAction()
194
defer i.Buffer.EndUserAction()
195
196
i.Buffer.Delete(row.Bounds[0], row.Bounds[1])
197
198
switch data := row.Data.(type) {
199
case EmojiData:
200
state := gtkcord.FromContext(i.ctx)
201
start, end := i.Buffer.Bounds()
202
203
canUseEmoji := false ||
204
// has Nitro so can use anything
205
state.EmojiState.HasNitro() ||
206
// unicode emoji
207
!data.Emoji.ID.IsValid() ||
208
// same guild, not animated
209
(data.GuildID == i.guildID && !data.Emoji.Animated) ||
210
// adding a reaction, so we can't even use URL
211
textBufferIsReaction(i.Buffer.Text(start, end, false))
212
213
var content string
214
if canUseEmoji {
215
// Use the default emoji format. This string is subject to
216
// server-side validation.
217
content = data.Emoji.String()
218
} else {
219
// Use the emoji URL instead of the emoji code to allow
220
// non-Nitro users to send emojis by sending the image URL.
221
content = gtkcord.InjectSizeUnscaled(data.Emoji.EmojiURL(), gtkcord.LargeEmojiSize)
222
}
223
224
i.Buffer.Insert(row.Bounds[1], content)
225
return true
226
case MemberData:
227
i.Buffer.Insert(row.Bounds[1], discord.Member(data).Mention())
228
return true
229
}
230
231
return false
232
}
233
234
var sendOnEnter = prefs.NewBool(true, prefs.PropMeta{
235
Name: "Send Message on Enter",
236
Section: "Composer",
237
Description: "Send the message when the user hits the Enter key. Disable this for mobile.",
238
})
239
240
func (i *Input) onKey(val, _ uint, state gdk.ModifierType) bool {
241
switch val {
242
case gdk.KEY_Return:
243
if i.ac.Select() {
244
return true
245
}
246
247
// TODO: find a better way to do this. goldmark won't try to
248
// parse an incomplete codeblock (I think), but the changed
249
// signal will be fired after this signal.
250
//
251
// Perhaps we could use the FindChar method to avoid allocating
252
// a new string (twice) on each keypress.
253
head := i.Buffer.StartIter()
254
tail := i.Buffer.IterAtMark(i.Buffer.GetInsert())
255
uinput := i.Buffer.Text(head, tail, false)
256
257
// Check if the number of triple backticks is odd. If it is, then we're
258
// in one.
259
withinCodeblock := strings.Count(uinput, "```")%2 != 0
260
261
// Enter (without holding Shift) sends the message.
262
if sendOnEnter.Value() && !state.Has(gdk.ShiftMask) && !withinCodeblock {
263
i.ctrl.Send()
264
return true
265
}
266
case gdk.KEY_Tab:
267
return i.ac.Select()
268
case gdk.KEY_Escape:
269
return i.ctrl.Escape()
270
case gdk.KEY_Up:
271
if i.ac.MoveUp() {
272
return true
273
}
274
if i.Buffer.CharCount() == 0 {
275
return i.ctrl.EditLastMessage()
276
}
277
case gdk.KEY_Down:
278
return i.ac.MoveDown()
279
}
280
281
return false
282
}
283
284
func (i *Input) readClipboard() {
285
display := gdk.DisplayGetDefault()
286
287
clipboard := display.Clipboard()
288
mimeTypes := clipboard.Formats().MIMETypes()
289
290
// Ignore anything text.
291
for _, mime := range mimeTypes {
292
if mimeIsText(mime) {
293
return
294
}
295
}
296
297
clipboard.ReadAsync(i.ctx, mimeTypes, int(glib.PriorityDefault), func(res gio.AsyncResulter) {
298
typ, streamer, err := clipboard.ReadFinish(res)
299
if err != nil {
300
app.Error(i.ctx, errors.Wrap(err, "failed to read clipboard"))
301
return
302
}
303
304
gtkutil.Async(i.ctx, func() func() {
305
stream := gio.BaseInputStream(streamer)
306
reader := gioutil.Reader(i.ctx, stream)
307
defer reader.Close()
308
309
f, err := osutil.Consume(reader)
310
if err != nil {
311
app.Error(i.ctx, errors.Wrap(err, "cannot clone clipboard"))
312
return nil
313
}
314
315
s, err := f.Stat()
316
if err != nil {
317
app.Error(i.ctx, errors.Wrap(err, "cannot stat clipboard file"))
318
return nil
319
}
320
321
// We're too lazy to do reference-counting, so just forbid Open from
322
// being called more than once.
323
var openedOnce atomic.Bool
324
325
file := &File{
326
Name: "clipboard",
327
Type: typ,
328
Size: s.Size(),
329
Open: func() (io.ReadCloser, error) {
330
if openedOnce.CompareAndSwap(false, true) {
331
return f, nil
332
}
333
return nil, errors.New("Open called more than once on TempFile")
334
},
335
}
336
337
if exts, _ := mime.ExtensionsByType(typ); len(exts) > 0 {
338
file.Name += exts[0]
339
}
340
341
return func() { i.ctrl.PasteClipboardFile(file) }
342
})
343
})
344
}
345
346