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