// SPDX-FileCopyrightText: 2020 Carl Schwan // SPDX-License-Identifier: GPL-2.0-or-later 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.neochat import org.kde.neochat.timeline 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. */ required property TimelineModel timelineModel /** * @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 The ActionsHandler object to use. * * This is expected to have the correct room set otherwise messages will be sent * to the wrong room. */ required property ActionsHandler actionsHandler readonly property bool atYEnd: messageListView.atYEnd property alias interactive: messageListView.interactive /// Used to determine if scrolling to the bottom should mark the message as unread property bool hasScrolledUpBefore: false signal focusChatBar QQC2.ScrollBar.vertical.interactive: false ListView { id: messageListView // So that delegates can access the actionsHandler properly. readonly property ActionsHandler actionsHandler: root.actionsHandler 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 bottomMargin: Kirigami.Units.largeSpacing + Math.round(Kirigami.Theme.defaultFont.pointSize * 2) model: root.messageFilterModel onCountChanged: if (root.roomChanging) { root.positionViewAtBeginning(); } Timer { interval: 1000 running: messageListView.atYBeginning triggeredOnStart: true onTriggered: { if (messageListView.atYBeginning && root.timelineModel.messageEventModel.canFetchMore(root.timelineModel.index(0, 0))) { root.timelineModel.messageEventModel.fetchMore(root.timelineModel.index(0, 0)); } } repeat: true } // HACK: The view should do this automatically but doesn't. onAtYBeginningChanged: if (atYBeginning && root.timelineModel.messageEventModel.canFetchMore(root.timelineModel.index(0, 0))) { root.timelineModel.messageEventModel.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; let index = -1; let i = 0; while (index === -1 && i < 100) { index = messageListView.indexAt(center, yStart + i); i++; } return index; } footer: Item { z: 3 width: root.width visible: !NeoChatConfig.blur 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 } } ] width: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.contentItem.width : 0 labelText: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.ListView.section : "" colorSet: NeoChatConfig.compactLayout ? Kirigami.Theme.View : Kirigami.Theme.Window } } footerPositioning: ListView.OverlayHeader delegate: EventDelegate { room: root.currentRoom } KirigamiComponents.FloatingButton { id: goReadMarkerFab anchors.right: parent.right anchors.top: parent.top anchors.topMargin: Kirigami.Units.largeSpacing anchors.rightMargin: Kirigami.Units.largeSpacing implicitWidth: Kirigami.Units.gridUnit * 2 implicitHeight: Kirigami.Units.gridUnit * 2 z: 2 visible: (root.currentRoom?.hasUnreadMessages ?? false) text: root.currentRoom.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.readMarkerEventId); } icon.name: "go-up" shortcut: "Shift+PgUp" } QQC2.ToolTip { id: goReadMarkerFabTooltip text: goReadMarkerFab.text delay: Kirigami.Units.toolTipDelay visible: goReadMarkerFab.hovered } } KirigamiComponents.FloatingButton { id: goMarkAsReadFab anchors.right: parent.right anchors.bottom: parent.bottom anchors.bottomMargin: Kirigami.Units.largeSpacing anchors.rightMargin: Kirigami.Units.largeSpacing implicitWidth: Kirigami.Units.gridUnit * 2 implicitHeight: Kirigami.Units.gridUnit * 2 z: 2 visible: !messageListView.atYEnd action: Kirigami.Action { onTriggered: { messageListView.goToLastMessage(); root.currentRoom.markAllMessagesAsRead(); } icon.name: "go-down" } QQC2.ToolTip { text: i18n("Jump to latest message") } } Component.onCompleted: { positionViewAtBeginning(); } DropArea { id: dropAreaFile anchors.fill: parent onDropped: root.currentRoom.mainCache.attachmentPath = drop.urls[0] enabled: !Controller.isFlatpak } 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: i18n("Drag items here to share them") } } TypingPane { id: typingPane visible: root.currentRoom && root.currentRoom.otherMembersTyping.length > 0 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(", ")) : "" anchors.left: parent.left anchors.bottom: parent.bottom height: visible ? implicitHeight : 0 Behavior on height { NumberAnimation { property: "height" duration: Kirigami.Units.shortDuration easing.type: Easing.OutCubic } } z: 2 } function goToEvent(eventID) { const index = eventToIndex(eventID); if (index == -1) { messageListView.positionViewAtEnd(); return; } messageListView.positionViewAtIndex(index, ListView.Center); itemAtIndex(index).isTemporaryHighlighted = true; } HoverActions { id: hoverActions currentRoom: root.currentRoom onFocusChatBar: root.focusChatBar() } onContentYChanged: hoverActions.updatePosition() Connections { target: root.timelineModel function onRowsInserted() { markReadIfVisibleTimer.reset(); } } 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); }); } } function goToLastMessage() { root.currentRoom.markAllMessagesAsRead(); // scroll to the very end, i.e to messageListView.YEnd messageListView.positionViewAtIndex(0, ListView.End); } function eventToIndex(eventID) { const index = root.timelineModel.messageEventModel.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.readMarkerEventId); if (readMarkerRow >= 0 && readMarkerRow < firstVisibleIndex() && messageListView.atYEnd) { return true; } return false; } function setHoverActionsToDelegate(delegate) { hoverActions.delegate = delegate; } } 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); } }