Path: blob/main/internal/messages/composer/composer.go
366 views
package composer12import (3"context"4"fmt"5"io"6"log/slog"7"os"8"strings"9"time"10"unicode"1112"github.com/diamondburned/arikawa/v3/discord"13"github.com/diamondburned/arikawa/v3/gateway"14"github.com/diamondburned/gotk4-adwaita/pkg/adw"15"github.com/diamondburned/gotk4/pkg/core/gioutil"16"github.com/diamondburned/gotk4/pkg/gio/v2"17"github.com/diamondburned/gotk4/pkg/glib/v2"18"github.com/diamondburned/gotk4/pkg/gtk/v4"19"github.com/diamondburned/gotk4/pkg/pango"20"github.com/diamondburned/gotkit/app"21"github.com/diamondburned/gotkit/app/locale"22"github.com/diamondburned/gotkit/app/prefs"23"github.com/diamondburned/gotkit/gtkutil"24"github.com/diamondburned/gotkit/gtkutil/cssutil"25"github.com/diamondburned/gotkit/gtkutil/mediautil"26"github.com/pkg/errors"27"libdb.so/dissent/internal/gtkcord"28)2930const (31// MessageLengthLimitNonNitro is the maximum number of characters allowed in a message.32MessageLengthLimitNonNitro = 200033// MessageLengthLimitNitro is the maximum number of characters allowed in a34// message if the user has Nitro.35MessageLengthLimitNitro = 400036)3738var showAllEmojis = prefs.NewBool(true, prefs.PropMeta{39Name: "Show All Emojis",40Section: "Composer",41Description: "Show (and autocomplete) all emojis even if the user doesn't have Nitro.",42})4344// File contains the filename and a callback to open the file that's called45// asynchronously.46type File struct {47Name string48Type string // MIME type49Size int6450Open func() (io.ReadCloser, error)51}5253const spoilerPrefix = "SPOILER_"5455// IsSpoiler returns whether the file is spoilered or not.56func (f File) IsSpoiler() bool { return strings.HasPrefix(f.Name, spoilerPrefix) }5758// SetSpoiler sets the spoilered state of the file.59func (f *File) SetSpoiler(spoiler bool) {60if spoiler {61if !f.IsSpoiler() {62f.Name = spoilerPrefix + f.Name63}64} else {65if f.IsSpoiler() {66f.Name = strings.TrimPrefix(f.Name, spoilerPrefix)67}68}69}7071// SendingMessage is the message created to be sent.72type SendingMessage struct {73Content string74Files []*File75ReplyingTo discord.MessageID76ReplyMention bool77}7879// Controller is the parent Controller for a View.80type Controller interface {81SendMessage(SendingMessage)82StopEditing()83StopReplying()84EditLastMessage() bool85AddReaction(discord.MessageID, discord.APIEmoji)86AddToast(*adw.Toast)87}8889type typer struct {90Markup string91UserID discord.UserID92Time discord.UnixTimestamp93}9495func findTyper(typers []typer, userID discord.UserID) *typer {96for i, t := range typers {97if t.UserID == userID {98return &typers[i]99}100}101return nil102}103104const typerTimeout = 10 * time.Second105106type replyingState uint8107108const (109notReplying replyingState = iota110replyingMention111replyingNoMention112)113114type View struct {115*gtk.Widget116117Input *Input118Placeholder *gtk.Label119UploadTray *UploadTray120EmojiChooser *gtk.EmojiChooser121122ctx context.Context123ctrl Controller124chID discord.ChannelID125126bigBox *gtk.Box127topBox *gtk.Box128129rightBox *gtk.Box130emojiButton *gtk.MenuButton131sendButton *gtk.Button132133leftBox *gtk.Box134uploadButton *gtk.Button135136msgLengthLabel *gtk.Label137msgLengthToast *adw.Toast138isOverLimit bool139140state struct {141id discord.MessageID142editing bool143replying replyingState144}145}146147var viewCSS = cssutil.Applier("composer-view", `148.composer-view * {149/* Fix spacing for certain GTK themes such as stock Adwaita. */150min-height: 0;151}152.composer-left-actions button,153.composer-right-actions button {154padding-top: 0.5em;155padding-bottom: 0.5em;156}157.composer-left-actions {158margin: 4px 0.65em;159}160.composer-right-actions button.toggle:checked {161background-color: alpha(@accent_color, 0.25);162color: @accent_color;163}164.composer-right-actions {165margin: 4px 0.65em 4px 0;166}167.composer-right-actions > *:not(:first-child) {168margin-left: 4px;169}170.composer-placeholder {171padding: 12px 2px;172color: alpha(@theme_fg_color, 0.65);173}174.composer-msg-length {175font-size: 0.8em;176margin: 0.25em 0.5em;177opacity: 0;178transition: opacity 0.1s;179}180.composer-msg-length.over-limit {181color: @destructive_color;182opacity: 1;183}184`)185186const (187sendIcon = "paper-plane-symbolic"188emojiIcon = "sentiment-satisfied-symbolic"189editIcon = "document-edit-symbolic"190stopIcon = "edit-clear-all-symbolic"191replyIcon = "mail-reply-sender-symbolic"192uploadIcon = "list-add-symbolic"193)194195func NewView(ctx context.Context, ctrl Controller, chID discord.ChannelID) *View {196v := &View{197ctx: ctx,198ctrl: ctrl,199chID: chID,200}201202scroll := gtk.NewScrolledWindow()203scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)204scroll.SetPropagateNaturalHeight(true)205scroll.SetMaxContentHeight(1000)206207v.Placeholder = gtk.NewLabel("")208v.Placeholder.AddCSSClass("composer-placeholder")209v.Placeholder.SetVAlign(gtk.AlignStart)210v.Placeholder.SetHAlign(gtk.AlignFill)211v.Placeholder.SetXAlign(0)212v.Placeholder.SetEllipsize(pango.EllipsizeEnd)213214revealer := gtk.NewRevealer()215revealer.SetChild(v.Placeholder)216revealer.SetCanTarget(false)217revealer.SetRevealChild(true)218revealer.SetTransitionType(gtk.RevealerTransitionTypeCrossfade)219revealer.SetTransitionDuration(75)220221overlay := gtk.NewOverlay()222overlay.AddCSSClass("composer-placeholder-overlay")223overlay.SetChild(scroll)224overlay.AddOverlay(revealer)225overlay.SetClipOverlay(revealer, true)226227middle := gtk.NewBox(gtk.OrientationVertical, 0)228middle.Append(overlay)229230v.uploadButton = newActionButton(actionButtonData{231Name: "Upload File",232Icon: uploadIcon,233Func: v.upload,234})235236v.leftBox = gtk.NewBox(gtk.OrientationHorizontal, 0)237v.leftBox.AddCSSClass("composer-left-actions")238v.leftBox.SetVAlign(gtk.AlignCenter)239240v.EmojiChooser = gtk.NewEmojiChooser()241v.EmojiChooser.ConnectEmojiPicked(func(emoji string) { v.insertEmoji(emoji) })242243v.emojiButton = gtk.NewMenuButton()244v.emojiButton.SetIconName(emojiIcon)245v.emojiButton.AddCSSClass("flat")246v.emojiButton.SetTooltipText(locale.Get("Choose Emoji"))247v.emojiButton.SetPopover(v.EmojiChooser)248249v.sendButton = gtk.NewButtonFromIconName(sendIcon)250v.sendButton.AddCSSClass("composer-send")251v.sendButton.SetTooltipText(locale.Get("Send Message"))252v.sendButton.SetHasFrame(false)253v.sendButton.ConnectClicked(v.send)254255v.rightBox = gtk.NewBox(gtk.OrientationHorizontal, 0)256v.rightBox.AddCSSClass("composer-right-actions")257v.rightBox.SetVAlign(gtk.AlignCenter)258259v.resetAction()260261v.topBox = gtk.NewBox(gtk.OrientationHorizontal, 0)262v.topBox.SetVAlign(gtk.AlignEnd)263v.topBox.Append(v.leftBox)264v.topBox.Append(middle)265v.topBox.Append(v.rightBox)266267v.msgLengthLabel = gtk.NewLabel("")268v.msgLengthLabel.AddCSSClass("composer-msg-length")269v.msgLengthLabel.SetCanTarget(false)270v.msgLengthLabel.SetVAlign(gtk.AlignEnd)271v.msgLengthLabel.SetHAlign(gtk.AlignEnd)272273topBoxOverlay := gtk.NewOverlay()274topBoxOverlay.SetChild(v.topBox)275topBoxOverlay.AddOverlay(v.msgLengthLabel)276277v.bigBox = gtk.NewBox(gtk.OrientationVertical, 0)278v.bigBox.Append(topBoxOverlay)279280v.Input = NewInput(ctx, inputControllerView{v}, chID)281scroll.SetChild(v.Input)282283v.UploadTray = NewUploadTray()284v.bigBox.Append(v.UploadTray)285286v.Widget = &v.bigBox.Widget287v.SetPlaceholderMarkup("")288289// Show or hide the placeholder when the buffer is empty or not.290updatePlaceholderVisibility := func() {291start, end := v.Input.Buffer.Bounds()292// Reveal if the buffer has 0 length.293revealer.SetRevealChild(start.Offset() == end.Offset())294}295v.Input.Buffer.ConnectChanged(updatePlaceholderVisibility)296updatePlaceholderVisibility()297298viewCSS(v)299return v300}301302// SetPlaceholder sets the composer's placeholder. The default is used if an303// empty string is given.304func (v *View) SetPlaceholderMarkup(markup string) {305if markup == "" {306v.ResetPlaceholder()307return308}309310v.Placeholder.SetMarkup(markup)311}312313func (v *View) ResetPlaceholder() {314v.Placeholder.SetText("Message " + gtkcord.ChannelNameFromID(v.ctx, v.chID))315}316317// actionButton is a button that is used in the composer bar.318type actionButton interface {319newButton() gtk.Widgetter320}321322// existingActionButton is a button that already exists in the composer bar.323type existingActionButton struct{ gtk.Widgetter }324325func (a existingActionButton) newButton() gtk.Widgetter { return a }326327// actionButtonData is the data that the action button in the composer bar is328// currently doing.329type actionButtonData struct {330Name locale.Localized331Icon string332Func func()333}334335func newActionButton(a actionButtonData) *gtk.Button {336button := gtk.NewButton()337button.AddCSSClass("composer-action")338button.SetHasFrame(false)339button.SetHAlign(gtk.AlignCenter)340button.SetSensitive(a.Func != nil)341button.SetIconName(a.Icon)342button.SetTooltipText(a.Name.String())343button.ConnectClicked(func() { a.Func() })344345return button346}347348func (a actionButtonData) newButton() gtk.Widgetter {349return newActionButton(a)350}351352type actions struct {353left []actionButton354right []actionButton355}356357// setAction sets the action of the button in the composer.358func (v *View) setActions(actions actions) {359gtkutil.RemoveChildren(v.leftBox)360gtkutil.RemoveChildren(v.rightBox)361362for _, a := range actions.left {363v.leftBox.Append(a.newButton())364}365for _, a := range actions.right {366v.rightBox.Append(a.newButton())367}368}369370func (v *View) resetAction() {371v.setActions(actions{372left: []actionButton{existingActionButton{v.uploadButton}},373right: []actionButton{existingActionButton{v.emojiButton}, existingActionButton{v.sendButton}},374})375}376377func (v *View) upload() {378d := gtk.NewFileDialog()379d.SetTitle(app.FromContext(v.ctx).SuffixedTitle(locale.Get("Upload Files")))380d.OpenMultiple(v.ctx, app.GTKWindowFromContext(v.ctx), func(async gio.AsyncResulter) {381files, err := d.OpenMultipleFinish(async)382if err != nil {383return384}385v.addFiles(files)386})387}388389func (v *View) addFiles(list gio.ListModeller) {390state := gtkcord.FromContext(v.ctx)391392go func() {393var i uint394for v.ctx.Err() == nil {395obj := list.Item(i)396if obj == nil {397break398}399400file := obj.Cast().(gio.Filer)401path := file.Path()402403f := &File{404Name: file.Basename(),405Type: mediautil.FileMIME(v.ctx, file),406Size: mediautil.FileSize(v.ctx, file),407}408409if path != "" {410f.Open = func() (io.ReadCloser, error) {411return os.Open(path)412}413} else {414f.Open = func() (io.ReadCloser, error) {415r, err := file.Read(v.ctx)416if err != nil {417return nil, err418}419return gioutil.Reader(v.ctx, r), nil420}421}422423maxUploadSize := state.DetermineUploadSize(v.Input.GuildID())424glib.IdleAdd(func() {425v.UploadTray.SetMaxUploadSize(int64(maxUploadSize))426v.UploadTray.AddFile(v.ctx, f)427})428i++429}430}()431}432433func (v *View) peekContent() (string, []*File) {434start, end := v.Input.Buffer.Bounds()435text := v.Input.Buffer.Text(start, end, false)436files := v.UploadTray.Files()437return text, files438}439440func (v *View) commitContent() (string, []*File) {441start, end := v.Input.Buffer.Bounds()442text := v.Input.Buffer.Text(start, end, false)443v.Input.Buffer.Delete(start, end)444files := v.UploadTray.Clear()445return text, files446}447448func (v *View) insertEmoji(emoji string) {449endIter := v.Input.Buffer.EndIter()450v.Input.Buffer.Insert(endIter, emoji)451}452453func (v *View) send() {454if v.isOverLimit {455if v.msgLengthToast == nil {456v.msgLengthToast = adw.NewToast(locale.Get("Your message is too long."))457v.msgLengthToast.SetTimeout(0)458v.msgLengthToast.ConnectDismissed(func() { v.msgLengthToast = nil })459460v.ctrl.AddToast(v.msgLengthToast)461}462return463} else {464if v.msgLengthToast != nil {465v.msgLengthToast.Dismiss()466}467}468469if v.state.editing {470v.edit()471return472}473474text, files := v.commitContent()475if text == "" && len(files) == 0 {476return477}478479if len(files) == 0 && textBufferIsReaction(text) {480state := gtkcord.FromContext(v.ctx).Online()481482var targetMessageID discord.MessageID483if v.state.replying != notReplying {484targetMessageID = v.state.id485} else {486msgs, _ := state.Cabinet.Messages(v.chID)487if len(msgs) > 0 {488targetMessageID = msgs[0].ID489}490}491492if targetMessageID.IsValid() {493text = strings.TrimPrefix(text, "+")494text = strings.TrimSpace(text)495text = strings.Trim(text, "<>")496497state := gtkcord.FromContext(v.ctx).Online()498emoji := discord.APIEmoji(text)499chID := v.chID500go func() {501if err := state.React(chID, targetMessageID, emoji); err != nil {502slog.Error(503"cannot react to message",504"channel", chID,505"message", targetMessageID,506"emoji", emoji,507"err", err)508app.Error(v.ctx, errors.Wrap(err, "cannot react to message"))509}510}()511512v.ctrl.StopReplying()513return514}515}516517v.ctrl.SendMessage(SendingMessage{518Content: text,519Files: files,520ReplyingTo: v.state.id,521ReplyMention: v.state.replying == replyingMention,522})523524if v.state.replying != notReplying {525v.ctrl.StopReplying()526}527}528529// textBufferIsReaction returns whether the text buffer is for adding a reaction.530// It is true if the input matches something like "+<emoji>".531func textBufferIsReaction(buffer string) bool {532buffer = strings.TrimRightFunc(buffer, unicode.IsSpace)533return strings.HasPrefix(buffer, "+") && !strings.ContainsFunc(buffer, unicode.IsSpace)534}535536func (v *View) edit() {537editingID := v.state.id538text, _ := v.commitContent()539540state := gtkcord.FromContext(v.ctx).Online()541542gtkutil.Async(v.ctx, func() func() {543_, err := state.EditMessage(v.chID, editingID, text)544if err != nil {545err = errors.Wrap(err, "cannot edit message")546slog.Error(547"cannot edit message",548"err", err)549550return func() {551toast := adw.NewToast(locale.Get("Cannot edit message"))552toast.SetTimeout(0)553toast.SetButtonLabel(locale.Get("Logs"))554toast.SetActionName("app.logs")555v.ctrl.AddToast(toast)556}557}558return nil559})560561v.ctrl.StopEditing()562}563564// StartEditing starts editing the given message. The message is edited once the565// user hits send.566func (v *View) StartEditing(msg *discord.Message) {567v.restart()568569v.state.id = msg.ID570v.state.editing = true571572v.Input.Buffer.SetText(msg.Content)573v.SetPlaceholderMarkup(locale.Get("Editing message"))574v.AddCSSClass("composer-editing")575v.setActions(actions{576left: []actionButton{577actionButtonData{578Name: "Stop Editing",579Icon: stopIcon,580Func: v.ctrl.StopEditing,581},582},583right: []actionButton{584actionButtonData{585Name: "Edit",586Icon: editIcon,587Func: v.edit,588},589},590})591}592593// StopEditing stops editing.594func (v *View) StopEditing() {595if !v.state.editing {596return597}598599v.state.id = 0600v.state.editing = false601start, end := v.Input.Buffer.Bounds()602v.Input.Buffer.Delete(start, end)603604v.SetPlaceholderMarkup("")605v.RemoveCSSClass("composer-editing")606v.resetAction()607}608609// StartReplyingTo starts replying to the given message. Visually, there is no610// difference except for the send button being different.611func (v *View) StartReplyingTo(msg *discord.Message) {612v.restart()613614v.state.id = msg.ID615v.state.replying = replyingMention616617v.AddCSSClass("composer-replying")618619state := gtkcord.FromContext(v.ctx)620v.SetPlaceholderMarkup(fmt.Sprintf(621"Replying to %s",622state.AuthorMarkup(&gateway.MessageCreateEvent{Message: *msg}),623))624625mentionToggle := gtk.NewToggleButton()626mentionToggle.AddCSSClass("composer-mention-toggle")627mentionToggle.SetIconName("online-symbolic")628mentionToggle.SetHasFrame(false)629mentionToggle.SetActive(true)630mentionToggle.SetHAlign(gtk.AlignCenter)631mentionToggle.SetVAlign(gtk.AlignCenter)632mentionToggle.ConnectToggled(func() {633if mentionToggle.Active() {634v.state.replying = replyingMention635} else {636v.state.replying = replyingNoMention637}638})639640v.setActions(actions{641left: []actionButton{642existingActionButton{v.uploadButton},643},644right: []actionButton{645existingActionButton{v.emojiButton},646existingActionButton{mentionToggle},647actionButtonData{648Name: "Reply",649Icon: replyIcon,650Func: v.send,651},652},653})654}655656// StopReplying undoes the start call.657func (v *View) StopReplying() {658if v.state.replying == 0 {659return660}661662v.state.id = 0663v.state.replying = 0664665v.SetPlaceholderMarkup("")666v.RemoveCSSClass("composer-replying")667v.resetAction()668}669670func (v *View) restart() bool {671state := v.state672673if v.state.editing {674v.ctrl.StopEditing()675}676if v.state.replying != notReplying {677v.ctrl.StopReplying()678}679680return state.editing || state.replying != notReplying681}682683func (v *View) UpdateMessageLength(length int) {684state := gtkcord.FromContext(v.ctx)685limit := MessageLengthLimitNonNitro686if state.EmojiState.HasNitro() {687limit = MessageLengthLimitNitro688}689690if length > limit-100 {691// Hack to not update the label too often.692v.msgLengthLabel.SetText(fmt.Sprintf("%d / %d", length, limit))693}694695overLimit := length > limit696if overLimit == v.isOverLimit {697return698}699700v.isOverLimit = overLimit701if overLimit {702v.msgLengthLabel.AddCSSClass("over-limit")703} else {704v.msgLengthLabel.RemoveCSSClass("over-limit")705}706}707708// inputControllerView implements InputController.709type inputControllerView struct {710*View711}712713func (v inputControllerView) Send() { v.send() }714func (v inputControllerView) Escape() bool { return v.restart() }715716func (v inputControllerView) EditLastMessage() bool {717return v.ctrl.EditLastMessage()718}719720func (v inputControllerView) PasteClipboardFile(file *File) {721v.UploadTray.AddFile(v.ctx, file)722}723724func (v inputControllerView) UpdateMessageLength(length int) {725v.View.UpdateMessageLength(length)726}727728729