Warning, /network/neochat/src/qml/TimelineView.qml is written in an unsupported language. File is not indexed.

0001 // SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
0002 // SPDX-License-Identifier: GPL-2.0-or-later
0003 
0004 import QtQuick
0005 import QtQuick.Controls as QQC2
0006 import QtQuick.Layouts
0007 import Qt.labs.qmlmodels
0008 import QtQuick.Window
0009 
0010 import org.kde.kirigamiaddons.components as KirigamiComponents
0011 import org.kde.kirigami as Kirigami
0012 import org.kde.kitemmodels
0013 
0014 import org.kde.neochat
0015 import org.kde.neochat.config
0016 
0017 QQC2.ScrollView {
0018     id: root
0019     required property NeoChatRoom currentRoom
0020     required property NeoChatConnection connection
0021     onCurrentRoomChanged: {
0022         roomChanging = true;
0023         roomChangingTimer.restart()
0024         applicationWindow().hoverLinkIndicator.text = "";
0025         messageListView.positionViewAtBeginning();
0026         hasScrolledUpBefore = false;
0027     }
0028     property bool roomChanging: false
0029 
0030     /**
0031      * @brief The TimelineModel to use.
0032      *
0033      * Required so that new events can be requested when the end of the current
0034      * local timeline is reached.
0035      */
0036     required property TimelineModel timelineModel
0037 
0038     /**
0039      * @brief The MessageFilterModel to use.
0040      *
0041      * This model has the filtered list of events that should be shown in the timeline.
0042      */
0043     required property MessageFilterModel messageFilterModel
0044 
0045     /**
0046      * @brief The ActionsHandler object to use.
0047      *
0048      * This is expected to have the correct room set otherwise messages will be sent
0049      * to the wrong room.
0050      */
0051     required property ActionsHandler actionsHandler
0052 
0053     readonly property bool atYEnd: messageListView.atYEnd
0054 
0055     property alias interactive: messageListView.interactive
0056 
0057     /// Used to determine if scrolling to the bottom should mark the message as unread
0058     property bool hasScrolledUpBefore: false;
0059 
0060     signal focusChatBar()
0061 
0062     QQC2.ScrollBar.vertical.interactive: false
0063 
0064     ListView {
0065         id: messageListView
0066         // So that delegates can access the current room properly.
0067         readonly property NeoChatRoom currentRoom: root.currentRoom
0068         // So that delegates can access the actionsHandler properly.
0069         readonly property ActionsHandler actionsHandler: root.actionsHandler
0070 
0071         readonly property int largestVisibleIndex: count > 0 ? indexAt(contentX + (width / 2), contentY + height - 1) : -1
0072         readonly property var sectionBannerItem: contentHeight >= height ? itemAtIndex(sectionBannerIndex()) : undefined
0073 
0074         // Spacing needs to be zero or the top sectionLabel overlay will be disrupted.
0075         // This is because itemAt returns null in the spaces.
0076         // All spacing should be handled by the delegates themselves
0077         spacing: 0
0078         verticalLayoutDirection: ListView.BottomToTop
0079         clip: true
0080         interactive: Kirigami.Settings.isMobile
0081         bottomMargin: Kirigami.Units.largeSpacing + Math.round(Kirigami.Theme.defaultFont.pointSize * 2)
0082 
0083         model: root.messageFilterModel
0084 
0085         Timer {
0086             interval: 1000
0087             running: messageListView.atYBeginning
0088             triggeredOnStart: true
0089             onTriggered: {
0090                 if (messageListView.atYBeginning && root.timelineModel.messageEventModel.canFetchMore(root.timelineModel.index(0, 0))) {
0091                     root.timelineModel.messageEventModel.fetchMore(root.timelineModel.index(0, 0));
0092                 }
0093             }
0094             repeat: true
0095         }
0096 
0097         // HACK: The view should do this automatically but doesn't.
0098         onAtYBeginningChanged: if (atYBeginning && root.timelineModel.messageEventModel.canFetchMore(root.timelineModel.index(0, 0))) {
0099             root.timelineModel.messageEventModel.fetchMore(root.timelineModel.index(0, 0));
0100         }
0101 
0102         Timer {
0103             id: roomChangingTimer
0104             interval: 1000
0105             onTriggered: {
0106                 root.roomChanging = false
0107                 markReadIfVisibleTimer.reset()
0108             }
0109         }
0110         onAtYEndChanged: if (!root.roomChanging) {
0111             if (atYEnd && root.hasScrolledUpBefore) {
0112                 if (QQC2.ApplicationWindow.window && (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden)) {
0113                     root.currentRoom.markAllMessagesAsRead();
0114                 }
0115                 root.hasScrolledUpBefore = false;
0116             } else if (!atYEnd) {
0117                 root.hasScrolledUpBefore = true;
0118             }
0119         }
0120 
0121         // 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.
0122         // This is due to the fact that the ListView verticalLayout is BottomToTop.
0123         // 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).
0124         // There is currently no option to put section headings at the bottom in qml.
0125         section.property: "section"
0126 
0127         function sectionBannerIndex() {
0128             let center = messageListView.x + messageListView.width / 2;
0129             let yStart = messageListView.y + messageListView.contentY;
0130             let index = -1;
0131             let i = 0;
0132             while (index === -1 && i < 100) {
0133                 index = messageListView.indexAt(center, yStart + i);
0134                 i++;
0135             }
0136             return index;
0137         }
0138 
0139         footer: SectionDelegate {
0140             id: sectionBanner
0141 
0142             anchors.left: parent.left
0143             anchors.leftMargin: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.contentItem.parent.x : 0
0144             anchors.right: parent.right
0145 
0146             maxWidth: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.contentItem.width : 0
0147             z: 3
0148             visible: !!messageListView.sectionBannerItem && messageListView.sectionBannerItem.ListView.section !== "" && !Config.blur
0149             labelText: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.ListView.section : ""
0150             colorSet: Config.compactLayout ? Kirigami.Theme.View : Kirigami.Theme.Window
0151         }
0152         footerPositioning: ListView.OverlayHeader
0153 
0154         delegate: EventDelegate {
0155             room: root.currentRoom
0156             connection: root.connection
0157         }
0158 
0159         KirigamiComponents.FloatingButton {
0160             id: goReadMarkerFab
0161 
0162             anchors.right: parent.right
0163             anchors.top: parent.top
0164             anchors.topMargin: Kirigami.Units.largeSpacing
0165             anchors.rightMargin: Kirigami.Units.largeSpacing
0166             implicitWidth: Kirigami.Units.gridUnit * 2
0167             implicitHeight: Kirigami.Units.gridUnit * 2
0168 
0169             z: 2
0170             visible: root.currentRoom && root.currentRoom.hasUnreadMessages && root.currentRoom.readMarkerLoaded
0171             action: Kirigami.Action {
0172                 onTriggered: {
0173                     if (!Kirigami.Settings.isMobile) {
0174                         root.focusChatBar();
0175                     }
0176                     messageListView.goToEvent(root.currentRoom.readMarkerEventId)
0177                 }
0178                 icon.name: "go-up"
0179                 shortcut: "Shift+PgUp"
0180             }
0181 
0182             QQC2.ToolTip {
0183                 text: i18n("Jump to first unread message")
0184             }
0185         }
0186         KirigamiComponents.FloatingButton {
0187             id: goMarkAsReadFab
0188             anchors.right: parent.right
0189             anchors.bottom: parent.bottom
0190             anchors.bottomMargin: Kirigami.Units.largeSpacing
0191             anchors.rightMargin: Kirigami.Units.largeSpacing
0192             implicitWidth: Kirigami.Units.gridUnit * 2
0193             implicitHeight: Kirigami.Units.gridUnit * 2
0194 
0195             z: 2
0196             visible: !messageListView.atYEnd
0197             action: Kirigami.Action {
0198                 onTriggered: {
0199                     messageListView.goToLastMessage();
0200                     root.currentRoom.markAllMessagesAsRead();
0201                 }
0202                 icon.name: "go-down"
0203             }
0204 
0205             QQC2.ToolTip {
0206                 text: i18n("Jump to latest message")
0207             }
0208         }
0209 
0210         Component.onCompleted: {
0211             positionViewAtBeginning();
0212         }
0213 
0214         DropArea {
0215             id: dropAreaFile
0216             anchors.fill: parent
0217             onDropped: root.currentRoom.mainCache.attachmentPath = drop.urls[0]
0218             enabled: !Controller.isFlatpak
0219         }
0220 
0221         QQC2.Pane {
0222             visible: dropAreaFile.containsDrag
0223             anchors {
0224                 fill: parent
0225                 margins: Kirigami.Units.gridUnit
0226             }
0227 
0228             Kirigami.PlaceholderMessage {
0229                 anchors.centerIn: parent
0230                 width: parent.width - (Kirigami.Units.largeSpacing * 4)
0231                 text: i18n("Drag items here to share them")
0232             }
0233         }
0234 
0235         TypingPane {
0236             id: typingPane
0237             visible: root.currentRoom && root.currentRoom.usersTyping.length > 0
0238             labelText: visible ? i18ncp(
0239                 "Message displayed when some users are typing", "%2 is typing", "%2 are typing",
0240                 root.currentRoom.usersTyping.length,
0241                 root.currentRoom.usersTyping.map(user => user.displayName).join(", ")
0242             ) :
0243             ""
0244             anchors.left: parent.left
0245             anchors.bottom: parent.bottom
0246             height: visible ? implicitHeight : 0
0247             Behavior on height {
0248                 NumberAnimation {
0249                     property: "height"
0250                     duration: Kirigami.Units.shortDuration
0251                     easing.type: Easing.OutCubic
0252                 }
0253             }
0254             z: 2
0255         }
0256 
0257         function goToEvent(eventID) {
0258             const index = eventToIndex(eventID)
0259             messageListView.positionViewAtIndex(index, ListView.Center)
0260             itemAtIndex(index).isTemporaryHighlighted = true
0261         }
0262 
0263         HoverActions {
0264             id: hoverActions
0265             currentRoom: root.currentRoom
0266             onFocusChatBar: root.focusChatBar()
0267         }
0268 
0269         onContentYChanged: {
0270             if (hoverActions.delegate) {
0271                 hoverActions.delegate.setHoverActionsToDelegate();
0272             }
0273         }
0274 
0275         Connections {
0276             target: root.timelineModel
0277 
0278             function onRowsInserted() {
0279                 markReadIfVisibleTimer.reset()
0280             }
0281         }
0282 
0283         Timer {
0284             id: markReadIfVisibleTimer
0285             running: messageListView.allUnreadVisible() && applicationWindow().active && (root.currentRoom.timelineSize > 0 || root.currentRoom.allHistoryLoaded)
0286             interval: 10000
0287             onTriggered: root.currentRoom.markAllMessagesAsRead()
0288 
0289             function reset() {
0290                 restart()
0291                 running = Qt.binding(function() { return messageListView.allUnreadVisible() && applicationWindow().active && (root.currentRoom.timelineSize > 0 || root.currentRoom.allHistoryLoaded) })
0292             }
0293         }
0294 
0295         Rectangle {
0296             FancyEffectsContainer {
0297                 id: fancyEffectsContainer
0298                 anchors.fill: parent
0299                 z: 100
0300 
0301                 enabled: Config.showFancyEffects
0302 
0303                 function processFancyEffectsReason(fancyEffect) {
0304                     if (fancyEffect === "snowflake") {
0305                         fancyEffectsContainer.showSnowEffect()
0306                     }
0307                     if (fancyEffect === "fireworks") {
0308                         fancyEffectsContainer.showFireworksEffect()
0309                     }
0310                     if (fancyEffect === "confetti") {
0311                         fancyEffectsContainer.showConfettiEffect()
0312                     }
0313                 }
0314 
0315                 Connections {
0316                     //enabled: Config.showFancyEffects
0317                     target: root.timelineModel.messageEventModel
0318 
0319                     function onFancyEffectsReasonFound(fancyEffect) {
0320                         fancyEffectsContainer.processFancyEffectsReason(fancyEffect)
0321                     }
0322                 }
0323 
0324                 Connections {
0325                     enabled: Config.showFancyEffects
0326                     target: actionsHandler
0327 
0328                     function onShowEffect(fancyEffect) {
0329                         fancyEffectsContainer.processFancyEffectsReason(fancyEffect)
0330                     }
0331                 }
0332             }
0333         }
0334 
0335         function goToLastMessage() {
0336             root.currentRoom.markAllMessagesAsRead()
0337             // scroll to the very end, i.e to messageListView.YEnd
0338             messageListView.positionViewAtIndex(0, ListView.End)
0339         }
0340 
0341         function eventToIndex(eventID) {
0342             const index = root.timelineModel.messageEventModel.eventIdToRow(eventID)
0343             if (index === -1)
0344                 return -1
0345             return root.messageFilterModel.mapFromSource(root.timelineModel.index(index, 0)).row
0346         }
0347 
0348         function firstVisibleIndex() {
0349             let center = messageListView.x + messageListView.width / 2;
0350             let index = -1
0351             let i = 0
0352             while (index === -1 && i < 100) {
0353                 index = messageListView.indexAt(center, messageListView.y + messageListView.contentY + i);
0354                 i++;
0355             }
0356             return index
0357         }
0358 
0359         function lastVisibleIndex() {
0360             let center = messageListView.x + messageListView.width / 2;
0361             let index = -1
0362             let i = 0
0363             while (index === -1 && i < 100) {
0364                 index = messageListView.indexAt(center, messageListView.y + messageListView.contentY + messageListView.height - i);
0365                 i++
0366             }
0367             return index;
0368         }
0369 
0370         function allUnreadVisible() {
0371             let readMarkerRow = eventToIndex(root.currentRoom.readMarkerEventId)
0372             if (readMarkerRow >= 0 && readMarkerRow < firstVisibleIndex() && messageListView.atYEnd) {
0373                 return true
0374             }
0375             return false
0376         }
0377 
0378         function setHoverActionsToDelegate(delegate) {
0379             hoverActions.delegate = delegate
0380         }
0381     }
0382 
0383     function goToLastMessage() {
0384         messageListView.goToLastMessage()
0385     }
0386 
0387     function pageUp() {
0388         const newContentY = messageListView.contentY - messageListView.height / 2;
0389         const minContentY = messageListView.originY + messageListView.topMargin;
0390         messageListView.contentY = Math.max(newContentY, minContentY);
0391         messageListView.returnToBounds();
0392     }
0393 
0394     function pageDown() {
0395         const newContentY = messageListView.contentY + messageListView.height / 2;
0396         const maxContentY = messageListView.originY + messageListView.bottomMargin + messageListView.contentHeight - messageListView.height;
0397         messageListView.contentY = Math.min(newContentY, maxContentY);
0398         messageListView.returnToBounds();
0399     }
0400 
0401     function positionViewAtBeginning() {
0402         messageListView.positionViewAtBeginning()
0403     }
0404 }