diff --git a/CMakeLists.txt b/CMakeLists.txt index 6334565d..01070a82 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -359,6 +359,8 @@ set(SRC_FILES src/timeline/CommunitiesModel.h src/timeline/DelegateChooser.cpp src/timeline/DelegateChooser.h + src/timeline/EventDelegateChooser.cpp + src/timeline/EventDelegateChooser.h src/timeline/EventStore.cpp src/timeline/EventStore.h src/timeline/InputBar.cpp @@ -882,6 +884,7 @@ target_link_libraries(nheko PRIVATE Qt::Gui Qt::Multimedia Qt::Qml + Qt::QmlPrivate Qt::QuickControls2 qt6keychain nlohmann_json::nlohmann_json diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml index a0ff0ff1..fde7ee57 100644 --- a/resources/qml/MessageView.qml +++ b/resources/qml/MessageView.qml @@ -20,6 +20,7 @@ Item { property int availableWidth: width property int padding: Nheko.paddingMedium property string searchString: "" + property Room roommodel: room // HACK: https://bugreports.qt.io/browse/QTBUG-83972, qtwayland cannot auto hide menu Connections { @@ -58,173 +59,33 @@ Item { spacing: 2 verticalLayoutDirection: ListView.BottomToTop - delegate: Item { + delegate: EventDelegateChooser { id: wrapper - - required property string blurhash - required property string body - required property string callType - required property var day - required property string duration - required property int encryptionError - required property string eventId - required property string filename - required property string filesize - required property string formattedBody - required property int index - required property bool isEditable - required property bool isEdited - required property bool isEncrypted - required property bool isOnlyEmoji - required property bool isSender - required property bool isStateEvent - required property int notificationlevel - required property int originalWidth - property var previousMessageDay: (index + 1) >= chat.count ? 0 : chat.model.dataByIndex(index + 1, Room.Day) - property bool previousMessageIsStateEvent: (index + 1) >= chat.count ? true : chat.model.dataByIndex(index + 1, Room.IsStateEvent) - property string previousMessageUserId: (index + 1) >= chat.count ? "" : chat.model.dataByIndex(index + 1, Room.UserId) - required property double proportionalHeight - required property var reactions - required property int relatedEventCacheBuster - required property string replyTo - required property string roomName - required property string roomTopic - property bool scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) - required property int status - required property string threadId - required property string thumbnailUrl - required property var timestamp - required property int trustlevel - required property int type - required property string typeString - required property string url - required property string userId - required property string userName - required property int userPowerlevel - ListView.delayRemove: true - anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined - height: (section.item?.height ?? 0) + timelinerow.height width: chat.delegateMaxWidth + height: main?.height ?? 10 + room: chatRoot.roommodel - Loader { - id: section + EventDelegateChoice { + roleValues: [ + MtxEvent.TextMessage, + MtxEvent.NoticeMessage, + ] + TextArea { + required property string body - property var day: wrapper.day - property bool isSender: wrapper.isSender - property bool isStateEvent: wrapper.isStateEvent - property int parentWidth: parent.width - property var previousMessageDay: wrapper.previousMessageDay - property bool previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent - property string previousMessageUserId: wrapper.previousMessageUserId - property date timestamp: wrapper.timestamp - property string userId: wrapper.userId - property string userName: wrapper.userName - property int userPowerlevel: wrapper.userPowerlevel - - active: previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent - //asynchronous: true - sourceComponent: sectionHeader - visible: status == Loader.Ready - z: 4 - } - TimelineRow { - id: timelinerow - - blurhash: wrapper.blurhash - body: wrapper.body - callType: wrapper.callType - duration: wrapper.duration - encryptionError: wrapper.encryptionError - eventId: chat.model, wrapper.eventId - filename: wrapper.filename - filesize: wrapper.filesize - formattedBody: wrapper.formattedBody - index: wrapper.index - isEditable: wrapper.isEditable - isEdited: wrapper.isEdited - isEncrypted: wrapper.isEncrypted - isOnlyEmoji: wrapper.isOnlyEmoji - isSender: wrapper.isSender - isStateEvent: wrapper.isStateEvent - notificationlevel: wrapper.notificationlevel - originalWidth: wrapper.originalWidth - proportionalHeight: wrapper.proportionalHeight - reactions: wrapper.reactions - relatedEventCacheBuster: wrapper.relatedEventCacheBuster - replyTo: wrapper.replyTo - roomName: wrapper.roomName - roomTopic: wrapper.roomTopic - status: wrapper.status - threadId: wrapper.threadId - thumbnailUrl: wrapper.thumbnailUrl - timestamp: wrapper.timestamp - trustlevel: wrapper.trustlevel - type: chat.model, wrapper.type - typeString: wrapper.typeString - url: wrapper.url - userId: wrapper.userId - userName: wrapper.userName - width: wrapper.width - y: section.visible && section.active ? section.y + section.height : 0 - - background: Rectangle { - id: scrollHighlight - - color: palette.highlight - enabled: false - opacity: 0 - visible: true - z: 1 - - states: State { - name: "revealed" - when: wrapper.scrolledToThis - } - transitions: Transition { - from: "" - to: "revealed" - - SequentialAnimation { - PropertyAnimation { - duration: 500 - easing.type: Easing.InOutQuad - from: 0 - properties: "opacity" - target: scrollHighlight - to: 1 - } - PropertyAnimation { - duration: 500 - easing.type: Easing.InOutQuad - from: 1 - properties: "opacity" - target: scrollHighlight - to: 0 - } - ScriptAction { - script: room.eventShown() - } - } - } - } - - onHoveredChanged: { - if (!Settings.mobileMode && hovered) { - if (!messageActions.hovered) { - messageActions.attached = timelinerow; - messageActions.model = timelinerow; - } - } + width: parent.width + text: body } } - Connections { - function onMovementEnded() { - if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) - chat.model.currentIndex = index; - } - target: chat + EventDelegateChoice { + roleValues: [ + ] + TextArea { + width: parent.width + text: "Unsupported" + } } } footer: Item { diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml index 16a31a3c..64fa80b1 100644 --- a/resources/qml/TimelineRow.qml +++ b/resources/qml/TimelineRow.qml @@ -147,7 +147,7 @@ AbstractButton { columns: Settings.bubbles ? 1 : 2 rowSpacing: 0 rows: Settings.bubbles ? 3 : 2 - +/* anchors { left: parent.left leftMargin: 4 @@ -230,6 +230,7 @@ AbstractButton { userId: r.userId userName: r.userName } + */ Row { id: metadata diff --git a/resources/qml/delegates/Reply.qml b/resources/qml/delegates/Reply.qml index 4d4983ac..64eb65a3 100644 --- a/resources/qml/delegates/Reply.qml +++ b/resources/qml/delegates/Reply.qml @@ -95,37 +95,11 @@ AbstractButton { onClicked: room.openUserProfile(userId) } - MessageDelegate { + Rectangle { Layout.leftMargin: 4 - Layout.preferredHeight: height - id: reply - blurhash: r.blurhash - body: r.body - formattedBody: r.formattedBody - eventId: r.eventId - filename: r.filename - filesize: r.filesize - proportionalHeight: r.proportionalHeight - type: r.type - typeString: r.typeString ?? "" - url: r.url - thumbnailUrl: r.thumbnailUrl - duration: r.duration - originalWidth: r.originalWidth - isOnlyEmoji: r.isOnlyEmoji - isStateEvent: r.isStateEvent - userId: r.userId - userName: r.userName - roomTopic: r.roomTopic - roomName: r.roomName - callType: r.callType - relatedEventCacheBuster: r.relatedEventCacheBuster - encryptionError: r.encryptionError - // This is disabled so that left clicking the reply goes to its location - enabled: false + Layout.preferredHeight: 20 Layout.fillWidth: true - isReply: true - keepFullText: r.keepFullText + color: "green" } } diff --git a/src/timeline/EventDelegateChooser.cpp b/src/timeline/EventDelegateChooser.cpp new file mode 100644 index 00000000..7618e20b --- /dev/null +++ b/src/timeline/EventDelegateChooser.cpp @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "EventDelegateChooser.h" +#include "TimelineModel.h" + +#include "Logging.h" + +#include +#include + +// privat qt headers to access required properties +#include +#include + +QQmlComponent * +EventDelegateChoice::delegate() const +{ + return delegate_; +} + +void +EventDelegateChoice::setDelegate(QQmlComponent *delegate) +{ + if (delegate != delegate_) { + delegate_ = delegate; + emit delegateChanged(); + emit changed(); + } +} + +QList +EventDelegateChoice::roleValues() const +{ + return roleValues_; +} + +void +EventDelegateChoice::setRoleValues(const QList &value) +{ + if (value != roleValues_) { + roleValues_ = value; + emit roleValuesChanged(); + emit changed(); + } +} + +QQmlListProperty +EventDelegateChooser::choices() +{ + return QQmlListProperty(this, + this, + &EventDelegateChooser::appendChoice, + &EventDelegateChooser::choiceCount, + &EventDelegateChooser::choice, + &EventDelegateChooser::clearChoices); +} + +void +EventDelegateChooser::appendChoice(QQmlListProperty *p, EventDelegateChoice *c) +{ + EventDelegateChooser *dc = static_cast(p->object); + dc->choices_.append(c); +} + +qsizetype +EventDelegateChooser::choiceCount(QQmlListProperty *p) +{ + return static_cast(p->object)->choices_.count(); +} +EventDelegateChoice * +EventDelegateChooser::choice(QQmlListProperty *p, qsizetype index) +{ + return static_cast(p->object)->choices_.at(index); +} +void +EventDelegateChooser::clearChoices(QQmlListProperty *p) +{ + static_cast(p->object)->choices_.clear(); +} + +void +EventDelegateChooser::componentComplete() +{ + QQuickItem::componentComplete(); + // eventIncubator.reset(eventIndex); +} + +void +EventDelegateChooser::DelegateIncubator::setInitialState(QObject *obj) +{ + auto item = qobject_cast(obj); + if (!item) + return; + + item->setParentItem(&chooser); + + auto roleNames = chooser.room_->roleNames(); + QHash nameToRole; + for (const auto &[k, v] : roleNames.asKeyValueRange()) { + nameToRole.insert(v, k); + } + + QHash roleToPropIdx; + std::vector roles; + + // Workaround for https://bugreports.qt.io/browse/QTBUG-98846 + QHash requiredProperties; + for (const auto &[propKey, prop] : + QQmlIncubatorPrivate::get(this)->requiredProperties()->asKeyValueRange()) { + requiredProperties.insert(prop.propertyName, propKey); + } + + // collect required properties + auto mo = obj->metaObject(); + for (int i = 0; i < mo->propertyCount(); i++) { + auto prop = mo->property(i); + // nhlog::ui()->critical("Found prop {}", prop.name()); + // See https://bugreports.qt.io/browse/QTBUG-98846 + if (!prop.isRequired() && !requiredProperties.contains(prop.name())) + continue; + + if (auto role = nameToRole.find(prop.name()); role != nameToRole.end()) { + roleToPropIdx.insert(*role, i); + roles.emplace_back(*role); + + nhlog::ui()->critical("Found prop {}, idx {}, role {}", prop.name(), i, *role); + } else { + nhlog::ui()->critical("Required property {} not found in model!", prop.name()); + } + } + + nhlog::ui()->debug("Querying data for id {}", currentId.toStdString()); + chooser.room_->multiData(currentId, forReply ? chooser.eventId_ : QString(), roles); + + QVariantMap rolesToSet; + for (const auto &role : roles) { + const auto &roleName = roleNames[role.role()]; + nhlog::ui()->critical("Setting role {}, {}", role.role(), roleName.toStdString()); + + mo->property(roleToPropIdx[role.role()]).write(obj, role.data()); + rolesToSet.insert(roleName, role.data()); + + if (const auto &req = requiredProperties.find(roleName); req != requiredProperties.end()) + QQmlIncubatorPrivate::get(this)->requiredProperties()->remove(*req); + } + + // setInitialProperties(rolesToSet); + + auto update = + [this, obj, roleToPropIdx = std::move(roleToPropIdx)](const QList &changedRoles) { + std::vector rolesToRequest; + + if (changedRoles.empty()) { + for (auto role : roleToPropIdx.keys()) + rolesToRequest.emplace_back(role); + } else { + for (auto role : changedRoles) { + if (roleToPropIdx.contains(role)) { + rolesToRequest.emplace_back(role); + } + } + } + + if (rolesToRequest.empty()) + return; + + auto mo = obj->metaObject(); + chooser.room_->multiData( + currentId, forReply ? chooser.eventId_ : QString(), rolesToRequest); + for (const auto &role : rolesToRequest) { + mo->property(roleToPropIdx[role.role()]).write(obj, role.data()); + } + }; + + if (!forReply) { + auto row = chooser.room_->idToIndex(currentId); + connect(chooser.room_, + &QAbstractItemModel::dataChanged, + obj, + [row, update](const QModelIndex &topLeft, + const QModelIndex &bottomRight, + const QList &changedRoles) { + if (row < topLeft.row() || row > bottomRight.row()) + return; + + update(changedRoles); + }); + } +} + +void +EventDelegateChooser::DelegateIncubator::reset(QString id) +{ + if (!chooser.room_ || id.isEmpty()) + return; + + nhlog::ui()->debug("Reset with id {}, reply {}", id.toStdString(), forReply); + + this->currentId = id; + + auto role = + chooser.room_ + ->dataById(id, TimelineModel::Roles::Type, forReply ? chooser.eventId_ : QString()) + .toInt(); + + for (const auto choice : qAsConst(chooser.choices_)) { + const auto &choiceValue = choice->roleValues(); + if (choiceValue.contains(role) || choiceValue.empty()) { + if (auto child = qobject_cast(object())) { + child->setParentItem(nullptr); + } + + choice->delegate()->create(*this, QQmlEngine::contextForObject(&chooser)); + return; + } + } +} + +void +EventDelegateChooser::DelegateIncubator::statusChanged(QQmlIncubator::Status status) +{ + if (status == QQmlIncubator::Ready) { + auto child = qobject_cast(object()); + if (child == nullptr) { + nhlog::ui()->error("Delegate has to be derived of Item!"); + return; + } + + child->setParentItem(&chooser); + QQmlEngine::setObjectOwnership(child, QQmlEngine::ObjectOwnership::JavaScriptOwnership); + if (forReply) + emit chooser.replyChanged(); + else + emit chooser.mainChanged(); + + } else if (status == QQmlIncubator::Error) { + auto errors_ = errors(); + for (const auto &e : qAsConst(errors_)) + nhlog::ui()->error("Error instantiating delegate: {}", e.toString().toStdString()); + } +} + diff --git a/src/timeline/EventDelegateChooser.h b/src/timeline/EventDelegateChooser.h new file mode 100644 index 00000000..ce22ca3a --- /dev/null +++ b/src/timeline/EventDelegateChooser.h @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// A DelegateChooser like the one, that was added to Qt5.12 (in labs), but compatible with older Qt +// versions see KDE/kquickitemviews see qtdeclarative/qqmldelagatecomponent + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "TimelineModel.h" + +class EventDelegateChoice : public QObject +{ + Q_OBJECT + QML_ELEMENT + Q_CLASSINFO("DefaultProperty", "delegate") + +public: + Q_PROPERTY(QList roleValues READ roleValues WRITE setRoleValues NOTIFY roleValuesChanged + REQUIRED FINAL) + Q_PROPERTY( + QQmlComponent *delegate READ delegate WRITE setDelegate NOTIFY delegateChanged REQUIRED FINAL) + + [[nodiscard]] QQmlComponent *delegate() const; + void setDelegate(QQmlComponent *delegate); + + [[nodiscard]] QList roleValues() const; + void setRoleValues(const QList &value); + +signals: + void delegateChanged(); + void roleValuesChanged(); + void changed(); + +private: + QList roleValues_; + QQmlComponent *delegate_ = nullptr; +}; + +class EventDelegateChooser : public QQuickItem +{ + Q_OBJECT + QML_ELEMENT + Q_CLASSINFO("DefaultProperty", "choices") + +public: + Q_PROPERTY(QQmlListProperty choices READ choices CONSTANT FINAL) + Q_PROPERTY(QQuickItem *main READ main NOTIFY mainChanged FINAL) + Q_PROPERTY(TimelineModel *room READ room WRITE setRoom NOTIFY roomChanged REQUIRED FINAL) + Q_PROPERTY(QString eventId READ eventId WRITE setEventId NOTIFY eventIdChanged REQUIRED FINAL) + Q_PROPERTY(QString replyTo READ replyTo WRITE setReplyTo NOTIFY replyToChanged REQUIRED FINAL) + + QQmlListProperty choices(); + + [[nodiscard]] QQuickItem *main() const + { + return qobject_cast(eventIncubator.object()); + } + + void setRoom(TimelineModel *m) + { + if (m != room_) { + room_ = m; + eventIncubator.reset(eventId_); + replyIncubator.reset(replyId); + emit roomChanged(); + } + } + [[nodiscard]] TimelineModel *room() { return room_; } + + void setEventId(QString idx) + { + eventId_ = idx; + emit eventIdChanged(); + } + [[nodiscard]] QString eventId() const { return eventId_; } + void setReplyTo(QString id) + { + replyId = id; + emit replyToChanged(); + } + [[nodiscard]] QString replyTo() const { return replyId; } + + void componentComplete() override; + +signals: + void mainChanged(); + void replyChanged(); + void roomChanged(); + void eventIdChanged(); + void replyToChanged(); + +private: + struct DelegateIncubator final : public QQmlIncubator + { + DelegateIncubator(EventDelegateChooser &parent, bool forReply) + : QQmlIncubator(QQmlIncubator::AsynchronousIfNested) + , chooser(parent) + , forReply(forReply) + { + } + void setInitialState(QObject *object) override; + void statusChanged(QQmlIncubator::Status status) override; + + void reset(QString id); + + EventDelegateChooser &chooser; + bool forReply; + QString currentId; + + QString instantiatedId; + int instantiatedRole = -1; + QAbstractItemModel *instantiatedModel = nullptr; + }; + + QVariant roleValue_; + QList choices_; + DelegateIncubator eventIncubator{*this, false}; + DelegateIncubator replyIncubator{*this, true}; + TimelineModel *room_{nullptr}; + QString eventId_; + QString replyId; + + static void appendChoice(QQmlListProperty *, EventDelegateChoice *); + static qsizetype choiceCount(QQmlListProperty *); + static EventDelegateChoice *choice(QQmlListProperty *, qsizetype index); + static void clearChoices(QQmlListProperty *); +}; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index b2a036c5..69ab3f5a 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -926,6 +926,26 @@ TimelineModel::multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSp } } +void +TimelineModel::multiData(const QString &id, + const QString &relatedTo, + QModelRoleDataSpan roleDataSpan) const +{ + if (id.isEmpty()) + return; + + auto event = events.get(id.toStdString(), relatedTo.toStdString()); + + if (!event) + return; + + for (QModelRoleData &roleData : roleDataSpan) { + int role = roleData.role(); + + roleData.setData(data(*event, role)); + } +} + QVariant TimelineModel::dataById(const QString &id, int role, const QString &relatedTo) { diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index fccc99eb..57caf26d 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -286,6 +286,8 @@ public: int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; void multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSpan) const override; + void + multiData(const QString &id, const QString &relatedTo, QModelRoleDataSpan roleDataSpan) const; QVariant data(const mtx::events::collections::TimelineEvents &event, int role) const; Q_INVOKABLE QVariant dataById(const QString &id, int role, const QString &relatedTo); Q_INVOKABLE QVariant dataByIndex(int i, int role = Qt::DisplayRole) const