From 6a5a2e614467e9815ce68ca04cb6bb8a3d64ae4c Mon Sep 17 00:00:00 2001 From: James Graham Date: Sat, 14 Jun 2025 12:16:39 +0100 Subject: [PATCH] Refactor TimelineView Refactor TimelineView to make it more reliable and prepare for read marker choice. This is done by creating signalling from the mode when reset which can be used to move the scrollbar to the newest meassage. Some of the spaghetti is also removed so there is no need for ChatBar and TimelineView to talk directly. The code to mark messages as read if they are all visible after 10s has been removed infour of just marking as read on entry if all are visible. This is temporary until a follow up providing user options is finished (although it will be one of the options) --- autotests/timelinemessagemodeltest.cpp | 2 +- src/app/CMakeLists.txt | 1 - src/app/qml/RoomPage.qml | 16 +- src/app/roommanager.cpp | 3 +- src/chatbar/ChatBar.qml | 6 - src/timeline/CMakeLists.txt | 1 + src/timeline/TimelineView.qml | 406 ++++++++----------- src/{app/qml => timeline}/TypingPane.qml | 0 src/timeline/messagedelegate.cpp | 1 + src/timeline/models/messagefiltermodel.cpp | 61 ++- src/timeline/models/messagefiltermodel.h | 21 +- src/timeline/models/messagemodel.cpp | 64 ++- src/timeline/models/messagemodel.h | 38 +- src/timeline/models/timelinemessagemodel.cpp | 39 +- src/timeline/models/timelinemessagemodel.h | 8 +- src/timeline/models/timelinemodel.cpp | 16 + src/timeline/models/timelinemodel.h | 3 + 17 files changed, 368 insertions(+), 318 deletions(-) rename src/{app/qml => timeline}/TypingPane.qml (100%) 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; };