Page MenuHomePhorge

D195.1732501411.diff
No OneTemporary

Size
19 KB
Referenced Files
None
Subscribers
None

D195.1732501411.diff

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
@@ -221,6 +221,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
@@ -240,6 +240,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

Mime Type
text/plain
Expires
Sun, Nov 24, 6:23 PM (5 h, 43 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
39999
Default Alt Text
D195.1732501411.diff (19 KB)

Event Timeline