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)
This commit is contained in:
@@ -1,41 +1,22 @@
|
||||
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user