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); + } + } +}