diff --git a/autotests/timelinemessagemodeltest.cpp b/autotests/timelinemessagemodeltest.cpp index ef5b419ed..3068a31ca 100644 --- a/autotests/timelinemessagemodeltest.cpp +++ b/autotests/timelinemessagemodeltest.cpp @@ -208,7 +208,7 @@ void TimelineMessageModelTest::idToRow() auto room = new TestUtils::TestRoom(connection, u"#myroom:kde.org"_s, u"test-min-sync.json"_s); model->setRoom(room); - QCOMPARE(model->eventIdToRow(u"$153456789:example.org"_s), 0); + QCOMPARE(model->indexforEventId(u"$153456789:example.org"_s).row(), 0); } void TimelineMessageModelTest::cleanup() diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index abaf66424..82e9f7f87 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -63,7 +63,6 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE qml/ExplorerDelegate.qml qml/ImageEditorPage.qml qml/NeochatMaximizeComponent.qml - qml/TypingPane.qml qml/QuickSwitcher.qml qml/AttachmentPane.qml qml/QuickFormatBar.qml diff --git a/src/app/qml/RoomPage.qml b/src/app/qml/RoomPage.qml index 6c0882b5e..4ba2303ee 100644 --- a/src/app/qml/RoomPage.qml +++ b/src/app/qml/RoomPage.qml @@ -112,15 +112,10 @@ Kirigami.Page { active: root.currentRoom && !root.currentRoom.isInvite && !root.loading && !root.currentRoom.isSpace sourceComponent: TimelineView { id: timelineView - currentRoom: root.currentRoom - page: root - timelineModel: root.timelineModel + room: root.currentRoom messageFilterModel: root.messageFilterModel - onFocusChatBar: { - if (chatBarLoader.item) { - chatBarLoader.item.forceActiveFocus(); - } - } + compactLayout: NeoChatConfig.compactLayout + fileDropEnabled: !Controller.isFlatpak } } @@ -175,11 +170,6 @@ Kirigami.Page { width: parent.width currentRoom: root.currentRoom connection: root.connection - onMessageSent: { - if (!timelineViewLoader.item.atYEnd) { - timelineViewLoader.item.goToLastMessage(); - } - } } } diff --git a/src/app/roommanager.cpp b/src/app/roommanager.cpp index 1ce0a62fb..270477b75 100644 --- a/src/app/roommanager.cpp +++ b/src/app/roommanager.cpp @@ -59,9 +59,9 @@ RoomManager::RoomManager(QObject *parent) m_directChatsConfig = m_config->group(u"DirectChatsActive"_s); connect(this, &RoomManager::currentRoomChanged, this, [this]() { + m_userListModel->setRoom(m_currentRoom); m_timelineModel->setRoom(m_currentRoom); m_sortFilterRoomTreeModel->setCurrentRoom(m_currentRoom); - m_userListModel->setRoom(m_currentRoom); }); connect(&Controller::instance(), &Controller::activeConnectionChanged, this, [this](NeoChatConnection *connection) { @@ -96,6 +96,7 @@ RoomManager::RoomManager(QObject *parent) m_messageFilterModel->invalidate(); } }); + connect(m_timelineModel->timelineMessageModel(), &MessageModel::modelResetComplete, this, &RoomManager::activateUserModel); MessageFilterModel::setShowAllEvents(NeoChatConfig::self()->showAllEvents()); connect(NeoChatConfig::self(), &NeoChatConfig::ShowAllEventsChanged, this, [this] { MessageFilterModel::setShowAllEvents(NeoChatConfig::self()->showAllEvents()); diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index 23a239e7f..7540aa1e0 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -160,11 +160,6 @@ QQC2.Control { } ] - /** - * @brief A message has been sent from the chat bar. - */ - signal messageSent - spacing: 0 Kirigami.Theme.colorSet: Kirigami.Theme.View @@ -436,7 +431,6 @@ QQC2.Control { repeatTimer.stop(); root.currentRoom.markAllMessagesAsRead(); textField.clear(); - messageSent(); } function formatText(format, selectionStart, selectionEnd) { diff --git a/src/timeline/CMakeLists.txt b/src/timeline/CMakeLists.txt index 2a8917302..db44b18c5 100644 --- a/src/timeline/CMakeLists.txt +++ b/src/timeline/CMakeLists.txt @@ -21,6 +21,7 @@ ecm_add_qml_module(Timeline GENERATE_PLUGIN_SOURCE AvatarFlow.qml SectionDelegate.qml QuickActions.qml + TypingPane.qml BaseMessageComponentChooser.qml MessageComponentChooser.qml ReplyMessageComponentChooser.qml diff --git a/src/timeline/TimelineView.qml b/src/timeline/TimelineView.qml index 71667ad1a..6efad40e9 100644 --- a/src/timeline/TimelineView.qml +++ b/src/timeline/TimelineView.qml @@ -1,41 +1,22 @@ -// SPDX-FileCopyrightText: 2020 Carl Schwan -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts -import Qt.labs.qmlmodels -import QtQuick.Window -import org.kde.kirigamiaddons.components as KirigamiComponents import org.kde.kirigami as Kirigami -import org.kde.kitemmodels +import org.kde.kirigamiaddons.components as KirigamiComponents -import org.kde.neochat -import org.kde.neochat.timeline import org.kde.neochat.libneochat as LibNeoChat QQC2.ScrollView { id: root - required property NeoChatRoom currentRoom - onCurrentRoomChanged: { - roomChanging = true; - roomChangingTimer.restart(); - applicationWindow().hoverLinkIndicator.text = ""; - messageListView.positionViewAtBeginning(); - hasScrolledUpBefore = false; - } - property bool roomChanging: false - - required property Item page /** - * @brief The TimelineModel to use. - * - * Required so that new events can be requested when the end of the current - * local timeline is reached. + * @brief The NeoChatRoom the delegate is being displayed in. */ - required property TimelineModel timelineModel + required property LibNeoChat.NeoChatRoom room /** * @brief The MessageFilterModel to use. @@ -44,130 +25,157 @@ QQC2.ScrollView { */ required property MessageFilterModel messageFilterModel + /** + * @brief Whether the timeline is scrolled to the end. + */ readonly property bool atYEnd: messageListView.atYEnd + /** + * @brief Whether the timeline ListView is interactive. + */ property alias interactive: messageListView.interactive - /// Used to determine if scrolling to the bottom should mark the message as unread - property bool hasScrolledUpBefore: false + /** + * @brief Whether the compact message layout is to be used. + */ + required property bool compactLayout - signal focusChatBar + /** + * @brief Whether the compact message layout is to be used. + */ + property bool fileDropEnabled: true + + /** + * @brief Shift the view to the given event ID. + */ + function goToEvent(eventId) { + const index = messageListView.model.indexforEventId(eventId) + if (!index.valid) { + messageListView.positionViewAtEnd(); + return; + } + messageListView.positionViewAtIndex(index.row, ListView.Center); + messageListView.itemAtIndex(index.row).isTemporaryHighlighted = true; + } + + /** + * @brief Shift the view to the latest message. + * + * All messages will be marked as read. + */ + function goToLastMessage() { + room.markAllMessagesAsRead(); + messageListView.positionViewAtBeginning(); + } + + /** + * @brief Move the timeline up a page. + */ + function pageUp() { + const newContentY = messageListView.contentY - messageListView.height / 2; + const minContentY = messageListView.originY + messageListView.topMargin; + messageListView.contentY = Math.max(newContentY, minContentY); + messageListView.returnToBounds(); + } + + /** + * @brief Move the timeline down a page. + */ + function pageDown() { + const newContentY = messageListView.contentY + messageListView.height / 2; + const maxContentY = messageListView.originY + messageListView.bottomMargin + messageListView.contentHeight - messageListView.height; + messageListView.contentY = Math.min(newContentY, maxContentY); + messageListView.returnToBounds(); + } QQC2.ScrollBar.vertical.interactive: false ListView { id: messageListView - readonly property int largestVisibleIndex: count > 0 ? indexAt(contentX + (width / 2), contentY + height - 1) : -1 - readonly property var sectionBannerItem: contentHeight >= height ? itemAtIndex(sectionBannerIndex()) : undefined - - // Spacing needs to be zero or the top sectionLabel overlay will be disrupted. - // This is because itemAt returns null in the spaces. - // All spacing should be handled by the delegates themselves - spacing: 0 - verticalLayoutDirection: ListView.BottomToTop - clip: true - interactive: Kirigami.Settings.isMobile - - model: root.messageFilterModel - - onCountChanged: if (root.roomChanging) { - root.positionViewAtBeginning(); - } - - Timer { - interval: 1000 - running: messageListView.atYBeginning - triggeredOnStart: true - onTriggered: { - if (messageListView.atYBeginning && root.timelineModel.timelineMessageModel.canFetchMore(root.timelineModel.index(0, 0))) { - root.timelineModel.timelineMessageModel.fetchMore(root.timelineModel.index(0, 0)); - } + /** + * @brief Whether all unread messages in the timeline are visible. + */ + function allUnreadVisible() { + let readMarkerRow = model.readMarkerIndex?.row ?? -1; + if (readMarkerRow >= 0 && readMarkerRow < oldestVisibleIndex() && atYEnd) { + return true; } - repeat: true + return false; } - // HACK: The view should do this automatically but doesn't. - onAtYBeginningChanged: if (atYBeginning && root.timelineModel.timelineMessageModel.canFetchMore(root.timelineModel.index(0, 0))) { - root.timelineModel.timelineMessageModel.fetchMore(root.timelineModel.index(0, 0)); - } - - Timer { - id: roomChangingTimer - interval: 1000 - onTriggered: { - root.roomChanging = false; - markReadIfVisibleTimer.reset(); - RoomManager.activateUserModel(); - } - } - onAtYEndChanged: if (!root.roomChanging) { - if (atYEnd && root.hasScrolledUpBefore) { - if (QQC2.ApplicationWindow.window && (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden)) { - root.currentRoom.markAllMessagesAsRead(); - } - root.hasScrolledUpBefore = false; - } else if (!atYEnd) { - root.hasScrolledUpBefore = true; - } - } - - // Not rendered because the sections are part of the MessageDelegate.qml, this is only so that items have the section property available for use by sectionBanner. - // This is due to the fact that the ListView verticalLayout is BottomToTop. - // This also flips the sections which would appear at the bottom but for a timeline they still need to be at the top (bottom from the qml perspective). - // There is currently no option to put section headings at the bottom in qml. - section.property: "section" - - function sectionBannerIndex() { - let center = messageListView.x + messageListView.width / 2; - let yStart = messageListView.y + messageListView.contentY; + /** + * @brief Get the oldest visible message. + */ + function oldestVisibleIndex() { + let center = x + width / 2; let index = -1; let i = 0; while (index === -1 && i < 100) { - index = messageListView.indexAt(center, yStart + i); + index = indexAt(center, y + contentY + i); i++; } return index; } - footer: Item { - z: 3 - width: root.width - visible: !NeoChatConfig.blur + /** + * @brief Get the newest visible message. + */ + function newestVisibleIndex() { + let center = x + width / 2; + let index = -1; + let i = 0; + while (index === -1 && i < 100) { + index = indexAt(center, y + contentY + height - i); + i++; + } + return index; + } - SectionDelegate { - id: sectionDelegate - anchors.leftMargin: state === "alignLeft" ? Kirigami.Units.largeSpacing : 0 - state: NeoChatConfig.compactLayout ? "alignLeft" : "alignCenter" - // Align left when in compact mode and center when using bubbles - states: [ - State { - name: "alignLeft" - AnchorChanges { - target: sectionDelegate - anchors.horizontalCenter: undefined - anchors.left: parent ? parent.left : undefined - } - }, - State { - name: "alignCenter" - AnchorChanges { - target: sectionDelegate - anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined - anchors.left: undefined - } - } - ] + spacing: 0 + verticalLayoutDirection: ListView.BottomToTop + clip: true + interactive: Kirigami.Settings.isMobile - width: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.timelineWidth : 0 - labelText: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.ListView.section : "" - colorSet: NeoChatConfig.compactLayout ? Kirigami.Theme.View : Kirigami.Theme.Window + Component.onCompleted: { + positionViewAtBeginning(); + } + Connections { + target: messageListView.model.sourceModel.timelineMessageModel + + function onModelAboutToBeReset() { + applicationWindow().hoverLinkIndicator.text = ""; + _private.hasScrolledUpBefore = false; + } + + function onModelResetComplete() { + messageListView.positionViewAtBeginning(); + } + + function onReadMarkerAdded() { + if (messageListView.allUnreadVisible()) { + root.room.markAllMessagesAsRead(); + } + } + + function onNewLocalUserEventAdded() { + messageListView.positionViewAtBeginning(); + root.room.markAllMessagesAsRead(); } } - footerPositioning: ListView.OverlayHeader + onAtYEndChanged: if (atYEnd && _private.hasScrolledUpBefore) { + if (QQC2.ApplicationWindow.window && (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden)) { + root.room.markAllMessagesAsRead(); + } + _private.hasScrolledUpBefore = false; + } else if (!atYEnd) { + _private.hasScrolledUpBefore = true; + } + + model: root.messageFilterModel delegate: EventDelegate { - room: root.currentRoom + room: root.room } KirigamiComponents.FloatingButton { @@ -186,23 +194,19 @@ QQC2.ScrollView { padding: Kirigami.Units.largeSpacing z: 2 - visible: (!root.currentRoom?.partiallyReadStats.empty()) + visible: (!root.room?.partiallyReadStats.empty()) - text: root.currentRoom.readMarkerLoaded ? i18n("Jump to first unread message") : i18n("Jump to oldest loaded message") + text: root.room.readMarkerLoaded ? i18n("Jump to first unread message") : i18n("Jump to oldest loaded message") action: Kirigami.Action { onTriggered: { - if (!Kirigami.Settings.isMobile) { - root.focusChatBar(); - } goReadMarkerFab.textChanged() - messageListView.goToEvent(root.currentRoom.lastFullyReadEventId); + root.goToEvent(root.room.lastFullyReadEventId); } icon.name: "go-up" shortcut: "Shift+PgUp" } QQC2.ToolTip { - id: goReadMarkerFabTooltip text: goReadMarkerFab.text delay: Kirigami.Units.toolTipDelay visible: goReadMarkerFab.hovered @@ -222,29 +226,30 @@ QQC2.ScrollView { padding: Kirigami.Units.largeSpacing z: 2 - visible: !messageListView.atYEnd + visible: !root.atYEnd + + text: i18n("Jump to latest message") action: Kirigami.Action { onTriggered: { - messageListView.goToLastMessage(); - root.currentRoom.markAllMessagesAsRead(); + root.positionViewAtBeginning(); + root.room.markAllMessagesAsRead(); } icon.name: "go-down" + shortcut: "Shift+PgDown" } QQC2.ToolTip { - text: i18n("Jump to latest message") + text: goMarkAsReadFab.text + delay: Kirigami.Units.toolTipDelay + visible: goMarkAsReadFab.hovered } } - Component.onCompleted: { - positionViewAtBeginning(); - } - DropArea { id: dropAreaFile anchors.fill: parent - onDropped: root.currentRoom.mainCache.attachmentPath = drop.urls[0] - enabled: !Controller.isFlatpak + onDropped: drop => { root.room.mainCache.attachmentPath = drop.urls[0] } + enabled: root.fileDropEnabled } QQC2.Pane { @@ -261,19 +266,9 @@ QQC2.ScrollView { } } - LibNeoChat.DelegateSizeHelper { - id: typingPaneSizeHelper - parentItem: typingPaneContainer - startBreakpoint: Kirigami.Units.gridUnit * 46 - endBreakpoint: Kirigami.Units.gridUnit * 66 - startPercentWidth: 100 - endPercentWidth: NeoChatConfig.compactLayout ? 100 : 85 - maxWidth: NeoChatConfig.compactLayout ? -1 : Kirigami.Units.gridUnit * 60 - } - RowLayout { id: typingPaneContainer - visible: root.currentRoom && root.currentRoom.otherMembersTyping.length > 0 + visible: root.room && root.room.otherMembersTyping.length > 0 anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom @@ -292,110 +287,37 @@ QQC2.ScrollView { Layout.maximumWidth: typingPaneSizeHelper.availableWidth TypingPane { id: typingPane - labelText: visible ? i18ncp("Message displayed when some users are typing", "%2 is typing", "%2 are typing", root.currentRoom.otherMembersTyping.length, root.currentRoom.otherMembersTyping.map(member => member.displayName).join(", ")) : "" + labelText: visible ? i18ncp("Message displayed when some users are typing", "%2 is typing", "%2 are typing", root.room.otherMembersTyping.length, root.room.otherMembersTyping.map(member => member.displayName).join(", ")) : "" } } - } - function goToEvent(eventID) { - const index = eventToIndex(eventID); - if (index == -1) { - messageListView.positionViewAtEnd(); - return; - } - messageListView.positionViewAtIndex(index, ListView.Center); - itemAtIndex(index).isTemporaryHighlighted = true; - } - - Connections { - target: root.timelineModel - - function onRowsInserted() { - markReadIfVisibleTimer.reset(); + LibNeoChat.DelegateSizeHelper { + id: typingPaneSizeHelper + parentItem: typingPaneContainer + startBreakpoint: Kirigami.Units.gridUnit * 46 + endBreakpoint: Kirigami.Units.gridUnit * 66 + startPercentWidth: 100 + endPercentWidth: root.compactLayout ? 100 : 85 + maxWidth: root.compactLayout ? -1 : Kirigami.Units.gridUnit * 60 } } Timer { - id: markReadIfVisibleTimer - running: messageListView.allUnreadVisible() && applicationWindow().active && (root.currentRoom.timelineSize > 0 || root.currentRoom.allHistoryLoaded) && applicationWindow().pageStack.visibleItems.includes(root.page) - interval: 10000 - onTriggered: root.currentRoom.markAllMessagesAsRead() - - function reset() { - restart(); - running = Qt.binding(function () { - return messageListView.allUnreadVisible() && applicationWindow().active && (root.currentRoom.timelineSize > 0 || root.currentRoom.allHistoryLoaded) && applicationWindow().pageStack.visibleItems.includes(root.page); - }); + interval: 1000 + running: messageListView.atYBeginning + triggeredOnStart: true + onTriggered: { + if (messageListView.atYBeginning && messageListView.model.sourceModel.canFetchMore(messageListView.model.index(0, 0))) { + messageListView.model.sourceModel.fetchMore(messageListView.model.index(0, 0)); + } } + repeat: true } - function goToLastMessage() { - root.currentRoom.markAllMessagesAsRead(); - // scroll to the very end, i.e to messageListView.YEnd - messageListView.positionViewAtIndex(0, ListView.End); + QtObject { + id: _private + // Used to determine if scrolling to the bottom should mark the message as unread + property bool hasScrolledUpBefore: false } - - function eventToIndex(eventID) { - const index = root.timelineModel.timelineMessageModel.eventIdToRow(eventID); - if (index === -1) - return -1; - return root.messageFilterModel.mapFromSource(root.timelineModel.index(index, 0)).row; - } - - function firstVisibleIndex() { - let center = messageListView.x + messageListView.width / 2; - let index = -1; - let i = 0; - while (index === -1 && i < 100) { - index = messageListView.indexAt(center, messageListView.y + messageListView.contentY + i); - i++; - } - return index; - } - - function lastVisibleIndex() { - let center = messageListView.x + messageListView.width / 2; - let index = -1; - let i = 0; - while (index === -1 && i < 100) { - index = messageListView.indexAt(center, messageListView.y + messageListView.contentY + messageListView.height - i); - i++; - } - return index; - } - - function allUnreadVisible() { - let readMarkerRow = eventToIndex(root.currentRoom.lastFullyReadEventId); - if (readMarkerRow >= 0 && readMarkerRow < firstVisibleIndex() && messageListView.atYEnd) { - return true; - } - return false; - } - } - - function goToLastMessage() { - messageListView.goToLastMessage(); - } - - function pageUp() { - const newContentY = messageListView.contentY - messageListView.height / 2; - const minContentY = messageListView.originY + messageListView.topMargin; - messageListView.contentY = Math.max(newContentY, minContentY); - messageListView.returnToBounds(); - } - - function pageDown() { - const newContentY = messageListView.contentY + messageListView.height / 2; - const maxContentY = messageListView.originY + messageListView.bottomMargin + messageListView.contentHeight - messageListView.height; - messageListView.contentY = Math.min(newContentY, maxContentY); - messageListView.returnToBounds(); - } - - function positionViewAtBeginning() { - messageListView.positionViewAtBeginning(); - } - - function goToEvent(eventId) { - messageListView.goToEvent(eventId); } } diff --git a/src/app/qml/TypingPane.qml b/src/timeline/TypingPane.qml similarity index 100% rename from src/app/qml/TypingPane.qml rename to src/timeline/TypingPane.qml diff --git a/src/timeline/messagedelegate.cpp b/src/timeline/messagedelegate.cpp index a62750fe7..4bd68052d 100644 --- a/src/timeline/messagedelegate.cpp +++ b/src/timeline/messagedelegate.cpp @@ -654,6 +654,7 @@ void MessageDelegateBase::setIsTemporaryHighlighted(bool isTemporaryHighlighted) } m_temporaryHighlightTimer->start(1500); connect(m_temporaryHighlightTimer, &QTimer::timeout, this, [this]() { + m_temporaryHighlightTimer->stop(); m_temporaryHighlightTimer->deleteLater(); Q_EMIT isTemporaryHighlightedChanged(); }); diff --git a/src/timeline/models/messagefiltermodel.cpp b/src/timeline/models/messagefiltermodel.cpp index 7259bbf83..607b58ee4 100644 --- a/src/timeline/models/messagefiltermodel.cpp +++ b/src/timeline/models/messagefiltermodel.cpp @@ -7,7 +7,8 @@ #include #include "enums/delegatetype.h" -#include "timelinemessagemodel.h" +#include "messagemodel.h" +#include "models/timelinemodel.h" using namespace Quotient; @@ -19,6 +20,37 @@ MessageFilterModel::MessageFilterModel(QObject *parent, QAbstractItemModel *sour { Q_ASSERT(sourceModel); setSourceModel(sourceModel); + + if (auto model = dynamic_cast(sourceModel)) { + connect(model->timelineMessageModel(), &MessageModel::readMarkerIndexChanged, this, &MessageFilterModel::readMarkerIndexChanged); + } +} + +QPersistentModelIndex MessageFilterModel::readMarkerIndex() const +{ + // Check if sourceModel is a message model. + auto messageModel = dynamic_cast(sourceModel()); + bool timelineModelIsSource = false; + // See if it's a timeline model. + if (!messageModel) { + if (const auto timelineModel = dynamic_cast(sourceModel())) { + messageModel = timelineModel->timelineMessageModel(); + timelineModelIsSource = true; + if (!messageModel) { + return {}; + } + } + } + + auto eventIndex = messageModel->readMarkerIndex(); + if (!eventIndex.isValid()) { + return {}; + } + + if (timelineModelIsSource) { + eventIndex = dynamic_cast(sourceModel())->mapFromSource(eventIndex); + } + return mapFromSource(eventIndex); } bool MessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const @@ -93,6 +125,33 @@ QHash MessageFilterModel::roleNames() const return roles; } +QModelIndex MessageFilterModel::indexforEventId(const QString &eventId) const +{ + // Check if sourceModel is a message model. + auto messageModel = dynamic_cast(sourceModel()); + bool timelineModelIsSource = false; + // See if it's a timeline model. + if (!messageModel) { + if (const auto timelineModel = dynamic_cast(sourceModel())) { + messageModel = timelineModel->timelineMessageModel(); + timelineModelIsSource = true; + if (!messageModel) { + return {}; + } + } + } + + auto eventIndex = messageModel->indexforEventId(eventId); + if (!eventIndex.isValid()) { + return {}; + } + + if (timelineModelIsSource) { + eventIndex = dynamic_cast(sourceModel())->mapFromSource(eventIndex); + } + return mapFromSource(eventIndex); +} + bool MessageFilterModel::showAuthor(QModelIndex index) const { for (auto r = index.row() + 1; r < rowCount(); ++r) { diff --git a/src/timeline/models/messagefiltermodel.h b/src/timeline/models/messagefiltermodel.h index 6645f19d9..63de09bc9 100644 --- a/src/timeline/models/messagefiltermodel.h +++ b/src/timeline/models/messagefiltermodel.h @@ -6,8 +6,7 @@ #include #include -#include "timelinemessagemodel.h" -#include "timelinemodel.h" +#include "models/timelinemessagemodel.h" /** * @class MessageFilterModel @@ -25,6 +24,11 @@ class MessageFilterModel : public QSortFilterProxyModel Q_OBJECT QML_ELEMENT + /** + * @brief The model index of the read marker. + */ + Q_PROPERTY(QPersistentModelIndex readMarkerIndex READ readMarkerIndex NOTIFY readMarkerIndexChanged) + public: /** * @brief Defines the model roles. @@ -37,6 +41,8 @@ public: LastRole, // Keep this last }; + QPersistentModelIndex readMarkerIndex() const; + explicit MessageFilterModel(QObject *parent = nullptr, QAbstractItemModel *sourceModel = nullptr); /** @@ -58,9 +64,20 @@ public: */ [[nodiscard]] QHash roleNames() const override; + /** + * @brief Get the QModelIndex the given event ID in the model. + */ + Q_INVOKABLE QModelIndex indexforEventId(const QString &eventId) const; + static void setShowAllEvents(bool enabled); static void setShowDeletedMessages(bool enabled); +Q_SIGNALS: + /** + * @brief Emitted when the reader marker index is changed. + */ + void readMarkerIndexChanged(); + private: static bool m_showAllEvents; static bool m_showDeletedMessages; diff --git a/src/timeline/models/messagemodel.cpp b/src/timeline/models/messagemodel.cpp index b801c8d2b..214ca8cf4 100644 --- a/src/timeline/models/messagemodel.cpp +++ b/src/timeline/models/messagemodel.cpp @@ -42,8 +42,10 @@ MessageModel::MessageModel(QObject *parent) }); connect(this, &MessageModel::threadsEnabledChanged, this, [this]() { + Q_EMIT modelAboutToBeReset(); beginResetModel(); endResetModel(); + Q_EMIT modelResetComplete(); }); } @@ -60,6 +62,7 @@ void MessageModel::setRoom(NeoChatRoom *room) clearModel(); + Q_EMIT modelAboutToBeReset(); beginResetModel(); m_room = room; if (m_room != nullptr) { @@ -67,6 +70,7 @@ void MessageModel::setRoom(NeoChatRoom *room) } Q_EMIT roomChanged(); endResetModel(); + Q_EMIT modelResetComplete(); } int MessageModel::timelineServerIndex() const @@ -74,6 +78,11 @@ int MessageModel::timelineServerIndex() const return 0; } +QPersistentModelIndex MessageModel::readMarkerIndex() const +{ + return m_lastReadEventIndex; +} + std::optional> MessageModel::getEventForIndex(QModelIndex index) const { Q_UNUSED(index) @@ -342,18 +351,18 @@ QHash MessageModel::roleNames() const return roles; } -int MessageModel::eventIdToRow(const QString &eventID) const +QModelIndex MessageModel::indexforEventId(const QString &eventId) const { if (m_room == nullptr) { - return -1; + return {}; } - const auto it = m_room->findInTimeline(eventID); + const auto it = m_room->findInTimeline(eventId); if (it == m_room->historyEdge()) { - // qWarning() << "Trying to find inexistent event:" << eventID; - return -1; + qWarning() << "Trying to find non-existent event:" << eventId; + return {}; } - return it - m_room->messageEvents().rbegin() + timelineServerIndex(); + return index(it - m_room->messageEvents().rbegin() + timelineServerIndex()); } void MessageModel::fullEventRefresh(int row) @@ -455,6 +464,47 @@ void MessageModel::createEventObjects(const Quotient::RoomEvent *event) } } +void MessageModel::moveReadMarker(const QString &toEventId) +{ + const auto timelineIt = m_room->findInTimeline(toEventId); + if (timelineIt == m_room->historyEdge()) { + return; + } + int newRow = int(timelineIt - m_room->messageEvents().rbegin()) + timelineServerIndex(); + + if (!m_lastReadEventIndex.isValid()) { + // Not valid index means we don't display any marker yet, in this case + // we create the new index and insert the row in case the read marker + // need to be displayed. + if (newRow > timelineServerIndex()) { + // The user didn't read all the messages yet. + beginInsertRows({}, newRow, newRow); + m_lastReadEventIndex = QPersistentModelIndex(index(newRow, 0)); + endInsertRows(); + Q_EMIT readMarkerIndexChanged(); + Q_EMIT readMarkerAdded(); + return; + } + // The user read all the messages and we didn't display any read marker yet + // => do nothing + return; + } + if (newRow <= timelineServerIndex()) { + // The user read all the messages => remove read marker + beginRemoveRows({}, m_lastReadEventIndex.row(), m_lastReadEventIndex.row()); + m_lastReadEventIndex = QModelIndex(); + endRemoveRows(); + Q_EMIT readMarkerIndexChanged(); + return; + } + + // The user didn't read all the messages yet but moved the reader marker. + beginMoveRows({}, m_lastReadEventIndex.row(), m_lastReadEventIndex.row(), {}, newRow); + m_lastReadEventIndex = QPersistentModelIndex(index(newRow, 0)); + endMoveRows(); + Q_EMIT readMarkerIndexChanged(); +} + void MessageModel::clearModel() { if (m_room) { @@ -462,10 +512,12 @@ void MessageModel::clearModel() // HACK: Reset the model to a null room first to make sure QML dismantles // last room's objects before the room is actually changed + Q_EMIT modelAboutToBeReset(); beginResetModel(); m_room->disconnect(this); m_room = nullptr; endResetModel(); + Q_EMIT modelResetComplete(); // Because we don't want any of the object deleted before the model is cleared. oldRoom->setVisible(false); diff --git a/src/timeline/models/messagemodel.h b/src/timeline/models/messagemodel.h index 43bb17ac3..9912890e9 100644 --- a/src/timeline/models/messagemodel.h +++ b/src/timeline/models/messagemodel.h @@ -51,6 +51,11 @@ class MessageModel : public QAbstractListModel */ Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + /** + * @brief The model index of the read marker. + */ + Q_PROPERTY(QPersistentModelIndex readMarkerIndex READ readMarkerIndex NOTIFY readMarkerIndexChanged) + public: /** * @brief Defines the model roles. @@ -94,6 +99,8 @@ public: [[nodiscard]] NeoChatRoom *room() const; void setRoom(NeoChatRoom *room); + QPersistentModelIndex readMarkerIndex() const; + /** * @brief Get the given role value at the given index. * @@ -109,9 +116,9 @@ public: [[nodiscard]] QHash roleNames() const override; /** - * @brief Get the row number of the given event ID in the model. + * @brief Get the QModelIndex of the given event ID in the model. */ - Q_INVOKABLE [[nodiscard]] int eventIdToRow(const QString &eventID) const; + Q_INVOKABLE QModelIndex indexforEventId(const QString &eventId) const; static void setHiddenFilter(std::function hiddenFilter); @@ -123,6 +130,26 @@ Q_SIGNALS: */ void roomChanged(); + /** + * @brief Emitted when the reader marker is added. + */ + void readMarkerAdded(); + + /** + * @brief Emitted when the reader marker index is changed. + */ + void readMarkerIndexChanged(); + + /** + * @brief Emitted when the model is about to reset. + */ + void modelAboutToBeReset(); + + /** + * @brief Emitted when the model has been reset. + */ + void modelResetComplete(); + /** * @brief A signal to tell the MessageModel that a new event has been added. * @@ -131,6 +158,11 @@ Q_SIGNALS: */ void newEventAdded(const Quotient::RoomEvent *event); + /** + * @brief A signal that should be emitted when the local user posts a new event in the room. + */ + void newLocalUserEventAdded(); + void threadsEnabledChanged(); protected: @@ -145,6 +177,8 @@ protected: void refreshEventRoles(int row, const QList &roles = {}); void refreshLastUserEvents(int baseTimelineRow); + void moveReadMarker(const QString &toEventId); + void clearModel(); void clearEventObjects(); diff --git a/src/timeline/models/timelinemessagemodel.cpp b/src/timeline/models/timelinemessagemodel.cpp index 90b269457..17e86aa2e 100644 --- a/src/timeline/models/timelinemessagemodel.cpp +++ b/src/timeline/models/timelinemessagemodel.cpp @@ -60,6 +60,7 @@ void TimelineMessageModel::connectNewRoom() connect(m_room, &Room::pendingEventAdded, this, [this](const Quotient::RoomEvent *event) { m_initialized = true; Q_EMIT newEventAdded(event); + Q_EMIT newLocalUserEventAdded(); beginInsertRows({}, 0, 0); endInsertRows(); }); @@ -143,44 +144,6 @@ int TimelineMessageModel::timelineServerIndex() const return m_room ? int(m_room->pendingEvents().size()) : 0; } -void TimelineMessageModel::moveReadMarker(const QString &toEventId) -{ - const auto timelineIt = m_room->findInTimeline(toEventId); - if (timelineIt == m_room->historyEdge()) { - return; - } - int newRow = int(timelineIt - m_room->messageEvents().rbegin()) + timelineServerIndex(); - - if (!m_lastReadEventIndex.isValid()) { - // Not valid index means we don't display any marker yet, in this case - // we create the new index and insert the row in case the read marker - // need to be displayed. - if (newRow > timelineServerIndex()) { - // The user didn't read all the messages yet. - m_initialized = true; - beginInsertRows({}, newRow, newRow); - m_lastReadEventIndex = QPersistentModelIndex(index(newRow, 0)); - endInsertRows(); - return; - } - // The user read all the messages and we didn't display any read marker yet - // => do nothing - return; - } - if (newRow <= timelineServerIndex()) { - // The user read all the messages => remove read marker - beginRemoveRows({}, m_lastReadEventIndex.row(), m_lastReadEventIndex.row()); - m_lastReadEventIndex = QModelIndex(); - endRemoveRows(); - return; - } - - // The user didn't read all the messages yet but moved the reader marker. - beginMoveRows({}, m_lastReadEventIndex.row(), m_lastReadEventIndex.row(), {}, newRow); - m_lastReadEventIndex = QPersistentModelIndex(index(newRow, 0)); - endMoveRows(); -} - std::optional> TimelineMessageModel::getEventForIndex(QModelIndex index) const { const auto row = index.row(); diff --git a/src/timeline/models/timelinemessagemodel.h b/src/timeline/models/timelinemessagemodel.h index 9fa87f593..50a6ad203 100644 --- a/src/timeline/models/timelinemessagemodel.h +++ b/src/timeline/models/timelinemessagemodel.h @@ -41,6 +41,9 @@ public: */ [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + bool canFetchMore(const QModelIndex &parent) const override; + void fetchMore(const QModelIndex &parent) override; + private: void connectNewRoom(); @@ -52,11 +55,6 @@ private: int timelineServerIndex() const override; - bool canFetchMore(const QModelIndex &parent) const override; - void fetchMore(const QModelIndex &parent) override; - - void moveReadMarker(const QString &toEventId); - // Hack to ensure that we don't call endInsertRows when we haven't called beginInsertRows bool m_initialized = false; }; diff --git a/src/timeline/models/timelinemodel.cpp b/src/timeline/models/timelinemodel.cpp index b16416c21..5b6ffeaff 100644 --- a/src/timeline/models/timelinemodel.cpp +++ b/src/timeline/models/timelinemodel.cpp @@ -43,6 +43,22 @@ QHash TimelineModel::roleNames() const return m_timelineMessageModel->roleNames(); } +bool TimelineModel::canFetchMore(const QModelIndex &parent) const +{ + if (!m_timelineMessageModel) { + return false; + } + return m_timelineMessageModel->canFetchMore(parent); +} + +void TimelineModel::fetchMore(const QModelIndex &parent) +{ + if (!m_timelineMessageModel) { + return; + } + return m_timelineMessageModel->fetchMore(parent); +} + TimelineBeginningModel::TimelineBeginningModel(QObject *parent) : QAbstractListModel(parent) { diff --git a/src/timeline/models/timelinemodel.h b/src/timeline/models/timelinemodel.h index aca649f8f..3ee6a9dd1 100644 --- a/src/timeline/models/timelinemodel.h +++ b/src/timeline/models/timelinemodel.h @@ -162,4 +162,7 @@ private: TimelineMessageModel *m_timelineMessageModel = nullptr; TimelineBeginningModel *m_timelineBeginningModel = nullptr; TimelineEndModel *m_timelineEndModel = nullptr; + + bool canFetchMore(const QModelIndex &parent) const override; + void fetchMore(const QModelIndex &parent) override; };