feat(chat): edit latest own message with configurable Up keybind (#837)

This commit is contained in:
ayn2op 2026-09-05 01:56:23 -04:00 • committed by GitHub
parent bdea99d4f9
commit 8390e9773a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 0 deletions

View file

@ -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"

View file

@ -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"),

View file

@ -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)
}

View file

@ -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 {