Files
neochat/src/timeline/TimelineView.qml
Joshua Goins e267f9f667 Fix closeToYEnd check
The comparison operator was reversed, and this was seen with mark as
read being broken and buttons showing up at the wrong times.

(cherry picked from commit 9ace01f74a)
2026-02-06 12:22:39 -05:00

375 lines
13 KiB
QML

// 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 org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.neochat
import org.kde.neochat.libneochat as LibNeoChat
QQC2.ScrollView {
id: root
/**
* @brief The MessageFilterModel to use.
*
* This model has the filtered list of events that should be shown in the timeline.
*/
required property MessageFilterModel messageFilterModel
/**
* @brief Whether the timeline ListView is interactive.
*/
property alias interactive: messageListView.interactive
/**
* @brief Whether the compact message layout is to be used.
*/
required property bool compactLayout
/**
* @brief Whether the compact message layout is to be used.
*/
property bool fileDropEnabled: true
/**
* @brief The TimelineMarkReadCondition to use for when messages should be marked as read automatically.
*/
required property int markReadCondition
/**
* @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() {
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
/**
* @brief Tell the view to resettle again as needed.
*/
function resetViewSettling() {
_private.viewHasSettled = false;
}
ListView {
id: messageListView
// HACK: Use this instead of atYEnd to handle cases like -643.2 at height of 643 not being counted as "at the beginning"
readonly property bool closeToYEnd: -Math.round(contentY) <= height
onHeightChanged: {
// HACK: Fix a bug where Qt doesn't resettle the view properly when the pinned messages changes our height
// We basically want to resettle back at the start if:
// * The user hasn't scrolled before (obviously) *and* that scroll is actually somewhere other than the beginning
// * This is the first height change
if (!_private.viewHasSettled && (!_private.hasScrolledUpBefore || closeToYEnd)) {
positionViewAtBeginning();
_private.viewHasSettled = true;
}
}
/**
* @brief Whether all unread messages in the timeline are visible.
*/
function allUnreadVisible() {
let readMarkerRow = model.readMarkerIndex?.row ?? -1;
if (readMarkerRow >= 0 && readMarkerRow < oldestVisibleIndex() && closeToYEnd) {
return true;
}
return false;
}
/**
* @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 = indexAt(center, y + contentY + i);
i++;
}
return index;
}
/**
* @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;
}
spacing: 0
verticalLayoutDirection: ListView.BottomToTop
clip: true
interactive: Kirigami.Settings.isMobile
Shortcut {
sequences: [ StandardKey.Cancel ]
onActivated: {
if (!messageListView.closeToYEnd || !_private.room.partiallyReadStats.empty()) {
messageListView.positionViewAtBeginning();
} else {
(root.Kirigami.PageStack.pageStack as Kirigami.PageRow).get(0).forceActiveFocus();
}
}
}
Connections {
target: messageListView.model.sourceModel.timelineMessageModel
function onModelAboutToBeReset() {
(root.QQC2.ApplicationWindow.window as Main).hoverLinkIndicator.text = "";
_private.hasScrolledUpBefore = false;
_private.viewHasSettled = false;
}
function onReadMarkerAdded() {
if (root.markReadCondition == LibNeoChat.TimelineMarkReadCondition.EntryVisible && messageListView.allUnreadVisible()) {
_private.room.markAllMessagesAsRead();
}
}
function onNewLocalUserEventAdded() {
messageListView.positionViewAtBeginning();
_private.room.markAllMessagesAsRead();
}
function onRoomAboutToChange(oldRoom, newRoom) {
if (root.markReadCondition == LibNeoChat.TimelineMarkReadCondition.Exit ||
(root.markReadCondition == LibNeoChat.TimelineMarkReadCondition.ExitVisible && messageListView.allUnreadVisible())
) {
oldRoom.markAllMessagesAsRead();
}
}
function onRoomChanged(oldRoom, newRoom) {
if (root.markReadCondition == LibNeoChat.TimelineMarkReadCondition.Entry) {
newRoom.markAllMessagesAsRead();
}
}
}
onCloseToYEndChanged: {
// Don't care about this until the view has settled first.
if (!_private.viewHasSettled) {
return;
}
if (closeToYEnd && _private.hasScrolledUpBefore) {
if (QQC2.ApplicationWindow.window && (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden)) {
_private.room.markAllMessagesAsRead();
}
_private.hasScrolledUpBefore = false;
} else if (!closeToYEnd) {
_private.hasScrolledUpBefore = true;
}
}
model: root.messageFilterModel
delegate: EventDelegate {
room: _private.room
}
KirigamiComponents.FloatingButton {
id: goReadMarkerFab
anchors {
right: parent.right
top: parent.top
topMargin: Kirigami.Units.largeSpacing
rightMargin: Kirigami.Units.largeSpacing
}
implicitWidth: Kirigami.Settings.hasTransientTouchInput ? Kirigami.Units.gridUnit * 3 : Kirigami.Units.gridUnit * 2
implicitHeight: Kirigami.Settings.hasTransientTouchInput ? Kirigami.Units.gridUnit * 3 : Kirigami.Units.gridUnit * 2
padding: Kirigami.Units.largeSpacing
z: 2
visible: !_private.room?.partiallyReadStats.empty()
text: _private.room.readMarkerLoaded ? i18nc("@action:button", "Jump to first unread message") : i18nc("@action:button", "Jump to oldest loaded message")
icon.name: "go-up"
onClicked: {
goReadMarkerFab.textChanged()
root.goToEvent(_private.room.lastFullyReadEventId);
}
Kirigami.Action {
shortcut: "Shift+PgUp"
onTriggered: goReadMarkerFab.clicked()
}
QQC2.ToolTip.text: goReadMarkerFab.text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.visible: goReadMarkerFab.hovered
}
KirigamiComponents.FloatingButton {
id: goMarkAsReadFab
anchors {
right: parent.right
bottom: parent.bottom
bottomMargin: Kirigami.Units.largeSpacing
rightMargin: Kirigami.Units.largeSpacing
}
implicitWidth: Kirigami.Settings.hasTransientTouchInput ? Kirigami.Units.gridUnit * 3 : Kirigami.Units.gridUnit * 2
implicitHeight: Kirigami.Settings.hasTransientTouchInput ? Kirigami.Units.gridUnit * 3 : Kirigami.Units.gridUnit * 2
padding: Kirigami.Units.largeSpacing
z: 2
visible: !messageListView.closeToYEnd
text: i18nc("@action:button", "Jump to latest message")
onClicked: {
messageListView.positionViewAtBeginning();
_private.room.markAllMessagesAsRead();
}
icon.name: "go-down"
Kirigami.Action {
shortcut: "Shift+PgDown"
onTriggered: goMarkAsReadFab.clicked()
}
QQC2.ToolTip.text: goMarkAsReadFab.text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.visible: goMarkAsReadFab.hovered
}
DropArea {
id: dropAreaFile
anchors.fill: parent
onDropped: drop => { _private.room.mainCache.attachmentPath = drop.urls[0] }
enabled: root.fileDropEnabled
}
QQC2.Pane {
visible: dropAreaFile.containsDrag
anchors {
fill: parent
margins: Kirigami.Units.gridUnit
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - (Kirigami.Units.largeSpacing * 4)
text: i18nc("@info:placeholder", "Drag items here to share them")
}
}
RowLayout {
id: typingPaneContainer
visible: _private.room && _private.room.otherMembersTyping.length > 0
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: visible ? typingPane.implicitHeight : 0
z: 2
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
RowLayout {
Layout.alignment: Qt.AlignCenter
Layout.fillWidth: true
Layout.maximumWidth: typingPaneSizeHelper.availableWidth
TypingPane {
id: typingPane
labelText: visible ? i18ncp("Message displayed when some users are typing", "%2 is typing", "%2 are typing", _private.room.otherMembersTyping.length, _private.room.otherMembersTyping.map(member => member.displayName).join(", ")) : ""
}
}
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 {
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
}
}
QtObject {
id: _private
// Get the room from the model so we always have the one its using (this
// may not be the case just after RoomManager.currentRoom changes while
// the model does the switch over).
readonly property LibNeoChat.NeoChatRoom room: messageListView.model.sourceModel.timelineMessageModel.room
// Used to determine if scrolling to the bottom should mark the message as unread
property bool hasScrolledUpBefore: false
// Used to determine if the view has settled and should stop moving
property bool viewHasSettled: false
}
}