Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
61 KB
Referenced Files
None
Subscribers
None
diff --git a/src/contents/ui/RoomTimelineView.qml b/src/contents/ui/RoomTimelineView.qml
index 16a10fb..3036535 100644
--- a/src/contents/ui/RoomTimelineView.qml
+++ b/src/contents/ui/RoomTimelineView.qml
@@ -1,203 +1,206 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import org.kde.kirigami as Kirigami
import '.' as Kazv
ListView {
id: roomTimelineView
objectName: 'roomTimelineView'
Accessible.role: Accessible.List
required property var room
required property var timeline
property var loadEventRequests: ({})
property string selectedEventId
signal pinEventRequested(string eventId)
signal unpinEventRequested(string eventId)
signal deleteEventRequested(eventIdOrTxnId: string, isLocalEcho: bool)
signal deleteEventRequestedNoConfirm(eventIdOrTxnId: string, isLocalEcho: bool)
signal loadEventRequested(string eventId)
property var pinEvent: Kazv.AsyncHandler {
property var eventIds
trigger: () => room.pinEvents(eventIds)
onResolved: (success, data) => {
if (success) {
showPassiveNotification(l10n.get('event-pin-success-prompt'));
} else {
showPassiveNotification(l10n.get('event-pin-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
}
}
}
property var unpinEvent: Kazv.AsyncHandler {
property var eventIds
trigger: () => room.unpinEvents(eventIds)
onResolved: (success, data) => {
if (success) {
showPassiveNotification(l10n.get('event-unpin-success-prompt'));
} else {
showPassiveNotification(l10n.get('event-unpin-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
}
}
}
property var deleteEvent: Kazv.AsyncHandler {
property var eventId
property var reason
trigger: () => room.redactEvent(eventId, reason)
onResolved: (success, data) => {
if (!success) {
showPassiveNotification(l10n.get('event-delete-failed', { errorMsg: data.error, errorCode: data.errorCode }));
}
}
}
property var deleteLocalEcho: Kazv.AsyncHandler {
property var txnId
trigger: () => room.removeLocalEcho(txnId)
onResolved: (success, data) => {
if (!success) {
showPassiveNotification(l10n.get('event-delete-failed', { errorMsg: data.error, errorCode: data.errorCode }));
}
}
}
onPinEventRequested: (eventId) => {
pinEventPopupComp.createObject(Overlay.overlay, { eventId }).open()
}
onUnpinEventRequested: (eventId) => {
unpinEventPopupComp.createObject(Overlay.overlay, { eventId }).open()
}
onDeleteEventRequested: (eventIdOrTxnId, isLocalEcho) => {
confirmDeletionPopupComp.createObject(Overlay.overlay, { eventIdOrTxnId, isLocalEcho }).open()
}
onDeleteEventRequestedNoConfirm: (eventIdOrTxnId, isLocalEcho) => {
doDeleteEvent(eventIdOrTxnId, isLocalEcho);
}
onLoadEventRequested: (eventId) => {
if (!(eventId in loadEventRequests)) {
loadEventRequests[eventId] = matrixSession.loadEvent(room.roomId, eventId);
loadEventRequests[eventId].resolved.connect((isSuccess) => {
if (isSuccess) {
delete loadEventRequests[eventId];
} else {
loadEventRequests[eventId] = undefined;
}
});
}
}
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'
property var eventId
shouldSelfDestroy: true
title: l10n.get('event-pin-confirmation-title')
message: l10n.get('event-pin-confirmation')
confirmActionText: l10n.get('event-pin-confirm-action')
cancelActionText: l10n.get('event-pin-cancel-action')
onAccepted: {
pinEvent.eventIds = [eventId];
pinEvent.call();
}
EventViewWrapper {
Layout.fillWidth: true
event: room.messageById(eventId)
compactMode: true
}
}
}
property var unpinEventPopupComp: Component {
Kazv.ConfirmationOverlay {
objectName: 'unpinEventPopup'
property var eventId
shouldSelfDestroy: true
title: l10n.get('event-unpin-confirmation-title')
message: l10n.get('event-unpin-confirmation')
confirmActionText: l10n.get('event-unpin-confirm-action')
cancelActionText: l10n.get('event-unpin-cancel-action')
onAccepted: {
unpinEvent.eventIds = [eventId];
unpinEvent.call();
}
EventViewWrapper {
Layout.fillWidth: true
event: room.messageById(eventId)
compactMode: true
}
}
}
property var confirmDeletionPopupComp: Component {
Kazv.ConfirmationOverlay {
objectName: 'confirmDeletionPopup'
property string eventIdOrTxnId
property bool isLocalEcho
shouldSelfDestroy: true
title: l10n.get('confirm-deletion-popup-title')
message: l10n.get('confirm-deletion-popup-message')
confirmActionText: l10n.get('confirm-deletion-popup-confirm-action')
cancelActionText: l10n.get('confirm-deletion-popup-cancel-action')
onAccepted: doDeleteEvent(eventIdOrTxnId, isLocalEcho)
EventViewWrapper {
id: eventView
Layout.fillWidth: true
event: isLocalEcho ? room.localEchoById(eventIdOrTxnId) : room.messageById(eventIdOrTxnId)
compactMode: true
}
}
}
spacing: 0
model: timeline
currentIndex: -1
verticalLayoutDirection: ListView.BottomToTop
delegate: EventViewWrapper {
property var eventListView: ListView.view
- objectName: `event-${event.eventId || event.txnId}`
+ required property int index
+ required property string eventOrTxnId
+ required property bool isLocalEcho
+ objectName: `event-${eventOrTxnId}`
prevEvent: index < timeline.count - 1 ? timeline.at(index + 1) : undefined
- event: timeline.at(index)
+ event: timeline.byId(eventOrTxnId, isLocalEcho)
width: ListView.view.width
- isSelected: roomTimelineView.selectedEventId && event.eventId === roomTimelineView.selectedEventId
+ isSelected: roomTimelineView.selectedEventId && eventOrTxnId === roomTimelineView.selectedEventId
Accessible.role: Accessible.ListItem
}
function goToEvent(eventId) {
selectedEventId = eventId;
const index = timeline.indexOfEvent(eventId);
positionViewAtIndex(index, ListView.Center);
}
}
diff --git a/src/list-diff.hpp b/src/list-diff.hpp
new file mode 100644
index 0000000..974d509
--- /dev/null
+++ b/src/list-diff.hpp
@@ -0,0 +1,96 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+#include <kazv-defs.hpp>
+#include <immer/flex_vector.hpp>
+#include <vector>
+#include <unordered_map>
+#include <type_traits>
+#include <variant>
+#include <ranges>
+
+struct ListMove
+{
+ std::size_t from;
+ std::size_t to;
+};
+
+template<class IdT>
+struct ListAdd
+{
+ std::size_t index;
+ IdT id;
+};
+
+struct ListRemove
+{
+ std::size_t index;
+};
+
+template<class IdT>
+using ListChange = std::variant<ListAdd<IdT>, ListRemove, ListMove>;
+
+template<std::ranges::range Range, std::ranges::range Range2, class Func, class IdT = std::invoke_result_t<Func, std::ranges::range_value_t<Range>>>
+std::vector<ListChange<IdT>> listDiff(Range &&last, Range2 &&next, Func &&idFunc)
+{
+ auto makeMap = [idFunc](auto &&r) {
+ std::unordered_map<IdT, std::size_t> res;
+ auto end = std::ranges::end(r);
+ for (auto i = std::ranges::begin(r); i != end; ++i) {
+ res[idFunc(*i)] = i.index();
+ }
+ return res;
+ };
+ std::unordered_map<IdT, std::size_t> lastMap = makeMap(last);
+ std::unordered_map<IdT, std::size_t> nextMap = makeMap(next);
+ std::vector<ListChange<IdT>> res;
+
+ std::size_t oldIndex = 0;
+ std::size_t workIndex = 0;
+ std::size_t removed = 0;
+ while (oldIndex < last.size()) {
+ auto lastId = idFunc(last[oldIndex]);
+ lastMap[lastId] -= removed;
+ if (nextMap.count(lastId) == 0) { // removed
+ res.push_back(ListRemove{workIndex});
+ lastMap.erase(lastId);
+ ++removed;
+ ++oldIndex;
+ } else {
+ ++oldIndex;
+ ++workIndex;
+ }
+ }
+
+ std::size_t changes = 0;
+ workIndex = 0;
+
+ // Change OLD into NEW. Let the process be WORK.
+ // After iteration i, WORK[0...i] is in place.
+ // Only move things to the front
+ while (workIndex < next.size()) {
+ // N = NEW[i]
+ auto nextId = idFunc(next[workIndex]);
+ if (lastMap.count(nextId) == 0) { // added
+ res.push_back(ListAdd<IdT>{workIndex, nextId});
+ ++changes;
+ } else {
+ // Calculate where N is in WORK
+ // if there are A inserts and B moves before N
+ // each insert or move will increase index of N in work by 1
+ auto oldIndex = lastMap[nextId] + changes;
+ if (oldIndex == workIndex) { // not changed
+ } else { // moved from a later entry to here
+ res.push_back(ListMove{oldIndex, workIndex});
+ ++changes;
+ }
+ }
+ ++workIndex;
+ }
+
+ return res;
+}
diff --git a/src/matrix-room-timeline-p.hpp b/src/matrix-room-timeline-p.hpp
new file mode 100644
index 0000000..02530b3
--- /dev/null
+++ b/src/matrix-room-timeline-p.hpp
@@ -0,0 +1,71 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <kazv-defs.hpp>
+#include "matrix-room-timeline.hpp"
+#include <functional>
+#include <ranges>
+
+struct TimelineItemDesc
+{
+ std::string id;
+ bool isLocalEcho;
+ friend bool operator==(const TimelineItemDesc &a, const TimelineItemDesc &b) = default;
+};
+
+struct EnhancedTotalItems : public TotalItems
+{
+ TimelineItemDesc at(std::size_t index) const;
+ TimelineItemDesc operator[](std::size_t index) const { return at(index); }
+ std::size_t size() const;
+ void eraseInPlace(std::size_t index);
+ void insertInPlace(std::size_t index, const TimelineItemDesc &item);
+
+ struct Iterator
+ {
+ using difference_type = std::ptrdiff_t;
+ using value_type = TimelineItemDesc;
+ using reference = TimelineItemDesc;
+ using iterator_category = std::random_access_iterator_tag;
+ const EnhancedTotalItems *m_range;
+ std::size_t m_index;
+
+ std::size_t index() const { return m_index; }
+ TimelineItemDesc operator*() const
+ {
+ return m_range->at(m_index);
+ }
+
+ Iterator &operator++()
+ {
+ ++m_index;
+ return *this;
+ }
+ void operator++(int) { ++*this; }
+ friend bool operator==(const Iterator &a, const Iterator &b) = default;
+ friend bool operator!=(const Iterator &a, const Iterator &b) = default;
+ };
+
+ Iterator begin() const
+ {
+ return Iterator{this, 0};
+ }
+
+ Iterator end() const
+ {
+ return Iterator{this, size()};
+ }
+};
+
+template<>
+struct std::hash<TimelineItemDesc>
+{
+ std::size_t operator()(const TimelineItemDesc &i) const noexcept
+ {
+ std::size_t res = std::hash<std::string>()(i.id) + std::hash<bool>()(i.isLocalEcho);
+ return res;
+ }
+};
diff --git a/src/matrix-room-timeline.cpp b/src/matrix-room-timeline.cpp
index a13e769..9a589c5 100644
--- a/src/matrix-room-timeline.cpp
+++ b/src/matrix-room-timeline.cpp
@@ -1,112 +1,275 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <kazv-defs.hpp>
#include <cmath>
#include <lager/lenses/optional.hpp>
#include <QTimer>
#include <QJsonValue>
#include <cursorutil.hpp>
-#include "matrix-room-timeline.hpp"
+#include "matrix-room-timeline-p.hpp"
#include "matrix-event.hpp"
#include "helper.hpp"
#include "kazv-log.hpp"
+#include "list-diff.hpp"
using namespace Kazv;
+std::size_t EnhancedTotalItems::size() const
+{
+ return timeline.size() + localEchoes.size();
+}
+
+TimelineItemDesc EnhancedTotalItems::at(std::size_t index) const
+{
+ auto rIndex = size() - index - 1;
+ if (rIndex < timeline.size()) {
+ return TimelineItemDesc{timeline[rIndex], /* isLocalEcho = */ false};
+ } else {
+ auto remainingIndex = rIndex - timeline.size();
+ if (remainingIndex < localEchoes.size()) {
+ return TimelineItemDesc{localEchoes[remainingIndex], /* isLocalEcho = */ true};
+ } else {
+ return TimelineItemDesc{"", /* isLocalEcho */ false};
+ }
+ }
+}
+
+void EnhancedTotalItems::eraseInPlace(std::size_t index)
+{
+ auto rIndex = size() - index - 1;
+ if (rIndex < timeline.size()) {
+ timeline = std::move(timeline).erase(rIndex);
+ } else {
+ auto remainingIndex = rIndex - timeline.size();
+ Q_ASSERT(remainingIndex < localEchoes.size());
+ localEchoes = std::move(localEchoes).erase(remainingIndex);
+ }
+}
+
+void EnhancedTotalItems::insertInPlace(std::size_t index, const TimelineItemDesc &item)
+{
+ auto rIndex = size() - index;
+ if (rIndex < timeline.size() || (rIndex == timeline.size() && !item.isLocalEcho)) {
+ Q_ASSERT(!item.isLocalEcho);
+ timeline = std::move(timeline).insert(rIndex, item.id);
+ } else {
+ auto remainingIndex = rIndex - timeline.size();
+ Q_ASSERT(remainingIndex <= localEchoes.size());
+ Q_ASSERT(item.isLocalEcho);
+ localEchoes = std::move(localEchoes).insert(remainingIndex, item.id);
+ }
+}
+
+TotalItems makeTotalItems(immer::flex_vector<std::string> tl, immer::flex_vector<LocalEchoDesc> local)
+{
+ return {
+ std::move(tl),
+ intoImmer(
+ immer::flex_vector<std::string>{},
+ zug::map([](const LocalEchoDesc &d) { return d.txnId; }),
+ local
+ ),
+ };
+}
+
MatrixRoomTimeline::MatrixRoomTimeline(lager::reader<immer::flex_vector<std::string>> eventIds, lager::reader<immer::map<std::string, Kazv::Event>> messagesMap, lager::reader<immer::flex_vector<LocalEchoDesc>> localEchoes, lager::reader<immer::map<std::string, std::string>> timelineGaps, Kazv::Room room, QObject *parent)
- : KazvAbstractListModel(parent)
+ : QAbstractListModel(parent)
, m_room(room)
, m_timelineEventIds(eventIds)
, m_messagesMap(messagesMap)
, m_localEchoes(localEchoes)
, m_timelineGaps(timelineGaps)
+ , m_totalItems(lager::with(
+ m_timelineEventIds,
+ m_localEchoes
+ ).map(makeTotalItems))
, LAGER_QT(gaps)(m_timelineGaps.map([](auto g) {
QJsonObject res;
for (const auto &[k, _v] : g) {
res.insert(QString::fromStdString(k), QJsonValue(true));
}
return res;
}))
{
- initCountCursor(lager::with(
- m_timelineEventIds.xform(containerSize),
- m_localEchoes.xform(containerSize)
- ).map([](const auto &tlSize, const auto &localSize) { return tlSize + localSize; }), InitLater);
+ lager::watch(m_totalItems, [this](const TotalItems &next) {
+ handleChange(next);
+ });
+ QTimer::singleShot(0, [this, total=m_totalItems.get()] {
+ auto newCount = static_cast<const EnhancedTotalItems &>(total).size();
+ if (newCount) {
+ Q_EMIT beginInsertRows(QModelIndex(), 0, newCount - 1);
+ m_innerTotalItems = total;
+ Q_EMIT endInsertRows();
+ Q_EMIT countChanged();
+ }
+ });
}
MatrixRoomTimeline::MatrixRoomTimeline(Kazv::Room room, QObject *parent)
: MatrixRoomTimeline(
room.timelineEventIds(),
room.messagesMap(),
room.localEchoes(),
room.timelineGaps(),
room,
parent
)
{
}
MatrixRoomTimeline::~MatrixRoomTimeline() = default;
MatrixEvent *MatrixRoomTimeline::at(int index) const
{
- return new MatrixEvent(lager::with(m_timelineEventIds, m_messagesMap, m_localEchoes, LAGER_QT(count))
- .map([index](const auto &tlEventIds, const auto &messagesMap, const auto &localEchoes, const auto &count) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc, std::string> {
+ return new MatrixEvent(lager::with(m_timelineEventIds, m_messagesMap, m_localEchoes)
+ .map([index](const auto &tlEventIds, const auto &messagesMap, const auto &localEchoes) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc, std::string> {
+ auto count = tlEventIds.size() + localEchoes.size();
auto rIndex = count - index - 1;
if (static_cast<unsigned int>(rIndex) < tlEventIds.size()) {
auto eventId = tlEventIds[rIndex];
// For some timelines like pinned events, the message may not be available
if (messagesMap.count(eventId)) {
return messagesMap[eventId];
} else {
return eventId;
}
} else {
auto remainingIndex = rIndex - tlEventIds.size();
if (remainingIndex < localEchoes.size()) {
return localEchoes[remainingIndex];
} else {
return Event();
}
}
}), m_room);
}
+QVariant MatrixRoomTimeline::data(const QModelIndex &index, int role) const
+{
+ if (!index.isValid() || std::size_t(index.row()) >= static_cast<const EnhancedTotalItems &>(m_innerTotalItems).size()) {
+ return QVariant();
+ } else if (role == Qt::UserRole) {
+ return QString::fromStdString(static_cast<const EnhancedTotalItems &>(m_innerTotalItems).at(index.row()).id);
+ } else if (role == Qt::UserRole + 1) {
+ return static_cast<const EnhancedTotalItems &>(m_innerTotalItems).at(index.row()).isLocalEcho;
+ }
+ return QVariant();
+}
+
+QHash<int, QByteArray> MatrixRoomTimeline::roleNames() const
+{
+ return {
+ {Qt::UserRole, "eventOrTxnId"},
+ {Qt::UserRole + 1, "isLocalEcho"},
+ };
+}
+
+int MatrixRoomTimeline::rowCount(const QModelIndex &parent) const
+{
+ if (parent.isValid()) {
+ return 0;
+ } else {
+ return count();
+ }
+}
+
int MatrixRoomTimeline::indexOfEvent(const QString &eventId) const
{
auto eventMap = m_messagesMap.get();
auto eventIdStd = eventId.toStdString();
if (eventMap.count(eventIdStd) == 0) {
return -1;
}
auto eventIds = m_timelineEventIds.get();
auto it = std::lower_bound(eventIds.begin(), eventIds.end(), eventIdStd,
[eventMap](const auto &idA, const auto &idB) {
auto a = std::make_pair(eventMap[idA].originServerTs(), idA);
auto b = std::make_pair(eventMap[idB].originServerTs(), idB);
return a < b;
}
);
if (it == eventIds.end()) {
return -1;
} else {
// suppose there are A events and B local echoes
// forward index:
// [0, 1, ..., A-1] [A, A+1, ..., A+B-1]
// reverse index:
// [A+B-1, A+B-2, ..., B] [B-1, B-2 , 0]
// forward = it.index()
// forward + reverse = A+B-1 = count() - 1
return count() - it.index() - 1;
}
}
+
+MatrixEvent *MatrixRoomTimeline::byId(const QString &id, bool isLocalEcho) const
+{
+ return new MatrixEvent(lager::with(m_messagesMap, m_localEchoes)
+ .map([id=id.toStdString(), isLocalEcho](const auto &messagesMap, const auto &localEchoes) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc, std::string> {
+ if (isLocalEcho) {
+ auto it = std::find_if(localEchoes.begin(), localEchoes.end(), [id](const auto &e) {
+ return e.txnId == id;
+ });
+ if (it != localEchoes.end()) {
+ return *it;
+ }
+ return Event();
+ }
+ if (messagesMap.count(id)) {
+ return messagesMap[id];
+ } else {
+ return id;
+ }
+ }), m_room);
+}
+
+int MatrixRoomTimeline::count() const
+{
+ return static_cast<const EnhancedTotalItems &>(m_innerTotalItems).size();
+}
+
+void MatrixRoomTimeline::handleChange(const TotalItems &next)
+{
+ using IdType = TimelineItemDesc;
+ const auto &nextCast = static_cast<const EnhancedTotalItems &>(next);
+ auto last = static_cast<EnhancedTotalItems>(m_innerTotalItems);
+
+ auto diff = listDiff(last, nextCast, [](auto x) { return x; });
+ auto &cur = static_cast<EnhancedTotalItems &>(m_innerTotalItems);
+
+ for (auto change : diff) {
+ lager::match(change)(
+ [&cur, this](ListMove m) {
+ auto item = cur.at(m.from);
+ Q_EMIT beginMoveRows(QModelIndex(), m.from, m.from, QModelIndex(), m.to);
+ cur.eraseInPlace(m.from);
+ cur.insertInPlace(m.to, std::move(item));
+ Q_EMIT endMoveRows();
+ },
+ [&cur, this](ListAdd<IdType> a) {
+ Q_EMIT beginInsertRows(QModelIndex(), a.index, a.index);
+ cur.insertInPlace(a.index, a.id);
+ Q_EMIT endInsertRows();
+ },
+ [&cur, this](ListRemove r) {
+ Q_EMIT beginRemoveRows(QModelIndex(), r.index, r.index);
+ cur.eraseInPlace(r.index);
+ Q_EMIT endRemoveRows();
+ }
+ );
+ }
+
+ if (nextCast.size() != last.size()) {
+ Q_EMIT countChanged();
+ }
+}
diff --git a/src/matrix-room-timeline.hpp b/src/matrix-room-timeline.hpp
index 56f902b..2406efe 100644
--- a/src/matrix-room-timeline.hpp
+++ b/src/matrix-room-timeline.hpp
@@ -1,43 +1,66 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <kazv-defs.hpp>
#include <QObject>
#include <QQmlEngine>
#include <QJsonObject>
+#include <QAbstractListModel>
#include <lager/extra/qt.hpp>
#include <client/room/room.hpp>
-#include "kazv-abstract-list-model.hpp"
class MatrixEvent;
+struct TotalItems
+{
+ immer::flex_vector<std::string> timeline;
+ immer::flex_vector<std::string> localEchoes;
+};
-class MatrixRoomTimeline : public KazvAbstractListModel
+class MatrixRoomTimeline : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
+ Q_PROPERTY(int count READ count NOTIFY countChanged)
Kazv::Room m_room;
lager::reader<immer::flex_vector<std::string>> m_timelineEventIds;
lager::reader<immer::map<std::string, Kazv::Event>> m_messagesMap;
lager::reader<immer::flex_vector<Kazv::LocalEchoDesc>> m_localEchoes;
lager::reader<immer::map<std::string, std::string>> m_timelineGaps;
+ lager::reader<TotalItems> m_totalItems;
+ TotalItems m_innerTotalItems;
public:
explicit MatrixRoomTimeline(Kazv::Room room, QObject *parent = 0);
explicit MatrixRoomTimeline(lager::reader<immer::flex_vector<std::string>> eventIds, lager::reader<immer::map<std::string, Kazv::Event>> messagesMap, lager::reader<immer::flex_vector<Kazv::LocalEchoDesc>> localEchoes, lager::reader<immer::map<std::string, std::string>> timelineGaps, Kazv::Room room, QObject *parent = 0);
~MatrixRoomTimeline() override;
LAGER_QT_READER(QJsonObject, gaps);
Q_INVOKABLE MatrixEvent *at(int index) const;
+ Q_INVOKABLE MatrixEvent *byId(const QString &id, bool isLocalEcho) const;
+
Q_INVOKABLE int indexOfEvent(const QString &eventId) const;
+
+ QVariant data(const QModelIndex &index, int role) const override;
+
+ int rowCount(const QModelIndex &parent) const override;
+
+ int count() const;
+
+ QHash<int, QByteArray> roleNames() const override;
+
+ Q_SIGNAL void countChanged();
+
+private:
+ void handleChange(const TotalItems &next);
};
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index 3e1ced8..b27269c 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -1,166 +1,167 @@
set(KAZV_TEST_TEMP_DIR ${CMAKE_CURRENT_BINARY_DIR}/test-temp)
configure_file(test-temp.hpp.in test-temp.hpp)
set(kazvtestlib_SRCS
test-model.cpp
)
add_library(kazvtestlib STATIC ${kazvtestlib_SRCS})
target_include_directories(kazvtestlib PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
target_link_libraries(kazvtestlib PUBLIC kazvprivlib libkazv::kazvtestfixtures)
add_executable(matrix-sdk-session-loader matrix-sdk-session-loader.cpp matrix-sdk-sessions-test.cpp)
target_compile_definitions(matrix-sdk-session-loader PRIVATE MATRIX_SDK_SESSIONS_TEST_NO_MAIN)
target_link_libraries(matrix-sdk-session-loader Qt${QT_MAJOR_VERSION}::Test kazvtestlib)
ecm_add_tests(
qt-job-handler-test.cpp
qt-promise-handler-test.cpp
qt-json-test.cpp
device-mgmt-test.cpp
matrix-session-test.cpp
matrix-session-controller-test.cpp
matrix-sdk-sessions-test.cpp
matrix-promise-test.cpp
matrix-room-timeline-test.cpp
matrix-room-pinned-events-timeline-test.cpp
matrix-room-member-list-model-test.cpp
matrix-room-list-test.cpp
matrix-room-state-test.cpp
kazv-markdown-test.cpp
matrix-sticker-pack-test.cpp
matrix-link-test.cpp
matrix-user-given-attrs-map-test.cpp
kazv-abstract-list-model-test.cpp
matrix-event-list-test.cpp
matrix-event-test.cpp
kazv-file-test.cpp
db-store-test.cpp
sso-login-process-test.cpp
kazv-config-test.cpp
+ list-diff-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test kazvtestlib
)
ecm_add_test(
kazv-io-job-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test Qt${QT_MAJOR_VERSION}::HttpServer kazvtestlib
)
ecm_add_test(
kazv-io-manager-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test Qt${QT_MAJOR_VERSION}::HttpServer kazvtestlib
)
ecm_add_test(
kazv-sanitizer-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test Qt${QT_MAJOR_VERSION}::HttpServer kazvsanitizer
)
add_executable(quicktest quick-test.cpp)
target_link_libraries(quicktest PRIVATE Qt${QT_MAJOR_VERSION}::QuickTest kazvtestlib)
if(KAZV_BUILD_BENCHMARKS)
ecm_add_test(
matrix-room-timeline-benchmark-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test kazvtestlib
)
ecm_add_test(kazv-sanitizer-benchmark-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test kazvsanitizer
)
endif()
set(quicktest_QMLS
tst_About.qml
tst_AddStickerPopup.qml
tst_AvatarImageAdapters.qml
tst_ConfirmUploadPopup.qml
tst_CreateRoomPage.qml
tst_DeviceList.qml
tst_DevicePopup.qml
tst_Device.qml
tst_EventHistoryView.qml
tst_EventReactions.qml
tst_EventReadIndicator.qml
tst_EventSourceView.qml
tst_EventView.qml
tst_EventViewRedacted.qml
tst_EventViewVideo.qml
tst_FileHandler.qml
tst_JoinRoomPage.qml
tst_KazvIOMenu.qml
tst_kazvshortcutsActionItem.qml
tst_LoginPage.qml
tst_MatrixHelpers.qml
tst_Notifications.qml
tst_PageManager.qml
tst_ReactToEventPopup.qml
tst_RoomInvitePage.qml
tst_RoomListViewItemDelegate.qml
tst_RoomMemberListPage.qml
tst_RoomNameProvider.qml
tst_RoomPage.qml
tst_RoomPinnedEventsPage.qml
tst_RoomSettingsPage.qml
tst_RoomStatePage.qml
tst_RoomStickerPackItemDelegate.qml
tst_RoomStickerPacksPage.qml
tst_RoomTimelineView.qml
tst_SendMessageBoxDrafts.qml
tst_SendMessageBox.qml
tst_StickerPackNameProvider.qml
tst_StickerPicker.qml
tst_TypingIndicator.qml
tst_UserNameProvider.qml
tst_UserPage.qml
)
foreach(testfilename ${quicktest_QMLS})
message(STATUS "Adding quick test ${testfilename}")
string(REPLACE .qml "" testname ${testfilename})
add_test(NAME ${testname} COMMAND quicktest -input ${CMAKE_CURRENT_SOURCE_DIR}/quick-tests/${testfilename})
set_property(TEST ${testname}
PROPERTY LABELS "quicktest")
endforeach()
if(kazv_ENABLE_APPIUM_TESTS)
qt_add_qml_module(kazvappiumcomponentqmlmodule
STATIC
URI moe.kazv.mxc.kazvappiumcomponent
VERSION 0.0
)
kazv_add_qml_files(kazvappiumcomponentqmlmodule
BASE_DIRECTORY run-appium-component-qml
SOURCES
Main.qml
)
add_executable(run-appium-component run-appium-component.cpp)
target_link_libraries(run-appium-component
PRIVATE
kazvappiumcomponentqmlmodule
kazvappiumcomponentqmlmoduleplugin
kazvtestlib
Qt::Quick
)
set(appium_tests_SOURCES
TestStickerPicker.py
TestEventViewUnavailable.py
TestRoomTimelineView.py
)
foreach(testfilename ${appium_tests_SOURCES})
message(STATUS "Adding appium test ${testfilename}")
string(REPLACE .py "" testname ${testfilename})
add_test(NAME ${testname}
COMMAND
${SELENIUM_WEBDRIVER_AT_SPI_RUN_PATH}
${CMAKE_CURRENT_SOURCE_DIR}/appium-tests/${testfilename}
)
set_property(TEST ${testname}
PROPERTY ENVIRONMENT "KAZV_APPIUM_TEST_BIN=$<TARGET_FILE:run-appium-component>;TEST_WITH_VIDEO_RECORDER=0")
set_property(TEST ${testname}
PROPERTY LABELS "appiumtest")
endforeach()
endif()
diff --git a/src/tests/appium-tests/TestRoomTimelineView.qml b/src/tests/appium-tests/TestRoomTimelineView.qml
index 2236772..966bba8 100644
--- a/src/tests/appium-tests/TestRoomTimelineView.qml
+++ b/src/tests/appium-tests/TestRoomTimelineView.qml
@@ -1,117 +1,81 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtTest
import '../../contents/ui' as Kazv
import 'qml-helpers' as AppiumQmlHelpers
+import '../quick-tests/test-helpers' as QmlHelpers
AppiumQmlHelpers.TestItem {
id: upper
signal loadEventRequested(string eventId)
- property var makeLocalEcho: (i) => ({
- eventId: '',
- sender: '',
- type: 'm.room.message',
- stateKey: '',
- content: {
- msgtype: 'm.text',
- body: 'some body' + i,
- },
- encrypted: false,
- isState: false,
- unsignedData: {},
- isLocalEcho: true,
- isSending: true,
- isFailed: false,
- isAvailable: true,
- txnId: 'some-txn-id' + i,
- })
-
- property var makeTextEvent: (i) => ({
- eventId: '$' + i,
- sender: '@foo:tusooa.xyz',
- type: 'm.room.message',
- stateKey: '',
- content: {
- msgtype: 'm.text',
- body: 'some body',
- },
- isAvailable: true,
- formattedTime: '4:06 P.M.',
- })
property var room: ({
pinEvents: mockHelper.promise(),
unpinEvents: mockHelper.promise(),
redactEvent: mockHelper.funcThenPromise((eventId) => {
console.log('redactEvent:', eventId);
redactLastArgLabel.value = eventId;
}),
removeLocalEcho: mockHelper.funcThenPromise((eventId) => {
console.log('rm local echo:', eventId);
removeLocalEchoLastArgLabel.value = eventId;
}),
messageById(id) {
return { eventId: id };
},
localEchoById(id) {
return { txnId: id };
}
})
- property var timeline: ListModel {
- ListElement {}
- ListElement {}
-
- function at(index) {
- if (index < 1) {
- return makeLocalEcho(index);
- } else {
- return makeTextEvent(index);
- }
- }
+ property var timeline: QmlHelpers.MatrixRoomTimelineMock {
+ data: [
+ { eventOrTxnId: 'some-txn-id0', isLocalEcho: true },
+ { eventOrTxnId: '$1', isLocalEcho: false },
+ ]
}
ColumnLayout {
anchors.fill: parent
RowLayout {
AppiumQmlHelpers.InfoLabel {
id: redactLastArgLabel
tag: 'redact-last-arg'
value: ''
}
AppiumQmlHelpers.InfoLabel {
id: removeLocalEchoLastArgLabel
tag: 'remove-local-echo-last-arg'
value: ''
}
Button {
text: 'make-redact-fail'
onClicked: () => {
room.redactEvent.lastRetVal().resolve(false, { errorCode: 'M_FORBIDDEN', error: 'You are not allowed to do so' });
}
}
}
Kazv.RoomTimelineView {
Layout.fillHeight: true
Layout.fillWidth: true
id: roomTimelineView
room: upper.room
timeline: upper.timeline
}
}
function reset() {
redactLastArgLabel.value = '';
removeLocalEchoLastArgLabel.value = '';
}
}
diff --git a/src/tests/list-diff-test.cpp b/src/tests/list-diff-test.cpp
new file mode 100644
index 0000000..188ce6d
--- /dev/null
+++ b/src/tests/list-diff-test.cpp
@@ -0,0 +1,46 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <kazv-defs.hpp>
+#include "list-diff.hpp"
+#include <immer/flex_vector.hpp>
+#include <zug/meta/util.hpp>
+#include <lager/util.hpp>
+#include <QtTest>
+
+class ListDiffTest : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void testDiff();
+};
+
+void ListDiffTest::testDiff()
+{
+ immer::flex_vector<std::string> last{"a", "b", "c", "d", "e", "f"};
+ immer::flex_vector<std::string> next{"x", "a", "c", "f", "y", "d", "z", "e"};
+ auto diff = listDiff(last, next, [](std::string s) -> std::string { return s; });
+ auto work = last;
+ for (auto act : diff) {
+ lager::match(act)(
+ [&](ListMove m) {
+ auto item = work.at(m.from);
+ work = std::move(work).erase(m.from).insert(m.to, std::move(item));
+ },
+ [&](ListAdd<std::string> a) {
+ work = std::move(work).insert(a.index, a.id);
+ },
+ [&](ListRemove r) {
+ work = std::move(work).erase(r.index);
+ }
+ );
+ }
+ QCOMPARE(work, next);
+}
+
+QTEST_MAIN(ListDiffTest)
+#include "list-diff-test.moc"
diff --git a/src/tests/matrix-room-pinned-events-timeline-test.cpp b/src/tests/matrix-room-pinned-events-timeline-test.cpp
index b3fa0bd..0cd5454 100644
--- a/src/tests/matrix-room-pinned-events-timeline-test.cpp
+++ b/src/tests/matrix-room-pinned-events-timeline-test.cpp
@@ -1,60 +1,60 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <kazv-defs.hpp>
#include "test-model.hpp"
#include "test-utils.hpp"
#include <matrix-event.hpp>
#include <matrix-room.hpp>
#include <matrix-room-pinned-events-timeline.hpp>
#include <matrix-room-list.hpp>
#include <matrix-session.hpp>
#include <factory.hpp>
#include <QtTest>
using namespace Qt::Literals::StringLiterals;
using namespace Kazv;
using namespace Kazv::Factory;
class MatrixRoomPinnedEventsTimelineTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void testUnavailable();
};
void MatrixRoomPinnedEventsTimelineTest::testUnavailable()
{
auto r = makeRoom(withRoomTimeline({
makeEvent(withEventId("$1")),
makeEvent(withEventId("$2")),
}) | withRoomState({
makeEvent(withEventType("m.room.pinned_events")
| withEventContent(json::object({
{"pinned", json::array({"$1", "$3"})},
}))
| withStateKey(""))
}));
auto model = SdkModel{makeClient(withRoom(r))};
SessionSetup s(model);
auto roomList = toUniquePtr(s.session.roomList());
auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
auto timeline = toUniquePtr(room->pinnedEventsTimeline());
- QCOMPARE(timeline->count(), 2);
+ QTRY_COMPARE(timeline->count(), 2);
auto e1 = toUniquePtr(timeline->at(1));
QCOMPARE(e1->eventId(), u"$1"_s);
QVERIFY(e1->isAvailable());
auto e0 = toUniquePtr(timeline->at(0));
QCOMPARE(e0->eventId(), u"$3"_s);
QVERIFY(!e0->isAvailable());
}
QTEST_MAIN(MatrixRoomPinnedEventsTimelineTest)
#include "matrix-room-pinned-events-timeline-test.moc"
diff --git a/src/tests/matrix-room-timeline-test.cpp b/src/tests/matrix-room-timeline-test.cpp
index b60c005..758dea2 100644
--- a/src/tests/matrix-room-timeline-test.cpp
+++ b/src/tests/matrix-room-timeline-test.cpp
@@ -1,162 +1,218 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <kazv-defs.hpp>
#include <memory>
#include <QtTest>
#include <factory.hpp>
-#include <matrix-room-timeline.hpp>
+#include <matrix-room-timeline-p.hpp>
#include <matrix-sdk.hpp>
#include <matrix-room-list.hpp>
#include <matrix-room.hpp>
#include <matrix-event.hpp>
#include <matrix-event-reader-list-model.hpp>
#include <matrix-event-reader.hpp>
#include "test-model.hpp"
#include "test-utils.hpp"
using namespace Qt::Literals::StringLiterals;
using namespace Kazv;
using namespace Kazv::Factory;
class MatrixRoomTimelineTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void testLocalEcho();
void testReadReceipts();
void testEdits();
void testIndexOfEvent();
+ void testTotalItems();
+ void testModification();
};
void MatrixRoomTimelineTest::testLocalEcho()
{
auto model = makeTestModel();
SessionSetup s(model);
auto roomList = toUniquePtr(s.session.roomList());
auto room = toUniquePtr(roomList->room(u"!foo:tusooa.xyz"_s));
auto timeline = toUniquePtr(room->timeline());
- QCOMPARE(timeline->count(), 2);
+ QTRY_COMPARE(timeline->count(), 2);
auto first = toUniquePtr(timeline->at(1)); // reverse order
QCOMPARE(first->isSending(), true);
QCOMPARE(first->isLocalEcho(), true);
QCOMPARE(first->isFailed(), false);
QCOMPARE(first->txnId(), u"some-txn-id"_s);
auto second = toUniquePtr(timeline->at(0));
QCOMPARE(second->isSending(), false);
QCOMPARE(second->isLocalEcho(), true);
QCOMPARE(second->isFailed(), true);
QCOMPARE(second->txnId(), u"some-other-txn-id"_s);
}
void MatrixRoomTimelineTest::testReadReceipts()
{
auto r = makeRoom(withRoomTimeline({
makeEvent(withEventId("$1")),
makeEvent(withEventId("$2")),
}));
r.eventReadUsers = {{"$1", {"@a:example.com", "@b:example.com"}}};
r.readReceipts = {{"@a:example.com", {"$1", 1234}}, {"@b:example.com", {"$2", 5678}}};
auto model = SdkModel{makeClient(withRoom(r))};
SessionSetup s(model);
auto roomList = toUniquePtr(s.session.roomList());
auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
auto timeline = toUniquePtr(room->timeline());
{
auto event = toUniquePtr(timeline->at(1));
QCOMPARE(event->eventId(), u"$1"_s);
auto readers = toUniquePtr(event->readers());
QCOMPARE(readers->count(), 2);
auto reader1 = toUniquePtr(readers->at(0));
QCOMPARE(reader1->userId(), u"@a:example.com"_s);
QCOMPARE(reader1->timestamp(), 1234);
auto reader2 = toUniquePtr(readers->at(1));
QCOMPARE(reader2->userId(), u"@b:example.com"_s);
QCOMPARE(reader2->timestamp(), 5678);
}
{
auto event = toUniquePtr(timeline->at(0));
QCOMPARE(event->eventId(), u"$2"_s);
auto readers = toUniquePtr(event->readers());
QCOMPARE(readers->count(), 0);
}
}
void MatrixRoomTimelineTest::testEdits()
{
auto r = makeRoom(withRoomTimeline({
makeEvent(withEventId("$0")),
makeEvent(withEventId("$1") | withEventRelationship("moe.kazv.mxc.some-rel", "$0")),
makeEvent(withEventId("$2") | withEventContent(json{{"body", "first"}}) | withEventRelationship("m.replace", "$1")),
// valid
makeEvent(withEventId("$3") | withEventContent(json{{"m.new_content", {{"body", "second"}}}}) | withEventRelationship("m.replace", "$1")),
// also valid
makeEvent(withEventId("$4") | withEventContent(json{{"m.new_content", {{"body", "third"}}}}) | withEventRelationship("m.replace", "$1")),
// invalid, changed event type
makeEvent(withEventId("$5") | withEventContent(json{{"m.new_content", {{"body", "fourth"}}}}) | withEventRelationship("m.replace", "$1") | withEventType("moe.kazv.mxc.some-other-type")),
// invalid, changed event sender
makeEvent(withEventId("$6") | withEventContent(json{{"m.new_content", {{"body", "fifth"}}}}) | withEventRelationship("m.replace", "$1") | withEventType("moe.kazv.mxc.some-other-type") | withEventSenderId("@othersender:example.com")),
// invalid, has state key
makeEvent(withEventId("$7") | withEventContent(json{{"m.new_content", {{"body", "sixth"}}}}) | withEventRelationship("m.replace", "$1") | withEventType("moe.kazv.mxc.some-other-type") | withStateKey("")),
// invalid, no m.new_content
makeEvent(withEventId("$8") | withEventContent(json{{"body", "seventh"}}) | withEventRelationship("m.replace", "$1") | withEventType("moe.kazv.mxc.some-other-type")),
}));
auto model = SdkModel{makeClient(withRoom(r))};
SessionSetup s(model);
auto roomList = toUniquePtr(s.session.roomList());
auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
auto timeline = toUniquePtr(room->timeline());
auto event = toUniquePtr(timeline->at(7));
QCOMPARE(event->eventId(), u"$1"_s);
// the content is taken from the newest valid edit
qDebug() << event->content();
QCOMPARE(event->content(), (QJsonObject{{u"body"_s, u"third"_s}}));
// the relation is taken from the original content
QCOMPARE(event->relationType(), u"moe.kazv.mxc.some-rel"_s);
QCOMPARE(event->relatedEventId(), u"$0"_s);
QVERIFY(event->isEdited());
}
void MatrixRoomTimelineTest::testIndexOfEvent()
{
// reverse index
// 4 3 2 1 0
// event/txnId
// $1 $2 $3 txn1 txn2
auto r = makeRoom(withRoomTimeline({
makeEvent(withEventId("$1") | withEventKV("/origin_server_ts"_json_pointer, 1000)),
makeEvent(withEventId("$2") | withEventKV("/origin_server_ts"_json_pointer, 2000)),
makeEvent(withEventId("$3") | withEventKV("/origin_server_ts"_json_pointer, 3000)),
}));
r.localEchoes = {
LocalEchoDesc{"txn1", makeEvent(withEventId("")), LocalEchoDesc::Sending},
LocalEchoDesc{"txn2", makeEvent(withEventId("")), LocalEchoDesc::Sending},
};
auto model = SdkModel{makeClient(withRoom(r))};
SessionSetup s(model);
auto roomList = toUniquePtr(s.session.roomList());
auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
auto timeline = toUniquePtr(room->timeline());
- QCOMPARE(timeline->indexOfEvent(u"$1"_s), 4);
+ QTRY_COMPARE(timeline->indexOfEvent(u"$1"_s), 4);
QCOMPARE(timeline->indexOfEvent(u"$2"_s), 3);
QCOMPARE(timeline->indexOfEvent(u"$3"_s), 2);
QCOMPARE(timeline->indexOfEvent(u"$4"_s), -1);
}
+void MatrixRoomTimelineTest::testModification()
+{
+ auto r = makeRoom(withRoomTimeline({
+ makeEvent(withEventId("$1") | withEventKV("/origin_server_ts"_json_pointer, 1000)),
+ makeEvent(withEventId("$2") | withEventKV("/origin_server_ts"_json_pointer, 2000)),
+ makeEvent(withEventId("$3") | withEventKV("/origin_server_ts"_json_pointer, 3000)),
+ }));
+ r.localEchoes = {
+ LocalEchoDesc{"txn1", makeEvent(withEventId("")), LocalEchoDesc::Sending},
+ LocalEchoDesc{"txn2", makeEvent(withEventId("")), LocalEchoDesc::Sending},
+ };
+
+ auto model = SdkModel{makeClient(withRoom(r))};
+ SessionSetup s(model);
+ auto roomList = toUniquePtr(s.session.roomList());
+ auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
+ auto timeline = toUniquePtr(room->timeline());
+ s.sdk.context().dispatch(UpdateRoomAction{r.roomId, AddToTimelineAction{
+ {
+ makeEvent(withEventId("$xxx") | withEventKV("/origin_server_ts"_json_pointer, 500)),
+ makeEvent(withEventId("$yyy") | withEventKV("/origin_server_ts"_json_pointer, 1500)),
+ makeEvent(withEventId("$zzz") | withEventKV("/origin_server_ts"_json_pointer, 5000)),
+ },
+ "prev-batch-1",
+ true,
+ std::nullopt,
+ }});
+
+ QTRY_COMPARE(timeline->count(), 8);
+ QCOMPARE(timeline->data(timeline->index(0), Qt::UserRole), u"txn2"_s);
+ QCOMPARE(timeline->data(timeline->index(1), Qt::UserRole), u"txn1"_s);
+ QCOMPARE(timeline->data(timeline->index(2), Qt::UserRole), u"$zzz"_s);
+ QCOMPARE(timeline->data(timeline->index(2), Qt::UserRole + 1), false);
+ QCOMPARE(timeline->data(timeline->index(3), Qt::UserRole), u"$3"_s);
+ QCOMPARE(timeline->data(timeline->index(4), Qt::UserRole), u"$2"_s);
+ QCOMPARE(timeline->data(timeline->index(5), Qt::UserRole), u"$yyy"_s);
+ QCOMPARE(timeline->data(timeline->index(6), Qt::UserRole), u"$1"_s);
+ QCOMPARE(timeline->data(timeline->index(7), Qt::UserRole), u"$xxx"_s);
+}
+
+void MatrixRoomTimelineTest::testTotalItems()
+{
+ EnhancedTotalItems t = static_cast<EnhancedTotalItems>(TotalItems{{"$1", "$2", "$3"}, {"txn1", "txn2"}});
+ QCOMPARE(t.at(0), (TimelineItemDesc{"txn2", true}));
+ QCOMPARE(t.at(1), (TimelineItemDesc{"txn1", true}));
+ QCOMPARE(t.at(2), (TimelineItemDesc{"$3", false}));
+ t.insertInPlace(2, TimelineItemDesc{"$4", false});
+ QCOMPARE(t.at(2), (TimelineItemDesc{"$4", false}));
+ t.insertInPlace(2, TimelineItemDesc{"txn3", true});
+ QCOMPARE(t.at(2), (TimelineItemDesc{"txn3", true}));
+ t.insertInPlace(7, TimelineItemDesc{"$x", false});
+ QCOMPARE(t.at(7), (TimelineItemDesc{"$x", false}));
+}
+
QTEST_MAIN(MatrixRoomTimelineTest)
#include "matrix-room-timeline-test.moc"
diff --git a/src/tests/quick-tests/test-helpers/MatrixRoomTimelineMock.qml b/src/tests/quick-tests/test-helpers/MatrixRoomTimelineMock.qml
new file mode 100644
index 0000000..e0e485e
--- /dev/null
+++ b/src/tests/quick-tests/test-helpers/MatrixRoomTimelineMock.qml
@@ -0,0 +1,64 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick
+
+ListModel {
+ property var data
+ onDataChanged: {
+ clear();
+ for (const i of data) {
+ append(i);
+ }
+ }
+
+ function txnIdFor(i) { return 'some-txn-id' + i; }
+ function eventIdFor(i) { return '$' + i; }
+
+ function makeLocalEcho(i) {
+ return {
+ eventId: '',
+ sender: '',
+ type: 'm.room.message',
+ stateKey: '',
+ content: {
+ msgtype: 'm.text',
+ body: 'some body' + i,
+ },
+ encrypted: false,
+ isState: false,
+ unsignedData: {},
+ isLocalEcho: true,
+ isSending: true,
+ isFailed: false,
+ isAvailable: true,
+ txnId: i,
+ };
+ }
+
+ function makeTextEvent(i) {
+ return {
+ eventId: i,
+ sender: '@foo:tusooa.xyz',
+ type: 'm.room.message',
+ stateKey: '',
+ content: {
+ msgtype: 'm.text',
+ body: 'some body',
+ },
+ isAvailable: true,
+ formattedTime: '4:06 P.M.',
+ };
+ }
+
+ function at(index) {
+ return get(index).isLocalEcho ? makeLocalEcho(get(index).eventOrTxnId) : makeTextEvent(get(index).eventOrTxnId);
+ }
+
+ function byId(id, isLocalEcho) {
+ return isLocalEcho ? makeLocalEcho(id) : makeTextEvent(id);
+ }
+}
diff --git a/src/tests/quick-tests/tst_RoomPage.qml b/src/tests/quick-tests/tst_RoomPage.qml
index e549b2b..2218e2a 100644
--- a/src/tests/quick-tests/tst_RoomPage.qml
+++ b/src/tests/quick-tests/tst_RoomPage.qml
@@ -1,200 +1,202 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick 2.3
import QtQuick.Layouts 1.15
import QtTest 1.0
import '../../contents/ui' as Kazv
import 'test-helpers.js' as JsHelpers
import 'test-helpers' as QmlHelpers
import moe.kazv.mxc.kazv 0.0 as MK
QmlHelpers.TestItem {
id: item
- property var roomTimeline: QtObject {
- property var count: 5
- function at(i) {
- if (i < 2) {
- return {
- isLocalEcho: true,
- };
- }
- if (i < 3) {
+ property var roomTimeline: QmlHelpers.MatrixRoomTimelineMock {
+ data: [
+ { eventOrTxnId: 'txn0', isLocalEcho: true },
+ { eventOrTxnId: 'txn1', isLocalEcho: true },
+ { eventOrTxnId: '$event2', isLocalEcho: false },
+ { eventOrTxnId: '$event3', isLocalEcho: false },
+ { eventOrTxnId: '$event4', isLocalEcho: false },
+ ]
+
+ function makeTextEvent(i) {
+ if (i === '$event2') {
return {
isLocalEcho: false,
sender: '@foo:example.org',
- eventId: `$event${i}`,
+ eventId: i,
};
}
return {
isLocalEcho: false,
sender: '@bar:example.org',
- eventId: `$event${i}`,
+ eventId: i,
};
}
}
property var roomInvite: JsHelpers.factory.room({
membership: MK.MatrixRoom.Invite,
})
property var roomJoin: JsHelpers.factory.room({
membership: MK.MatrixRoom.Join,
paginateBackFrom: mockHelper.promise(),
pinnedEventsTimeline: () => ({
count: 0,
}),
})
property var roomLeave: JsHelpers.factory.room({
membership: MK.MatrixRoom.Leave,
})
property var roomWithPinned: JsHelpers.factory.room({
membership: MK.MatrixRoom.Join,
paginateBackFrom: mockHelper.promise(),
pinnedEventsTimeline: () => ({
count: 1,
}),
})
property var roomScrollToLatest: JsHelpers.factory.room({
- timeline: () => Qt.createComponent("QtQml.Models", "ListModel").createObject()
+ timeline: () => item.roomTimeline
})
Component {
id: pageInviteComp
Kazv.RoomPage {
id: pageInvite
room: roomInvite
}
}
Component {
id: pageJoinComp
Kazv.RoomPage {
id: pageJoin
room: roomJoin
}
}
Component {
id: pageLeaveComp
Kazv.RoomPage {
id: pageLeave
room: roomLeave
}
}
Component {
id: pageWithPinnedComp
Kazv.RoomPage {
id: pageWithPinned
room: roomWithPinned
}
}
Component {
id: pageScrollToLatestComp
Kazv.RoomPage {
id: pageScrollToLatest
room: roomScrollToLatest
}
}
Loader {
id: pageLoader
anchors.fill: item
}
TestCase {
id: roomPageTest
name: 'RoomPageTest'
when: windowShown
function init() {
matrixSession.userId = '@foo:example.org';
mockHelper.clearAll();
}
function test_inviteRoom() {
pageLoader.sourceComponent = pageInviteComp;
const sendMessageBox = findChild(pageLoader.item, 'sendMessageBox');
verify(sendMessageBox);
verify(!sendMessageBox.enabled);
}
function test_joinRoom() {
pageLoader.sourceComponent = pageJoinComp;
const sendMessageBox = findChild(pageLoader.item, 'sendMessageBox');
verify(sendMessageBox);
verify(sendMessageBox.enabled);
}
function test_leaveRoom() {
pageLoader.sourceComponent = pageLeaveComp;
const sendMessageBox = findChild(pageLoader.item, 'sendMessageBox');
verify(sendMessageBox);
verify(!sendMessageBox.enabled);
}
function test_receiptableEventId() {
pageLoader.sourceComponent = pageJoinComp;
verify(pageLoader.item.getLastReceiptableEventId(item.roomTimeline, item.roomTimeline.count) === '$event3');
}
function test_paginateBack() {
pageLoader.sourceComponent = pageJoinComp;
pageLoader.item.paginateBackRequested('$1');
tryVerify(() => roomJoin.paginateBackFrom.calledTimes() === 1);
verify(roomJoin.paginateBackFrom.lastArgs()[0] === '$1');
// one pagination under way, do not call it twice
pageLoader.item.paginateBackRequested('$1');
verify(roomJoin.paginateBackFrom.calledTimes() === 1);
// paginating from another event is not affected
pageLoader.item.paginateBackRequested('$2');
tryVerify(() => roomJoin.paginateBackFrom.calledTimes() === 2);
verify(roomJoin.paginateBackFrom.lastArgs()[0] === '$2');
// after the promise resolves, we can paginate from this id again
roomJoin.paginateBackFrom.retVals[0].resolve(true);
pageLoader.item.paginateBackRequested('$1');
tryVerify(() => roomJoin.paginateBackFrom.calledTimes() === 3);
verify(roomJoin.paginateBackFrom.lastArgs()[0] === '$1');
}
function test_pinnedAction() {
pageLoader.sourceComponent = pageJoinComp;
verify(!findChild(pageLoader.item, 'pinnedEventsAction').visible);
verify(!findChild(pageLoader.item, 'pinnedEventsAction').enabled);
pageLoader.sourceComponent = pageWithPinnedComp;
verify(findChild(pageLoader.item, 'pinnedEventsAction').visible);
verify(findChild(pageLoader.item, 'pinnedEventsAction').enabled);
}
function test_scrollToLatest() {
pageLoader.sourceComponent = pageScrollToLatestComp;
const action = findChild(pageLoader.item, 'scrollToLatestAction');
const roomTimelineView = findChild(pageLoader.item, 'roomTimelineView');
verify(action.visible);
verify(!action.enabled);
while (item.height >= roomTimelineView.contentHeight) {
roomTimelineView.model.append({});
roomTimelineView.forceLayout();
}
roomTimelineView.positionViewAtEnd();
verify(action.enabled);
action.triggered();
verify(!action.enabled);
verify(roomTimelineView.atYEnd);
}
}
}
diff --git a/src/tests/quick-tests/tst_RoomPinnedEventsPage.qml b/src/tests/quick-tests/tst_RoomPinnedEventsPage.qml
index 3dbc606..04c2b87 100644
--- a/src/tests/quick-tests/tst_RoomPinnedEventsPage.qml
+++ b/src/tests/quick-tests/tst_RoomPinnedEventsPage.qml
@@ -1,65 +1,51 @@
/*
* 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 '../../contents/ui' as Kazv
import 'test-helpers' as QmlHelpers
QmlHelpers.TestItem {
id: item
property var room: ({
name: 'some room',
pinnedEventsTimeline() {
return item.timeline;
},
})
- property var makeTextEvent: (i) => ({
- eventId: '$' + i,
- sender: '@foo:tusooa.xyz',
- type: 'm.room.message',
- stateKey: '',
- content: {
- msgtype: 'm.text',
- body: 'some body',
- },
- formattedTime: '4:06 P.M.',
- })
-
- property var timeline: ListModel {
- ListElement {}
- ListElement {}
-
- function at(index) {
- return makeTextEvent(index);
- }
+ property var timeline: QmlHelpers.MatrixRoomTimelineMock {
+ data: [
+ { eventOrTxnId: '$0', isLocalEcho: false },
+ { eventOrTxnId: '$1', isLocalEcho: false },
+ ]
}
Kazv.RoomPinnedEventsPage {
anchors.fill: parent
id: pinnedEventsPage
room: item.room
}
TestCase {
id: roomPinnedEventsPageTest
name: 'RoomPinnedEventsPageTest'
when: windowShown
function initTestCase() {
pinnedEventsPage.contentItem.clip = false;
}
function test_pinned() {
const roomTimelineView = findChild(pinnedEventsPage, 'roomTimelineView');
verify(roomTimelineView);
tryVerify(() => roomTimelineView.itemAtIndex(0));
tryVerify(() => roomTimelineView.itemAtIndex(1));
}
}
}
diff --git a/src/tests/quick-tests/tst_RoomTimelineView.qml b/src/tests/quick-tests/tst_RoomTimelineView.qml
index dd9c88b..004a8f1 100644
--- a/src/tests/quick-tests/tst_RoomTimelineView.qml
+++ b/src/tests/quick-tests/tst_RoomTimelineView.qml
@@ -1,173 +1,136 @@
/*
* 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 QtQuick.Layouts 1.15
import QtQuick.Window
import QtQuick.Controls
import QtTest 1.0
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.js' as JsHelpers
import 'test-helpers' as QmlHelpers
QmlHelpers.TestItem {
id: item
signal loadEventRequested(string eventId)
- property var makeLocalEcho: (i) => ({
- eventId: '',
- sender: '',
- type: 'm.room.message',
- stateKey: '',
- content: {
- msgtype: 'm.text',
- body: 'some body' + i,
- },
- encrypted: false,
- isState: false,
- unsignedData: {},
- isLocalEcho: true,
- isSending: true,
- isFailed: false,
- isAvailable: true,
- txnId: 'some-txn-id' + i,
- })
-
- property var makeTextEvent: (i) => ({
- eventId: '$' + i,
- sender: '@foo:tusooa.xyz',
- type: 'm.room.message',
- stateKey: '',
- content: {
- msgtype: 'm.text',
- body: 'some body',
- },
- isAvailable: true,
- formattedTime: '4:06 P.M.',
- })
property var room: ({
pinEvents: mockHelper.promise(),
unpinEvents: mockHelper.promise(),
redactEvent: mockHelper.promise(),
removeLocalEcho: mockHelper.promise(),
messageById(id) {
return { eventId: id, isAvailable: true };
},
localEchoById(id) {
return { txnId: id, isAvailable: true };
}
})
Kazv.RoomTimelineView {
anchors.fill: parent
id: roomTimelineView
room: item.room
timeline: item.timeline
}
property var backfillRequested: mockHelper.noop()
- property var timeline: ListModel {
- ListElement {}
- ListElement {}
-
- function at(index) {
- if (index < 1) {
- return makeLocalEcho(index);
- } else {
- return makeTextEvent(index);
- }
- }
+ property var timeline: QmlHelpers.MatrixRoomTimelineMock {
+ data: [
+ { eventOrTxnId: 'some-txn-id0', isLocalEcho: true },
+ { eventOrTxnId: '$1', isLocalEcho: true },
+ ]
}
TestCase {
id: roomTimelineViewTest
name: 'RoomTimelineViewTest'
when: windowShown
function init() {
mockHelper.clearAll();
}
function test_selected() {
tryVerify(() => roomTimelineView.itemAtIndex(0));
tryVerify(() => roomTimelineView.itemAtIndex(1));
const localEcho = roomTimelineView.itemAtIndex(0);
verify(!localEcho.isSelected);
const sentMessage = roomTimelineView.itemAtIndex(1);
verify(!sentMessage.isSelected);
roomTimelineView.selectedEventId = '$1';
verify(sentMessage.isSelected);
}
function test_pinSuccess() {
roomTimelineView.pinEventRequested('$2');
const popup = findChild(Overlay.overlay, 'pinEventPopup');
verify(popup);
tryVerify(() => popup.opened);
mouseClick(findChild(popup, 'confirmButton'));
verify(room.pinEvents.calledTimes() === 1);
verify(JsHelpers.deepEqual(room.pinEvents.lastArgs()[0], ['$2']));
tryVerify(() => !popup.opened);
tryVerify(() => !findChild(Overlay.overlay, 'pinEventPopup'));
room.pinEvents.lastRetVal().resolve(true, {});
compare(showPassiveNotification.calledTimes(), 1);
compare(showPassiveNotification.lastArgs()[0], l10n.get('event-pin-success-prompt'));
}
function test_pinFailure() {
roomTimelineView.pinEventRequested('$2');
const popup = findChild(Overlay.overlay, 'pinEventPopup');
verify(popup);
tryVerify(() => popup.opened);
mouseClick(findChild(popup, 'confirmButton'));
verify(room.pinEvents.calledTimes() === 1);
verify(JsHelpers.deepEqual(room.pinEvents.lastArgs()[0], ['$2']));
tryVerify(() => !popup.opened);
tryVerify(() => !findChild(Overlay.overlay, 'pinEventPopup'));
room.pinEvents.lastRetVal().resolve(false, { errorCode: 'M_FORBIDDEN', error: 'You are not allowed to do so' });
compare(showPassiveNotification.calledTimes(), 1);
compare(showPassiveNotification.lastArgs()[0], l10n.get('event-pin-failed-prompt', { errorCode: 'M_FORBIDDEN', errorMsg: 'You are not allowed to do so' }));
}
function test_unpinSuccess() {
roomTimelineView.unpinEventRequested('$2');
const popup = findChild(Overlay.overlay, 'unpinEventPopup');
verify(popup);
tryVerify(() => popup.opened);
mouseClick(findChild(popup, 'confirmButton'));
verify(room.unpinEvents.calledTimes() === 1);
verify(JsHelpers.deepEqual(room.unpinEvents.lastArgs()[0], ['$2']));
tryVerify(() => !popup.opened);
tryVerify(() => !findChild(Overlay.overlay, 'unpinEventPopup'));
room.unpinEvents.lastRetVal().resolve(true, {});
compare(showPassiveNotification.calledTimes(), 1);
compare(showPassiveNotification.lastArgs()[0], l10n.get('event-unpin-success-prompt'));
}
function test_unpinFailure() {
roomTimelineView.unpinEventRequested('$2');
const popup = findChild(Overlay.overlay, 'unpinEventPopup');
verify(popup);
tryVerify(() => popup.opened);
mouseClick(findChild(popup, 'confirmButton'));
verify(room.unpinEvents.calledTimes() === 1);
verify(JsHelpers.deepEqual(room.unpinEvents.lastArgs()[0], ['$2']));
tryVerify(() => !popup.opened);
tryVerify(() => !findChild(Overlay.overlay, 'unpinEventPopup'));
room.unpinEvents.lastRetVal().resolve(false, { errorCode: 'M_FORBIDDEN', error: 'You are not allowed to do so' });
compare(showPassiveNotification.calledTimes(), 1);
compare(showPassiveNotification.lastArgs()[0], l10n.get('event-unpin-failed-prompt', { errorCode: 'M_FORBIDDEN', errorMsg: 'You are not allowed to do so' }));
}
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 4:28 AM (17 h, 19 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801028
Default Alt Text
(61 KB)

Event Timeline