Path: blob/main/internal/messages/composer/input.go
366 views
package composer12import (3"context"4"io"5"mime"6"strings"7"sync"8"sync/atomic"9"time"1011"github.com/diamondburned/arikawa/v3/discord"12"github.com/diamondburned/chatkit/components/autocomplete"13"github.com/diamondburned/chatkit/md/mdrender"14"github.com/diamondburned/gotk4/pkg/core/gioutil"15"github.com/diamondburned/gotk4/pkg/core/glib"16"github.com/diamondburned/gotk4/pkg/gdk/v4"17"github.com/diamondburned/gotk4/pkg/gio/v2"18"github.com/diamondburned/gotk4/pkg/gtk/v4"19"github.com/diamondburned/gotkit/app"20"github.com/diamondburned/gotkit/app/prefs"21"github.com/diamondburned/gotkit/gtkutil"22"github.com/diamondburned/gotkit/gtkutil/cssutil"23"github.com/diamondburned/gotkit/utils/osutil"24"github.com/pkg/errors"25"libdb.so/dissent/internal/gtkcord"26)2728var persistInput = prefs.NewBool(true, prefs.PropMeta{29Name: "Persist Input",30Section: "Composer",31Description: "Persist the input message between sessions (to disk). " +32"If disabled, the input is only persisted for the current session on memory.",33})3435// InputController is the parent controller that Input controls.36type InputController interface {37// Send sends or edits everything in the current message buffer state.38Send()39// Escape is called when the Escape key is pressed. It is meant to stop any40// ongoing action and return true, or false if no action.41Escape() bool42// EditLastMessage is called when the user wants to edit their last message.43// False is returned if no messages can be found.44EditLastMessage() bool45// PasteClipboardFile is called everytime the user pastes a file from their46// clipboard. The file is usually (but not always) an image.47PasteClipboardFile(*File)48// UpdateMessageLength updates the message length counter.49UpdateMessageLength(int)50}5152// Input is the text field of the composer.53type Input struct {54*gtk.TextView55Buffer *gtk.TextBuffer56ac *autocomplete.Autocompleter5758ctx context.Context59ctrl InputController60chID discord.ChannelID61guildID discord.GuildID62}6364var inputCSS = cssutil.Applier("composer-input", `65.composer-input,66.composer-input text {67background-color: inherit;68}69.composer-input {70padding: 12px 2px;71margin-top: 0px;72}73.composer-input .autocomplete-row label {74margin: 0;75}76`)7778var inputWYSIWYG = prefs.NewBool(true, prefs.PropMeta{79Name: "Rich Preview",80Section: "Composer",81Description: "Enable a semi-WYSIWYG feature that decorates the input Markdown text.",82})8384// inputStateKey is the app state that stores the last input message.85var inputStateKey = app.NewStateKey[string]("input-state")8687var inputStateMemory sync.Map // map[discord.ChannelID]string8889// initializedInput contains a subset of Input.90// This stays here for as long as the dynexport cap on Windows is an issue,91// which should be fixed by Go 1.24.92type initializedInput struct {93View *gtk.TextView94Buffer *gtk.TextBuffer95}9697// NewInput creates a new Input widget.98func NewInput(ctx context.Context, ctrl InputController, chID discord.ChannelID) *Input {99i := Input{100ctx: ctx,101ctrl: ctrl,102chID: chID,103}104105inputState := inputStateKey.Acquire(ctx)106input := initializeInput()107108input.Buffer.ConnectChanged(func() {109// Do rough WYSIWYG rendering.110if inputWYSIWYG.Value() {111mdrender.RenderWYSIWYG(ctx, input.Buffer)112}113114// Check for message length limit.115ctrl.UpdateMessageLength(input.Buffer.CharCount())116117// Handle autocompletion.118i.ac.Autocomplete()119120start, end := i.Buffer.Bounds()121122// Persist input.123if end.Offset() == 0 {124if persistInput.Value() {125inputState.Delete(chID.String())126} else {127inputStateMemory.Delete(chID)128}129} else {130text := i.Buffer.Text(start, end, false)131if persistInput.Value() {132inputState.Set(chID.String(), text)133} else {134inputStateMemory.Store(chID, text)135}136}137})138139i.Buffer = input.Buffer140141i.TextView = input.View142i.TextView.SetWrapMode(gtk.WrapWordChar)143i.TextView.SetAcceptsTab(true)144i.TextView.SetHExpand(true)145i.TextView.ConnectPasteClipboard(i.readClipboard)146i.TextView.SetInputHints(0 |147gtk.InputHintEmoji |148gtk.InputHintSpellcheck |149gtk.InputHintWordCompletion |150gtk.InputHintUppercaseSentences,151)152// textutil.SetTabSize(i.TextView)153inputCSS(i)154155i.ac = autocomplete.New(ctx, i.TextView)156i.ac.AddSelectedFunc(i.onAutocompleted)157i.ac.SetCancelOnChange(false)158i.ac.SetMinLength(2)159i.ac.SetTimeout(time.Second)160161state := gtkcord.FromContext(ctx)162if ch, err := state.Cabinet.Channel(chID); err == nil {163i.guildID = ch.GuildID164i.ac.Use(165NewEmojiCompleter(i.guildID), // :166NewMemberCompleter(chID), // @167)168}169170enterKeyer := gtk.NewEventControllerKey()171enterKeyer.ConnectKeyPressed(i.onKey)172i.AddController(enterKeyer)173174inputState.Get(chID.String(), func(text string) {175i.Buffer.SetText(text)176})177178return &i179}180181// ChannelID returns the channel ID of the channel that this input is in.182func (i *Input) ChannelID() discord.ChannelID {183return i.chID184}185186// GuildID returns the guild ID of the channel that this input is in.187func (i *Input) GuildID() discord.GuildID {188return i.guildID189}190191func (i *Input) onAutocompleted(row autocomplete.SelectedData) bool {192i.Buffer.BeginUserAction()193defer i.Buffer.EndUserAction()194195i.Buffer.Delete(row.Bounds[0], row.Bounds[1])196197switch data := row.Data.(type) {198case EmojiData:199state := gtkcord.FromContext(i.ctx)200start, end := i.Buffer.Bounds()201202canUseEmoji := false ||203// has Nitro so can use anything204state.EmojiState.HasNitro() ||205// unicode emoji206!data.Emoji.ID.IsValid() ||207// same guild, not animated208(data.GuildID == i.guildID && !data.Emoji.Animated) ||209// adding a reaction, so we can't even use URL210textBufferIsReaction(i.Buffer.Text(start, end, false))211212var content string213if canUseEmoji {214// Use the default emoji format. This string is subject to215// server-side validation.216content = data.Emoji.String()217} else {218// Use the emoji URL instead of the emoji code to allow219// non-Nitro users to send emojis by sending the image URL.220content = gtkcord.InjectSizeUnscaled(data.Emoji.EmojiURL(), gtkcord.LargeEmojiSize)221}222223i.Buffer.Insert(row.Bounds[1], content)224return true225case MemberData:226i.Buffer.Insert(row.Bounds[1], discord.Member(data).Mention())227return true228}229230return false231}232233var sendOnEnter = prefs.NewBool(true, prefs.PropMeta{234Name: "Send Message on Enter",235Section: "Composer",236Description: "Send the message when the user hits the Enter key. Disable this for mobile.",237})238239func (i *Input) onKey(val, _ uint, state gdk.ModifierType) bool {240switch val {241case gdk.KEY_Return:242if i.ac.Select() {243return true244}245246// TODO: find a better way to do this. goldmark won't try to247// parse an incomplete codeblock (I think), but the changed248// signal will be fired after this signal.249//250// Perhaps we could use the FindChar method to avoid allocating251// a new string (twice) on each keypress.252head := i.Buffer.StartIter()253tail := i.Buffer.IterAtMark(i.Buffer.GetInsert())254uinput := i.Buffer.Text(head, tail, false)255256// Check if the number of triple backticks is odd. If it is, then we're257// in one.258withinCodeblock := strings.Count(uinput, "```")%2 != 0259260// Enter (without holding Shift) sends the message.261if sendOnEnter.Value() && !state.Has(gdk.ShiftMask) && !withinCodeblock {262i.ctrl.Send()263return true264}265case gdk.KEY_Tab:266return i.ac.Select()267case gdk.KEY_Escape:268return i.ctrl.Escape()269case gdk.KEY_Up:270if i.ac.MoveUp() {271return true272}273if i.Buffer.CharCount() == 0 {274return i.ctrl.EditLastMessage()275}276case gdk.KEY_Down:277return i.ac.MoveDown()278}279280return false281}282283func (i *Input) readClipboard() {284display := gdk.DisplayGetDefault()285286clipboard := display.Clipboard()287mimeTypes := clipboard.Formats().MIMETypes()288289// Ignore anything text.290for _, mime := range mimeTypes {291if mimeIsText(mime) {292return293}294}295296clipboard.ReadAsync(i.ctx, mimeTypes, int(glib.PriorityDefault), func(res gio.AsyncResulter) {297typ, streamer, err := clipboard.ReadFinish(res)298if err != nil {299app.Error(i.ctx, errors.Wrap(err, "failed to read clipboard"))300return301}302303gtkutil.Async(i.ctx, func() func() {304stream := gio.BaseInputStream(streamer)305reader := gioutil.Reader(i.ctx, stream)306defer reader.Close()307308f, err := osutil.Consume(reader)309if err != nil {310app.Error(i.ctx, errors.Wrap(err, "cannot clone clipboard"))311return nil312}313314s, err := f.Stat()315if err != nil {316app.Error(i.ctx, errors.Wrap(err, "cannot stat clipboard file"))317return nil318}319320// We're too lazy to do reference-counting, so just forbid Open from321// being called more than once.322var openedOnce atomic.Bool323324file := &File{325Name: "clipboard",326Type: typ,327Size: s.Size(),328Open: func() (io.ReadCloser, error) {329if openedOnce.CompareAndSwap(false, true) {330return f, nil331}332return nil, errors.New("Open called more than once on TempFile")333},334}335336if exts, _ := mime.ExtensionsByType(typ); len(exts) > 0 {337file.Name += exts[0]338}339340return func() { i.ctrl.PasteClipboardFile(file) }341})342})343}344345346