diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -45,6 +45,8 @@ matrix-room-member-list-model.cpp matrix-event-reader.cpp matrix-event-reader-list-model.cpp + matrix-event-reaction-list-model.cpp + matrix-event-reaction.cpp matrix-event.cpp matrix-event-list.cpp matrix-link.cpp @@ -167,6 +169,7 @@ EventViewCompact.qml EventSourceView.qml EventHistoryView.qml + EventReactions.qml Bubble.qml MediaFileMenu.qml KazvIOMenu.qml 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 @@ -22,6 +22,7 @@ property var menuContent: [] property var shouldDisplayTime: displayTime || (!compactMode && !event.isLocalEcho) property var eventPinned: event.eventId && pinnedEvents.eventIds.includes(event.eventId) + readonly property var eventReactions: event.reactions() topPadding: 0 bottomPadding: 0 @@ -179,6 +180,13 @@ id: children data: upper.children } + + Kazv.EventReactions { + objectName: 'eventReactions' + visible: !compactMode + Layout.fillWidth: true + reactions: upper.eventReactions + } } property var popupAction: Kirigami.Action { diff --git a/src/contents/ui/EventReactions.qml b/src/contents/ui/EventReactions.qml new file mode 100644 --- /dev/null +++ b/src/contents/ui/EventReactions.qml @@ -0,0 +1,88 @@ +/* + * This file is part of kazv. + * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import moe.kazv.mxc.kazv 0.0 as MK + +Flow { + required property var reactions + Repeater { + model: reactions + delegate: RowLayout { + id: reaction + objectName: `reaction${index}` + spacing: -1 + Kirigami.Theme.colorSet: Kirigami.Theme.Button + required property int index + readonly property var item: reactions.at(index) + readonly property string key: item.key + readonly property double commonHeight: Math.max(content.implicitHeight, count.implicitHeight) + readonly property string selfReactEventId: getSelfReactEventId() + + function getSelfReactEventId() { + for (let i = 0; i < item.count; ++i) { + const e = item.at(i); + if (e.sender === matrixSdk.userId) { + return e.eventId; + } + } + return ''; + } + + ItemDelegate { + id: content + objectName: 'reactionContent' + Layout.preferredHeight: reaction.commonHeight + rightInset: 0 + contentItem: Text { + objectName: 'reactionContentText' + textFormat: Qt.RichText + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + // See the comments in event-types/Reaction.qml + text: '<span style="font-family: emoji, sans-serif;">' + MK.KazvUtil.escapeHtml(key) + '</span>' + Accessible.description: l10n.get('event-react-button-description', { key }) + color: content.checked ? Kirigami.Theme.activeTextColor : Kirigami.Theme.highlightedTextColor + } + checked: !!reaction.selfReactEventId + onClicked: reaction.selfReactEventId ? deleteEventRequestedNoConfirm(reaction.selfReactEventId, /* isLocalEcho = */ false) : eventView.reactWith(key) + background: Rectangle { + topLeftRadius: Kirigami.Units.cornerRadius + bottomLeftRadius: Kirigami.Units.cornerRadius + border.color: Kirigami.Theme.highlightColor + color: content.hovered + ? Kirigami.Theme.hoverColor + : content.checked + ? Kirigami.Theme.highlightColor + : 'transparent' + } + } + + ItemDelegate { + id: count + objectName: 'reactionCount' + Layout.preferredHeight: reaction.commonHeight + leftInset: 0 + contentItem: Text { + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + text: `${reaction.item.count}` + } + background: Rectangle { + topRightRadius: Kirigami.Units.cornerRadius + bottomRightRadius: Kirigami.Units.cornerRadius + border.color: Kirigami.Theme.highlightColor + color: count.hovered + ? Kirigami.Theme.hoverColor + : 'transparent' + } + } + } + } +} diff --git a/src/contents/ui/EventView.qml b/src/contents/ui/EventView.qml --- a/src/contents/ui/EventView.qml +++ b/src/contents/ui/EventView.qml @@ -243,7 +243,9 @@ return 'ignore'; case 'm.reaction': - return 'reaction'; + // Only display a reaction if it is a failed local echo + // (otherwise it will be stuck forever and we have no way to delete it) + return e.isFailed ? 'reaction' : 'ignore'; default: return 'unknown'; diff --git a/src/contents/ui/RoomTimelineView.qml b/src/contents/ui/RoomTimelineView.qml --- a/src/contents/ui/RoomTimelineView.qml +++ b/src/contents/ui/RoomTimelineView.qml @@ -23,6 +23,7 @@ signal pinEventRequested(string eventId) signal unpinEventRequested(string eventId) signal deleteEventRequested(eventIdOrTxnId: string, isLocalEcho: bool) + signal deleteEventRequestedNoConfirm(eventIdOrTxnId: string, isLocalEcho: bool) property var pinEvent: Kazv.AsyncHandler { property var eventIds @@ -81,6 +82,20 @@ confirmDeletionPopupComp.createObject(Overlay.overlay, { eventIdOrTxnId, isLocalEcho }).open() } + onDeleteEventRequestedNoConfirm: (eventIdOrTxnId, isLocalEcho) => { + doDeleteEvent(eventIdOrTxnId, isLocalEcho); + } + + function doDeleteEvent(eventIdOrTxnId, isLocalEcho) { + if (isLocalEcho) { + deleteLocalEcho.txnId = eventIdOrTxnId; + deleteLocalEcho.call(); + } else { + deleteEvent.eventId = eventIdOrTxnId; + deleteEvent.call(); + } + } + property var pinEventPopupComp: Component { Kazv.ConfirmationOverlay { objectName: 'pinEventPopup' @@ -135,15 +150,7 @@ message: l10n.get('confirm-deletion-popup-message') confirmActionText: l10n.get('confirm-deletion-popup-confirm-action') cancelActionText: l10n.get('confirm-deletion-popup-cancel-action') - onAccepted: { - if (isLocalEcho) { - deleteLocalEcho.txnId = eventIdOrTxnId; - deleteLocalEcho.call(); - } else { - deleteEvent.eventId = eventIdOrTxnId; - deleteEvent.call(); - } - } + onAccepted: doDeleteEvent(eventIdOrTxnId, isLocalEcho) EventViewWrapper { id: eventView 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 @@ -220,6 +220,7 @@ event-react-accept-action = 回应 event-react-cancel-action = 取消 event-react-with-prompt = 回应以: +event-react-button-description = 回应以 { $key } event-edit-action = 编辑 event-encrypted = 这条消息已加密 event-msgtype-fallback-text = 未知的消息类型:{ $msgtype } 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 @@ -239,6 +239,7 @@ event-react-accept-action = React event-react-cancel-action = Cancel event-react-with-prompt = React with: +event-react-button-description = React with { $key } event-edit-action = Edit event-encrypted = This message is encrypteed event-msgtype-fallback-text = Unknown message type: { $msgtype } diff --git a/src/matrix-event-reaction-list-model.hpp b/src/matrix-event-reaction-list-model.hpp new file mode 100644 --- /dev/null +++ b/src/matrix-event-reaction-list-model.hpp @@ -0,0 +1,33 @@ +/* + * This file is part of kazv. + * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +#pragma once +#include <kazv-defs.hpp> +#include <lager/extra/qt.hpp> +#include <room/room.hpp> +#include "kazv-abstract-list-model.hpp" +Q_MOC_INCLUDE("matrix-event-reaction.hpp") + +class MatrixEventReaction; + +class MatrixEventReactionListModel : public KazvAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + + lager::reader<QMap<QString /* key */, Kazv::EventList>> m_reactions; + lager::reader<QList<QString>> m_reactionKeys; +public: + explicit MatrixEventReactionListModel( + Kazv::Room room, + lager::reader<std::string> parentEventId, + QObject *parent = 0); + + ~MatrixEventReactionListModel() override; + + Q_INVOKABLE MatrixEventReaction *at(int index) const; +}; diff --git a/src/matrix-event-reaction-list-model.cpp b/src/matrix-event-reaction-list-model.cpp new file mode 100644 --- /dev/null +++ b/src/matrix-event-reaction-list-model.cpp @@ -0,0 +1,59 @@ +/* + * This file is part of kazv. + * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +#include "matrix-event-reaction-list-model.hpp" +#include <kazv-defs.hpp> +#include <json-utils.hpp> +#include "matrix-event-reaction.hpp" + +using namespace Kazv; + +MatrixEventReactionListModel::MatrixEventReactionListModel( + Room room, + lager::reader<std::string> parentEventId, + QObject *parent) + : KazvAbstractListModel(parent) + , m_reactions(room.relatedEvents(parentEventId, "m.annotation") + .map([](const auto &events) { + QMap<QString, Kazv::EventList> reactions; + for (const auto &e : events) { + auto rel = e.mRelatesTo().get(); + // This automatically excludes redacted events, + // because redacted events' content is empty. + if (hasAtThat(rel, "key", &json::is_string)) { + auto key = QString::fromStdString(rel["key"].template get<std::string>()); + reactions[key] = reactions[key].push_back(e); + } + } + return reactions; + })) + , m_reactionKeys(m_reactions.map([](const auto &reactions) { + return reactions.keys(); + })) +{ + initCountCursor(m_reactions.map([](const auto &reactions) -> int { + return reactions.size(); + })); +} + +MatrixEventReactionListModel::~MatrixEventReactionListModel() = default; + +MatrixEventReaction *MatrixEventReactionListModel::at(int index) const +{ + return new MatrixEventReaction(lager::with(m_reactionKeys, m_reactions) + .map([index](const auto &keys, const auto &reactions) { + if (index >= 0 && index < keys.size()) { + return reactions.value(keys[index]); + } + return EventList{}; + }), + m_reactionKeys.map([index](const auto &keys) { + if (index >= 0 && index < keys.size()) { + return keys[index]; + } + return QString(); + })); +} diff --git a/src/matrix-event-reaction.hpp b/src/matrix-event-reaction.hpp new file mode 100644 --- /dev/null +++ b/src/matrix-event-reaction.hpp @@ -0,0 +1,25 @@ +/* + * This file is part of kazv. + * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +#pragma once +#include <kazv-defs.hpp> +#include <lager/extra/qt.hpp> +#include <room/room.hpp> +#include "matrix-event-list.hpp" + +class MatrixEventReaction : public MatrixEventList +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + explicit MatrixEventReaction(lager::reader<Kazv::EventList> events, lager::reader<QString> key, QObject *parent = nullptr); + + ~MatrixEventReaction() override; + + LAGER_QT_READER(QString, key); +}; diff --git a/src/matrix-event-reaction.cpp b/src/matrix-event-reaction.cpp new file mode 100644 --- /dev/null +++ b/src/matrix-event-reaction.cpp @@ -0,0 +1,15 @@ +/* + * This file is part of kazv. + * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +#include "matrix-event-reaction.hpp" + +MatrixEventReaction::MatrixEventReaction(lager::reader<Kazv::EventList> events, lager::reader<QString> key, QObject *parent) + : MatrixEventList(events, parent) + , LAGER_QT(key)(key) +{ +} + +MatrixEventReaction::~MatrixEventReaction() = default; diff --git a/src/matrix-event.hpp b/src/matrix-event.hpp --- a/src/matrix-event.hpp +++ b/src/matrix-event.hpp @@ -17,9 +17,11 @@ #include "qt-json.hpp" Q_MOC_INCLUDE("matrix-event-list.hpp") Q_MOC_INCLUDE("matrix-event-reader-list-model.hpp") +Q_MOC_INCLUDE("matrix-event-reaction-list-model.hpp") class MatrixEventReaderListModel; class MatrixEventList; +class MatrixEventReactionListModel; class MatrixEvent : public QObject { @@ -69,4 +71,6 @@ Kazv::Event underlyingEvent() const; Q_INVOKABLE MatrixEventList *history() const; + + Q_INVOKABLE MatrixEventReactionListModel *reactions() const; }; diff --git a/src/matrix-event.cpp b/src/matrix-event.cpp --- a/src/matrix-event.cpp +++ b/src/matrix-event.cpp @@ -8,6 +8,7 @@ #include "matrix-event.hpp" #include "matrix-event-reader-list-model.hpp" +#include "matrix-event-reaction-list-model.hpp" #include "matrix-event-list.hpp" #include "helper.hpp" #include "kazv-log.hpp" @@ -211,3 +212,14 @@ ); })); } + +MatrixEventReactionListModel *MatrixEvent::reactions() const +{ + if (!m_room) { + return nullptr; + } + return new MatrixEventReactionListModel( + m_room.value(), + m_eventIdStd + ); +} diff --git a/src/tests/matrix-event-test.cpp b/src/tests/matrix-event-test.cpp --- a/src/tests/matrix-event-test.cpp +++ b/src/tests/matrix-event-test.cpp @@ -15,6 +15,8 @@ #include <matrix-room.hpp> #include <matrix-event-list.hpp> #include <matrix-event.hpp> +#include <matrix-event-reaction-list-model.hpp> +#include <matrix-event-reaction.hpp> #include "test-model.hpp" #include "test-utils.hpp" @@ -29,6 +31,7 @@ private Q_SLOTS: void testEdits(); void testEncryptedEdits(); + void testReactions(); }; void MatrixEventTest::testEdits() @@ -107,6 +110,60 @@ QCOMPARE(v1->relationType(), QStringLiteral()); } +void MatrixEventTest::testReactions() +{ + auto r = makeRoom(); + auto events = EventList{ + makeEvent(withEventId("$0") | withEventType("m.room.encrypted")).setDecryptedJson(json{ + {"room_id", r.roomId}, + {"type", "m.room.message"}, + {"content", {{"body", "first"}}}, + }, Event::Decrypted), + makeEvent(withEventId("$1") + | withEventType("m.room.encrypted") + | withEventRelationship("m.annotation", "$0") + | withEventKV("/content/m.relates_to/key"_json_pointer, "mew") + ).setDecryptedJson(json{ + {"room_id", r.roomId}, + {"type", "m.reaction"}, + }, Event::Decrypted), + makeEvent(withEventId("$2") + | withEventType("m.room.encrypted") + | withEventRelationship("m.annotation", "$0") + | withEventKV("/content/m.relates_to/key"_json_pointer, "mew") + ).setDecryptedJson(json{ + {"room_id", r.roomId}, + {"type", "m.reaction"}, + }, Event::Decrypted), + makeEvent(withEventId("$3") + | withEventType("m.room.encrypted") + | withEventRelationship("m.annotation", "$0") + | withEventKV("/content/m.relates_to/key"_json_pointer, "meww") + ).setDecryptedJson(json{ + {"room_id", r.roomId}, + {"type", "m.reaction"}, + }, Event::Decrypted), + }; + withRoomTimeline(events)(r); + + auto model = SdkModel{makeClient(withRoom(r))}; + std::unique_ptr<MatrixSdk> sdk{makeTestSdk(model)}; + auto roomList = toUniquePtr(sdk->roomList()); + auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId))); + auto timeline = toUniquePtr(room->timeline()); + + auto event = toUniquePtr(timeline->at(events.size() - 1)); + QCOMPARE(event->eventId(), u"$0"_s); + auto reactions = toUniquePtr(event->reactions()); + QCOMPARE(reactions->count(), 2); + auto r0 = toUniquePtr(reactions->at(0)); + auto r1 = toUniquePtr(reactions->at(1)); + QVERIFY(r0->count() == 1 || r1->count() == 1); + QVERIFY(r0->count() == 2 || r1->count() == 2); + QVERIFY(r0->key() == u"mew"_s || r1->key() == u"mew"_s); + QVERIFY(r0->key() == u"meww"_s || r1->key() == u"meww"_s); +} + QTEST_MAIN(MatrixEventTest) #include "matrix-event-test.moc" diff --git a/src/tests/quick-tests/tst_EventReactions.qml b/src/tests/quick-tests/tst_EventReactions.qml new file mode 100644 --- /dev/null +++ b/src/tests/quick-tests/tst_EventReactions.qml @@ -0,0 +1,115 @@ +/* + * This file is part of kazv. + * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import QtQuick +import QtQuick.Layouts +import QtTest +import moe.kazv.mxc.kazv 0.0 as MK +import org.kde.kirigami 2.13 as Kirigami +import '../../contents/ui' as Kazv +import 'test-helpers' as QmlHelpers + +QmlHelpers.TestItem { + id: item + + function getReaction(i) { + return { + count: 2, + key: `r${i}`, + at(index) { + return index === 0 ? { + sender: `@foo${i}:example.com`, + eventId: `$xxx${i}`, + } : { + sender: `@bar${i}:example.com`, + eventId: `$yyy${i}`, + }; + }, + }; + } + readonly property var reactions: ListModel { + ListElement {} + ListElement {} + + function at(index) { + return item.getReaction(index); + } + } + + readonly property var deleteEventRequestedNoConfirm: mockHelper.noop() + readonly property var eventView: QtObject { + readonly property var reactWith: item.mockHelper.noop() + } + + ColumnLayout { + Kazv.EventReactions { + id: eventReactions + reactions: item.reactions + } + + Kazv.EventView { + id: eventViewReaction + event: ({ + type: 'm.reaction', + content: {}, + isFailed: false, + }) + } + + Kazv.EventView { + id: eventViewReactionFailed + event: ({ + type: 'm.reaction', + content: {}, + isFailed: true, + }) + } + } + + TestCase { + id: eventViewVideoTest + name: 'EventViewVideoTest' + when: windowShown + + function init() { + item.mockHelper.clearAll(); + item.matrixSdk.userId = '@foo0:example.com'; + } + + function test_reactionEvent() { + compare(eventViewReaction.messageType, 'ignore'); + compare(eventViewReactionFailed.messageType, 'reaction'); + } + + function test_reactionsReacted() { + const reaction = findChild(eventReactions, 'reaction0'); + const text = findChild(reaction, 'reactionContentText'); + verify(text.text.includes('r0')); + const content = findChild(reaction, 'reactionContent'); + verify(content.checked); + compare(reaction.selfReactEventId, '$xxx0'); + + mouseClick(content); + compare(item.eventView.reactWith.calledTimes(), 0); + compare(item.deleteEventRequestedNoConfirm.calledTimes(), 1); + compare(item.deleteEventRequestedNoConfirm.lastArgs()[0], '$xxx0'); + } + + function test_reactionsUnreacted() { + const reaction = findChild(eventReactions, 'reaction1'); + const text = findChild(reaction, 'reactionContentText'); + verify(text.text.includes('r1')); + const content = findChild(reaction, 'reactionContent'); + verify(!content.checked); + verify(!reaction.selfReactEventId); + + mouseClick(content); + compare(item.deleteEventRequestedNoConfirm.calledTimes(), 0); + compare(item.eventView.reactWith.calledTimes(), 1); + compare(item.eventView.reactWith.lastArgs()[0], 'r1'); + } + } +}