Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F114018
D195.1732501415.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
19 KB
Referenced Files
None
Subscribers
None
D195.1732501415.diff
View Options
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.highlightedTextColor : Kirigami.Theme.textColor
+ }
+ 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');
+ }
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Nov 24, 6:23 PM (8 h, 20 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
40000
Default Alt Text
D195.1732501415.diff (19 KB)
Attached To
Mode
D195: Display reactions under the reacted event
Attached
Detach File
Event Timeline
Log In to Comment