diff --git a/internal/config/config.toml b/internal/config/config.toml index b3a138d..e216858 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -180,6 +180,9 @@ yank_id = "i" # Only while typing a message [keybinds.composer] +# Edit your newest editable message loaded in the current channel, with no age limit. +# Only when the composer is empty, with no reply, attachments, or edit in progress. +edit_last = "up" # Paste from clipboard (supports both text and images) paste = "ctrl+v" send = "enter" diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 9d44eae..59c910a 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -92,6 +92,7 @@ type MessagesListKeybinds struct { } type ComposerKeybinds struct { + EditLast Keybind `toml:"edit_last"` Paste Keybind `toml:"paste"` Send Keybind `toml:"send"` Newline Keybind `toml:"newline"` @@ -186,6 +187,7 @@ func defaultMessagesListKeybinds() MessagesListKeybinds { func defaultComposerKeybinds() ComposerKeybinds { return ComposerKeybinds{ + EditLast: desc("edit last"), Paste: desc("paste"), Send: desc("send"), Newline: desc("nl"), diff --git a/internal/ui/chat/composer.go b/internal/ui/chat/composer.go index d8df7fa..71aaf29 100644 --- a/internal/ui/chat/composer.go +++ b/internal/ui/chat/composer.go @@ -179,6 +179,8 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd { case tview.KeyMsg: switch { + case keybind.Matches(msg, c.cfg.Keybinds.Composer.EditLast.Keybind) && c.canEditLastMessage(): + return c.editLastMessage() case keybind.Matches(msg, c.cfg.Keybinds.Composer.Paste.Keybind): return tview.Sequence(pasteImage(), c.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))) case keybind.Matches(msg, c.cfg.Keybinds.Composer.Newline.Keybind): @@ -230,6 +232,31 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd { return c.TextArea.Update(msg) } +func (c *composer) canEditLastMessage() bool { + return !c.Disabled() && !c.edit && c.Text() == "" && + c.sendMessageData.Reference == nil && len(c.sendMessageData.Files) == 0 && + !c.chat.GetVisible(mentionsListLayerName) +} + +func (c *composer) editLastMessage() tview.Cmd { + channel, ok := c.chat.SelectedChannel() + if !ok { + return nil + } + ml := c.chat.messagesList + for i := len(ml.messages) - 1; i >= 0; i-- { + message := &ml.messages[i] + if message.ChannelID != channel.ID || !c.chat.isMe(message.Author.ID) || + !message.ID.IsValid() || message.Content == "" || len(message.MessageSnapshots) > 0 || + (message.Type != discord.DefaultMessage && message.Type != discord.InlinedReplyMessage) { + continue + } + ml.SetCursor(i) + return ml.editSelectedMessage() + } + return nil +} + func (c *composer) toggleReplyMention() { data := c.sendMessageData if data.Reference == nil || data.AllowedMentions == nil || data.AllowedMentions.RepliedUser == nil { @@ -847,6 +874,9 @@ func (c *composer) ShortHelp() []keybind.Keybind { cfg := c.cfg.Keybinds.Composer short := []keybind.Keybind{cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind} + if c.canEditLastMessage() { + short = append(short, cfg.EditLast.Keybind) + } if c.sendMessageData.Reference != nil { short = append(short, cfg.ToggleReplyMention.Keybind) } @@ -874,6 +904,7 @@ func (c *composer) FullHelp() [][]keybind.Keybind { } compose := []keybind.Keybind{cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.Undo.Keybind} + compose = append(compose, cfg.EditLast.Keybind) if c.sendMessageData.Reference != nil { compose = append(compose, cfg.ToggleReplyMention.Keybind) } diff --git a/internal/ui/chat/composer_test.go b/internal/ui/chat/composer_test.go index b1fe431..0d9fd0f 100644 --- a/internal/ui/chat/composer_test.go +++ b/internal/ui/chat/composer_test.go @@ -3,13 +3,83 @@ package chat import ( "maps" "path/filepath" + "slices" "testing" "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/gateway" + "github.com/ayn2op/arikawa/v3/utils/sendpart" "github.com/ayn2op/discordo/internal/config" + "github.com/gdamore/tcell/v3" ) +func TestComposerEditLastMessage(t *testing.T) { + for _, scenario := range []string{"latest", "old", "draft", "multiline", "reply", "attachment", "editing", "disabled", "no own message", "remapped", "unbound"} { + t.Run(scenario, func(t *testing.T) { + cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml")) + if err != nil { + t.Fatal(err) + } + m := NewModel(cfg, "") + if err := m.state.Cabinet.MyselfSet(discord.User{ID: 1}, false); err != nil { + t.Fatal(err) + } + m.SetSelectedChannel(&discord.Channel{ID: 1}) + c := m.composer + c.SetDisabled(false) + messages := []discord.Message{ + {ID: 1, ChannelID: 1, Author: discord.User{ID: 1}, Content: "old message"}, + {ID: 2, ChannelID: 1, Author: discord.User{ID: 1}, Content: "latest message"}, + {ID: 3, ChannelID: 1, Author: discord.User{ID: 2}, Content: "someone else"}, + {ID: 4, ChannelID: 1, Author: discord.User{ID: 1}, Type: discord.ChannelPinnedMessage, Content: "system"}, + {ID: 5, ChannelID: 1, Author: discord.User{ID: 1}}, + } + want := "" + key := tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone) + switch scenario { + case "latest": + want = "latest message" + case "old": + messages = messages[:1] + want = "old message" + case "draft": + c.SetText("draft", true) + case "multiline": + c.SetText("first line\nsecond line", true) + case "reply": + c.sendMessageData.Reference = &discord.MessageReference{MessageID: 3} + case "attachment": + c.sendMessageData.Files = []sendpart.File{{Name: "file"}} + case "editing": + c.edit = true + case "disabled": + c.SetDisabled(true) + case "no own message": + messages = messages[2:3] + case "remapped": + cfg.Keybinds.Composer.EditLast.SetKeys("ctrl+p") + key = tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModNone) + want = "latest message" + case "unbound": + cfg.Keybinds.Composer.EditLast.SetKeys() + } + slices.Reverse(messages) + m.messagesList.setMessages(messages) + before := c.Text() + wasEditing := c.edit + c.Update(key) + if want != "" { + selected, ok := m.messagesList.selectedMessage() + if !c.edit || c.Text() != want || !ok || selected.Content != want { + t.Fatalf("edit = %v, text = %q, selected = %v", c.edit, c.Text(), selected) + } + } else if c.Text() != before || c.edit != wasEditing { + t.Fatalf("composer changed: edit = %v, text = %q", c.edit, c.Text()) + } + }) + } +} + func TestMemberSearchCache(t *testing.T) { cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml")) if err != nil {