diff --git a/src/contents/ui/Bubble.qml b/src/contents/ui/Bubble.qml
--- a/src/contents/ui/Bubble.qml
+++ b/src/contents/ui/Bubble.qml
@@ -11,6 +11,7 @@
 import org.kde.kirigami 2.13 as Kirigami
 
 import '.' as Kazv
+import 'matrix-helpers.js' as Helpers
 
 ItemDelegate {
   id: upper
@@ -40,15 +41,30 @@
     onAccepted: eventView.reactWith(text)
   }
 
+  function getIsEditable(event) {
+    return event.sender === matrixSdk.userId
+      && event.type === 'm.room.message'
+      && event.content.msgtype === 'm.text';
+  }
+
   property var menu: Menu {
     objectName: 'bubbleContextMenu'
     property list<QtObject> staticItems: [
       Kirigami.Action {
         objectName: 'replyToMenuItem'
         text: l10n.get('event-reply-action')
-        onTriggered: setDraftReplyTo(currentEvent.eventId)
+        onTriggered: setDraftRelation('m.in_reply_to', currentEvent.eventId)
         enabled: event && !event.redacted
       },
+      Kirigami.Action {
+        objectName: 'editMenuItem'
+        text: l10n.get('event-edit-action')
+        onTriggered: {
+          setDraftRelation('m.replace', currentEvent.eventId);
+          replaceDraftRequested(Helpers.getEventBodyForEditing(event));
+        }
+        enabled: event && !event.redacted && !event.isLocalEcho && getIsEditable(event)
+      },
       Kirigami.Action {
         objectName: 'deleteMenuItem'
         text: l10n.get('event-delete')
diff --git a/src/contents/ui/RoomPage.qml b/src/contents/ui/RoomPage.qml
--- a/src/contents/ui/RoomPage.qml
+++ b/src/contents/ui/RoomPage.qml
@@ -23,6 +23,7 @@
   property var lastReceiptableEventId: getLastReceiptableEventId(roomTimeline, roomTimeline.count)
 
   signal mentionUserRequested(string userId)
+  signal replaceDraftRequested(string newDraft)
 
   title: room.name || roomId
 
@@ -135,8 +136,9 @@
     }
   }
 
-  function setDraftReplyTo(eventId) {
-    sendMessageBox.draftReplyTo = eventId;
+  function setDraftRelation(relType, eventId) {
+    sendMessageBox.draftRelType = relType;
+    sendMessageBox.draftRelatedTo = eventId;
   }
 
   property var joinRoomHandler: Kazv.AsyncHandler {
@@ -168,6 +170,10 @@
     sendMessageBox.focusInput();
   }
 
+  onReplaceDraftRequested: (newDraft) => {
+    sendMessageBox.replaceDraft(newDraft);
+  }
+
   function getLastReceiptableEventId(timeline, timelineCount) {
     for (let i = 0; i < timelineCount; ++i) {
       const event = timeline.at(i);
diff --git a/src/contents/ui/SendMessageBox.qml b/src/contents/ui/SendMessageBox.qml
--- a/src/contents/ui/SendMessageBox.qml
+++ b/src/contents/ui/SendMessageBox.qml
@@ -17,24 +17,45 @@
 ColumnLayout {
   id: sendMessageBox
   property var room
-  property var draftReplyTo: ''
+  property var draftRelType: ''
+  property var draftRelatedTo: ''
   property var timeline: room.timeline()
 
+  function getRelationPrompt(draftRelType) {
+    if (draftRelType === 'm.in_reply_to') {
+      return l10n.get('send-message-box-reply-to');
+    } else if (draftRelType === 'm.replace') {
+      return l10n.get('send-message-box-edit');
+    }
+  }
+
+  function getCancelRelationPrompt(draftRelType) {
+    if (draftRelType === 'm.in_reply_to') {
+      return l10n.get('send-message-box-remove-reply-to-action');
+    } else if (draftRelType === 'm.replace') {
+      return l10n.get('send-message-box-remove-replace-action');
+    }
+  }
+
+  function replaceDraft(newDraft) {
+    textArea.changeText(newDraft, /* inhibitTyping = */ true);
+  }
+
   onRoomChanged: {
     textArea.changeText(room.localDraft, true);
   }
 
   ColumnLayout {
-    visible: !!sendMessageBox.draftReplyTo
+    visible: !!sendMessageBox.draftRelatedTo
 
     RowLayout {
       Label {
         Layout.fillWidth: true
-        text: l10n.get('send-message-box-reply-to')
+        text: getRelationPrompt(sendMessageBox.draftRelType)
       }
 
       ToolButton {
-        action: removeReplyToAction
+        action: removeRelatedToAction
         display: AbstractButton.IconOnly
       }
     }
@@ -52,7 +73,7 @@
           left: parent.left
           right: parent.right
         }
-        event: room.messageById(sendMessageBox.draftReplyTo)
+        event: room.messageById(sendMessageBox.draftRelatedTo)
         compactMode: true
       }
     }
@@ -143,11 +164,12 @@
   }
 
   Kirigami.Action {
-    id: removeReplyToAction
+    id: removeRelatedToAction
     iconName: 'window-close-symbolic'
-    text: l10n.get('send-message-box-remove-reply-to-action')
+    text: getCancelRelationPrompt(sendMessageBox.draftRelType)
     onTriggered: {
-      sendMessageBox.draftReplyTo = '';
+      sendMessageBox.draftRelType = '';
+      sendMessageBox.draftRelatedTo = '';
     }
   }
 
@@ -157,9 +179,10 @@
     text: l10n.get("send-message-box-send")
     onTriggered: {
       room.setTyping(false);
-      room.sendTextMessage(textArea.text, draftReplyTo);
+      room.sendTextMessage(textArea.text, draftRelType, draftRelatedTo);
       textArea.changeText("", true);
-      draftReplyTo = '';
+      sendMessageBox.draftRelType = '';
+      sendMessageBox.draftRelatedTo = '';
     }
   }
   Kirigami.Action {
@@ -199,8 +222,9 @@
       stickerPackList: matrixSdk.stickerPackList()
       onSendMessageRequested: eventJson => {
         console.log(JSON.stringify(eventJson));
-        room.sendMessage(eventJson, draftReplyTo);
-        draftReplyTo = '';
+        room.sendMessage(eventJson, draftRelType, draftRelatedTo);
+        draftRelType = '';
+        draftRelatedTo = '';
         stickerPopup.close();
       }
     }
diff --git a/src/js/matrix-helpers.js b/src/js/matrix-helpers.js
--- a/src/js/matrix-helpers.js
+++ b/src/js/matrix-helpers.js
@@ -59,3 +59,41 @@
   }
   return false;
 }
+
+/**
+ * Get the event body for editing.
+ *
+ * This takes into account the replied-to content.
+ * As Matrix spec has nothing for the source content of a message,
+ * we can only use some heuristics to determine what really is
+ * what the user has typed.
+ *
+ * @param event The event to get the body of.
+ * @return The body for editing.
+ */
+function getEventBodyForEditing(event) {
+  if (
+    event.replyingToEventId
+    // Element will put the quotation inside the body and it follows the
+    // following format:
+    // ```
+    // > <@userid:domain.ltd> whatever quoted content
+    // > other lines
+    // > other lines
+    //
+    // actual content
+    // ```
+    && event.content.body.startsWith('> <@')
+  ) {
+    const body = event.content.body;
+    const lines = body.split('\n');
+    const lastLineOfQuote = lines.findIndex((line, index) => {
+      // the current line is a quote but the next line is not
+      return line.startsWith('>') && !(lines[index + 1] || '').startsWith('>');
+    });
+    // Considering the empty line may not exist, strip the empty line from the beginning
+    const firstLineOfContent = lines[lastLineOfQuote + 1] ? lastLineOfQuote + 1 : lastLineOfQuote + 2
+    return lines.slice(firstLineOfContent).join('\n');
+  }
+  return event.content.body;
+}
diff --git a/src/l10n/cmn-Hans/100-ui.ftl b/src/l10n/cmn-Hans/100-ui.ftl
--- a/src/l10n/cmn-Hans/100-ui.ftl
+++ b/src/l10n/cmn-Hans/100-ui.ftl
@@ -96,7 +96,9 @@
 send-message-box-send = 发送
 send-message-box-send-file = 发送文件
 send-message-box-reply-to = 回复给
+send-message-box-edit = 编辑
 send-message-box-remove-reply-to-action = 移除回复关系
+send-message-box-remove-replace-action = 取消编辑
 send-message-box-stickers = 发送贴纸...
 send-message-box-stickers-popup-title = 发送贴纸
 
@@ -158,6 +160,7 @@
 event-react-accept-action = 回应
 event-react-cancel-action = 取消
 event-react-with-prompt = 回应以:
+event-edit-action = 编辑
 event-encrypted = 这条消息已加密
 event-msgtype-fallback-text = 未知的消息类型:{ $msgtype }
 event-fallback-text = 未知事件:{ $type }
diff --git a/src/l10n/en/100-ui.ftl b/src/l10n/en/100-ui.ftl
--- a/src/l10n/en/100-ui.ftl
+++ b/src/l10n/en/100-ui.ftl
@@ -96,7 +96,9 @@
 send-message-box-send = Send
 send-message-box-send-file = Send file
 send-message-box-reply-to = Replying to
+send-message-box-edit = Editing
 send-message-box-remove-reply-to-action = Remove reply-to relationship
+send-message-box-remove-replace-action = Cancel editing
 send-message-box-stickers = Send a sticker...
 send-message-box-stickers-popup-title = Send a sticker
 
@@ -170,6 +172,7 @@
 event-react-accept-action = React
 event-react-cancel-action = Cancel
 event-react-with-prompt = React with:
+event-edit-action = Edit
 event-encrypted = This message is encrypteed
 event-msgtype-fallback-text = Unknown message type: { $msgtype }
 event-fallback-text = Unknown event: { $type }
diff --git a/src/matrix-room.hpp b/src/matrix-room.hpp
--- a/src/matrix-room.hpp
+++ b/src/matrix-room.hpp
@@ -24,7 +24,7 @@
 class MatrixRoomMemberListModel;
 class MatrixEvent;
 
-nlohmann::json makeTextMessageJson(const QString &text, const QString &replyTo, Kazv::Event replyToEvent);
+nlohmann::json makeTextMessageJson(const QString &text, const QString &relType, const QString &relatedTo, Kazv::Event replyToEvent);
 
 class MatrixRoom : public QObject
 {
@@ -68,9 +68,9 @@
 
     Q_INVOKABLE MatrixEvent *messageById(QString eventId) const;
 
-    Q_INVOKABLE void sendMessage(const QJsonObject &eventJson, const QString &replyTo) const;
+    Q_INVOKABLE void sendMessage(const QJsonObject &eventJson, const QString &relType, const QString &relatedTo) const;
 
-    Q_INVOKABLE void sendTextMessage(QString text, QString replyTo) const;
+    Q_INVOKABLE void sendTextMessage(QString text, const QString &relType, QString relatedTo) const;
 
     Q_INVOKABLE void sendMediaFileMessage(QString fileName, QString mimeType,
         qint64 fileSize, QString mxcUri) const;
diff --git a/src/matrix-room.cpp b/src/matrix-room.cpp
--- a/src/matrix-room.cpp
+++ b/src/matrix-room.cpp
@@ -92,20 +92,29 @@
     return new MatrixRoomTimeline(m_room);
 }
 
-static void maybeAddRelations(nlohmann::json &msg, const QString &replyTo)
+static void maybeAddRelations(nlohmann::json &msg, const QString &relType, const QString &relatedTo)
 {
-    if (!replyTo.isEmpty()) {
+    if (relatedTo.isEmpty()) {
+        return;
+    }
+
+    if (relType == "m.in_reply_to") {
         msg["content"]["m.relates_to"] = nlohmann::json{
             {"m.in_reply_to", {
-                {"event_id", replyTo},
+                {"event_id", relatedTo},
             }},
         };
+    } else {
+        msg["content"]["m.relates_to"] = nlohmann::json{
+            {"rel_type", relType},
+            {"event_id", relatedTo},
+        };
     }
 }
 
 static const std::string HTML_FORMAT = "org.matrix.custom.html";
 
-nlohmann::json makeTextMessageJson(const QString &text, const QString &replyTo, Event replyToEvent)
+nlohmann::json makeTextMessageJson(const QString &text, const QString &relType, const QString &relatedTo, Event replyToEvent)
 {
     auto msg = nlohmann::json{
         {"type", "m.room.message"},
@@ -116,7 +125,7 @@
     };
 
     std::string replyToBody;
-    if (!replyTo.isEmpty()) {
+    if (relType == "m.in_reply_to" && !relatedTo.isEmpty()) {
         auto replyToContent = replyToEvent.content().get();
         if (replyToContent.contains("format")
             && replyToContent["format"] == HTML_FORMAT
@@ -150,23 +159,27 @@
         };
     }
 
-    maybeAddRelations(msg, replyTo);
+    if (relType == "m.replace" && !relatedTo.isEmpty()) {
+        msg["content"]["m.new_content"] = msg["content"];
+    }
+
+    maybeAddRelations(msg, relType, relatedTo);
     return msg;
 }
 
-void MatrixRoom::sendMessage(const QJsonObject &eventJson, const QString &replyTo) const
+void MatrixRoom::sendMessage(const QJsonObject &eventJson, const QString &relType, const QString &relatedTo) const
 {
     auto msg = nlohmann::json(eventJson);
-    maybeAddRelations(msg, replyTo);
+    maybeAddRelations(msg, relType, relatedTo);
     m_room.sendMessage(Event(msg));
 }
 
-void MatrixRoom::sendTextMessage(QString text, QString replyTo) const
+void MatrixRoom::sendTextMessage(QString text, const QString &relType, QString relatedTo) const
 {
-    Event replyToEvent = replyTo.isEmpty()
-        ? Event()
-        : m_room.message(lager::make_constant(replyTo.toStdString())).make().get();
-    auto j = makeTextMessageJson(text, replyTo, replyToEvent);
+    Event replyToEvent = (relType == "m.in_reply_to" && !relatedTo.isEmpty())
+        ? m_room.message(lager::make_constant(relatedTo.toStdString())).make().get()
+        : Event();
+    auto j = makeTextMessageJson(text, relType, relatedTo, replyToEvent);
     m_room.sendMessage(Event(j));
 }
 
diff --git a/src/tests/kazv-markdown-test.cpp b/src/tests/kazv-markdown-test.cpp
--- a/src/tests/kazv-markdown-test.cpp
+++ b/src/tests/kazv-markdown-test.cpp
@@ -114,14 +114,14 @@
 void KazvMarkdownTest::testMakeMentionsEvent()
 {
     auto replyToEvent = makeEvent(withEventKV(json::json_pointer("/sender"), "@foo:example.com"));
-    auto e1 = makeTextMessageJson("@foo:example.com mew", "", Kazv::Event());
+    auto e1 = makeTextMessageJson("@foo:example.com mew", "", "", Kazv::Event());
 
     QVERIFY(e1["content"]["m.mentions"]["user_ids"] == json{"@foo:example.com"});
 
-    auto e2 = makeTextMessageJson("@foo:example.com mew", QString::fromStdString(replyToEvent.id()), replyToEvent);
+    auto e2 = makeTextMessageJson("@foo:example.com mew", "", QString::fromStdString(replyToEvent.id()), replyToEvent);
     QVERIFY(e2["content"]["m.mentions"]["user_ids"] == json{"@foo:example.com"});
 
-    auto e3 = makeTextMessageJson("@bar:example.com mew", QString::fromStdString(replyToEvent.id()), replyToEvent);
+    auto e3 = makeTextMessageJson("@bar:example.com mew", "", QString::fromStdString(replyToEvent.id()), replyToEvent);
     QVERIFY((e3["content"]["m.mentions"]["user_ids"] == json{"@bar:example.com", "@foo:example.com"}));
 }
 
diff --git a/src/tests/quick-tests/tst_EventView.qml b/src/tests/quick-tests/tst_EventView.qml
--- a/src/tests/quick-tests/tst_EventView.qml
+++ b/src/tests/quick-tests/tst_EventView.qml
@@ -151,6 +151,18 @@
     formattedTime: '4:06 P.M.',
   })
 
+  property var eventTextBySomeoneElse: ({
+    eventId: '',
+    sender: '@bar:tusooa.xyz',
+    type: 'm.room.message',
+    stateKey: '',
+    content: {
+      msgtype: 'm.text',
+      body: 'some body',
+    },
+    formattedTime: '4:06 P.M.',
+  })
+
   property var ignoredEvent: ({
     eventId: '',
     sender: '@foo:tusooa.xyz',
@@ -204,6 +216,13 @@
     avatarMxcUri: '',
   })
 
+  property var senderOther: ({
+    membership: 'join',
+    userId: '@bar:tusooa.xyz',
+    name: 'bar',
+    avatarMxcUri: '',
+  })
+
   ColumnLayout {
     anchors.fill: parent
     Kazv.EventView {
@@ -307,6 +326,12 @@
       sender: item.sender
       prevEvent: item.ignoredEvent
     }
+
+    Kazv.EventView {
+      id: eventViewTextBySomeoneElse
+      event: item.eventTextBySomeoneElse
+      sender: item.senderOther
+    }
   }
 
   TestCase {
@@ -382,6 +407,7 @@
       const menu = findChild(eventViewText, 'bubbleContextMenu');
       verify(menu);
       tryVerify(() => findChild(menu, 'replyToMenuItem').enabled);
+      tryVerify(() => findChild(menu, 'editMenuItem').enabled);
       tryVerify(() => findChild(menu, 'deleteMenuItem').enabled);
     }
 
@@ -422,5 +448,11 @@
       verify(findChild(eventViewDontCollapseIgnoredEvent, 'senderAvatar').visible);
       verify(!findChild(eventViewDontCollapseIgnoredEvent, 'senderCollapsedPlaceholder').visible);
     }
+
+    function test_eventByOther() {
+      const menu = findChild(eventViewTextBySomeoneElse, 'bubbleContextMenu');
+      verify(menu);
+      tryVerify(() => !findChild(menu, 'editMenuItem').enabled);
+    }
   }
 }
diff --git a/src/tests/quick-tests/tst_MatrixHelpers.qml b/src/tests/quick-tests/tst_MatrixHelpers.qml
new file mode 100644
--- /dev/null
+++ b/src/tests/quick-tests/tst_MatrixHelpers.qml
@@ -0,0 +1,67 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick 2.15
+import QtTest 1.0
+
+import '../../js/matrix-helpers.js' as Helpers
+
+TestCase {
+  id: matrixHelpersTest
+  name: 'MatrixHelpersTest'
+  when: true
+
+  property var eventTestData: [
+    [{
+      replyingToEventId: '',
+      content: {
+        body: '> <@tusooa:tusooa.xyz> some text\n> some text\n\nmew\n\nmew',
+      },
+    }, '> <@tusooa:tusooa.xyz> some text\n> some text\n\nmew\n\nmew'],
+    [{
+      replyingToEventId: '$1',
+      content: {
+        body: '> <@tusooa:tusooa.xyz> some text\n> some text\n\nmewmew',
+      },
+    }, 'mewmew'],
+    [{
+      replyingToEventId: '$1',
+      content: {
+        body: '> <@tusooa:tusooa.xyz> some text\n\nmewmew',
+      },
+    }, 'mewmew'],
+    [{
+      replyingToEventId: '$1',
+      content: {
+        body: '> <@tusooa:tusooa.xyz> some text\nmewmew',
+      },
+    }, 'mewmew'],
+    [{
+      replyingToEventId: '$1',
+      content: {
+        body: 'mewmew',
+      },
+    }, 'mewmew'],
+    [{
+      replyingToEventId: '$1',
+      content: {
+        body: '> <@tusooa:tusooa.xyz> some text\n> some text\n\n> some\nmewmew',
+      },
+    }, '> some\nmewmew'],
+    [{
+      replyingToEventId: '$1',
+      content: {
+        body: '> some\n\nmewmew',
+      },
+    }, '> some\n\nmewmew'],
+  ]
+
+  function test_getEventBodyForEditing() {
+    for (const [event, expected] of eventTestData) {
+      compare(Helpers.getEventBodyForEditing(event), expected);
+    }
+  }
+}