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

0001 // SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
0002 // SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
0003 // SPDX-License-Identifier: GPL-2.0-or-later
0004 
0005 import QtCore
0006 import QtQuick
0007 import QtQuick.Controls as QQC2
0008 import QtQuick.Layouts
0009 
0010 import org.kde.kirigami as Kirigami
0011 import org.kde.neochat
0012 import org.kde.neochat.config
0013 
0014 /**
0015  * @brief A component for typing and sending chat messages.
0016  *
0017  * This is designed to go to the bottom of the timeline and provides all the functionality
0018  * required for the user to send messages to the room.
0019  *
0020  * In addition when replying this component supports showing the message that is being
0021  * replied to.
0022  *
0023  * @sa ChatBar
0024  */
0025 QQC2.Control {
0026     id: root
0027 
0028     /**
0029      * @brief The current room that user is viewing.
0030      */
0031     required property NeoChatRoom currentRoom
0032 
0033     required property NeoChatConnection connection
0034 
0035     onActiveFocusChanged: textField.forceActiveFocus()
0036 
0037     onCurrentRoomChanged: _private.chatBarCache = currentRoom.mainCache
0038 
0039     /**
0040      * @brief The ActionsHandler object to use.
0041      *
0042      * This is expected to have the correct room set otherwise messages will be sent
0043      * to the wrong room.
0044      */
0045     required property ActionsHandler actionsHandler
0046 
0047     /**
0048      * @brief The list of actions in the ChatBar.
0049      *
0050      * Each of these will be visualised in the ChatBar so new actions can be added
0051      * by appending to this list.
0052      */
0053     property list<Kirigami.Action> actions: [
0054         Kirigami.Action {
0055             id: attachmentAction
0056 
0057             property bool isBusy: root.currentRoom && root.currentRoom.hasFileUploading
0058 
0059             // Matrix does not allow sending attachments in replies
0060             visible: _private.chatBarCache.replyId.length === 0 && _private.chatBarCache.attachmentPath.length === 0
0061             icon.name: "mail-attachment"
0062             text: i18n("Attach an image or file")
0063             displayHint: Kirigami.DisplayHint.IconOnly
0064 
0065             onTriggered: {
0066                 let dialog = (Clipboard.hasImage ? attachDialog : openFileDialog).createObject(applicationWindow().overlay);
0067                 dialog.chosen.connect(path => _private.chatBarCache.attachmentPath = path);
0068                 dialog.open();
0069             }
0070 
0071             tooltip: text
0072         },
0073         Kirigami.Action {
0074             id: emojiAction
0075 
0076             property bool isBusy: false
0077 
0078             visible: !Kirigami.Settings.isMobile
0079             icon.name: "smiley"
0080             text: i18n("Emojis & Stickers")
0081             displayHint: Kirigami.DisplayHint.IconOnly
0082             checkable: true
0083 
0084             onTriggered: {
0085                 if (emojiDialog.visible) {
0086                     emojiDialog.close();
0087                 } else {
0088                     emojiDialog.open();
0089                 }
0090             }
0091             tooltip: text
0092         },
0093         Kirigami.Action {
0094             id: mapButton
0095             icon.name: "globe"
0096             property bool isBusy: false
0097             text: i18n("Send a Location")
0098             displayHint: QQC2.AbstractButton.IconOnly
0099 
0100             onTriggered: {
0101                 locationChooser.createObject(QQC2.ApplicationWindow.overlay, {
0102                     room: root.currentRoom
0103                 }).open();
0104             }
0105             tooltip: text
0106         },
0107         Kirigami.Action {
0108             id: sendAction
0109 
0110             property bool isBusy: false
0111 
0112             icon.name: "document-send"
0113             text: i18n("Send message")
0114             displayHint: Kirigami.DisplayHint.IconOnly
0115             checkable: true
0116 
0117             onTriggered: {
0118                 _private.postMessage();
0119             }
0120 
0121             tooltip: text
0122         }
0123     ]
0124 
0125     /**
0126      * @brief A message has been sent from the chat bar.
0127      */
0128     signal messageSent
0129 
0130     spacing: 0
0131 
0132     Kirigami.Theme.colorSet: Kirigami.Theme.View
0133     Kirigami.Theme.inherit: false
0134 
0135     background: Rectangle {
0136         color: Kirigami.Theme.backgroundColor
0137         Kirigami.Separator {
0138             anchors.left: parent.left
0139             anchors.right: parent.right
0140             anchors.top: parent.top
0141         }
0142     }
0143 
0144     leftPadding: rightPadding
0145     rightPadding: (root.width - chatBarSizeHelper.currentWidth) / 2
0146     topPadding: 0
0147     bottomPadding: 0
0148 
0149     contentItem: ColumnLayout {
0150         spacing: 0
0151         Item {
0152             // Required to adjust for the top separator
0153             Layout.preferredHeight: 1
0154             Layout.fillWidth: true
0155         }
0156         Loader {
0157             id: paneLoader
0158 
0159             Layout.fillWidth: true
0160             Layout.margins: Kirigami.Units.largeSpacing
0161 
0162             active: visible
0163             visible: root.currentRoom.mainCache.replyId.length > 0 || root.currentRoom.mainCache.attachmentPath.length > 0
0164             sourceComponent: root.currentRoom.mainCache.replyId.length > 0 ? replyPane : attachmentPane
0165         }
0166         RowLayout {
0167             QQC2.ScrollView {
0168                 id: chatBarScrollView
0169 
0170                 Layout.fillWidth: true
0171                 Layout.maximumHeight: Kirigami.Units.gridUnit * 8
0172 
0173                 Layout.topMargin: Kirigami.Units.smallSpacing
0174                 Layout.bottomMargin: Kirigami.Units.smallSpacing
0175                 Layout.minimumHeight: Kirigami.Units.gridUnit * 2
0176 
0177                 // HACK: This is to stop the ScrollBar flickering on and off as the height is increased
0178                 QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded
0179 
0180                 Behavior on implicitHeight {
0181                     NumberAnimation {
0182                         id: chatBarHeightAnimation
0183                         duration: Kirigami.Units.shortDuration
0184                         easing.type: Easing.InOutCubic
0185                     }
0186                 }
0187 
0188                 QQC2.TextArea {
0189                     id: textField
0190 
0191                     placeholderText: root.currentRoom.usesEncryption ? i18n("Send an encrypted message…") : root.currentRoom.mainCache.attachmentPath.length > 0 ? i18n("Set an attachment caption…") : i18n("Send a message…")
0192                     verticalAlignment: TextEdit.AlignVCenter
0193                     wrapMode: TextEdit.Wrap
0194 
0195                     Accessible.description: placeholderText
0196 
0197                     Kirigami.SpellCheck.enabled: false
0198 
0199                     Timer {
0200                         id: repeatTimer
0201                         interval: 5000
0202                     }
0203 
0204                     onTextChanged: {
0205                         if (!repeatTimer.running && Config.typingNotifications) {
0206                             var textExists = text.length > 0;
0207                             root.currentRoom.sendTypingNotification(textExists);
0208                             textExists ? repeatTimer.start() : repeatTimer.stop();
0209                         }
0210                         _private.chatBarCache.text = text;
0211                     }
0212                     onSelectedTextChanged: {
0213                         if (selectedText.length > 0) {
0214                             quickFormatBar.selectionStart = selectionStart;
0215                             quickFormatBar.selectionEnd = selectionEnd;
0216                             quickFormatBar.open();
0217                         }
0218                     }
0219 
0220                     QuickFormatBar {
0221                         id: quickFormatBar
0222 
0223                         x: textField.cursorRectangle.x
0224                         y: textField.cursorRectangle.y - height
0225 
0226                         onFormattingSelected: root.formatText(format, selectionStart, selectionEnd)
0227                     }
0228 
0229                     Keys.onDeletePressed: {
0230                         if (selectedText.length > 0) {
0231                             remove(selectionStart, selectionEnd);
0232                         } else {
0233                             remove(cursorPosition, cursorPosition + 1);
0234                         }
0235                         if (textField.text == selectedText || textField.text.length <= 1) {
0236                             root.currentRoom.sendTypingNotification(false);
0237                             repeatTimer.stop();
0238                         }
0239                         if (quickFormatBar.visible) {
0240                             quickFormatBar.close();
0241                         }
0242                     }
0243                     Keys.onEnterPressed: event => {
0244                         if (completionMenu.visible) {
0245                             completionMenu.complete();
0246                         } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) {
0247                             textField.insert(cursorPosition, "\n");
0248                         } else {
0249                             _private.postMessage();
0250                         }
0251                     }
0252                     Keys.onReturnPressed: event => {
0253                         if (completionMenu.visible) {
0254                             completionMenu.complete();
0255                         } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) {
0256                             textField.insert(cursorPosition, "\n");
0257                         } else {
0258                             _private.postMessage();
0259                         }
0260                     }
0261                     Keys.onTabPressed: {
0262                         if (completionMenu.visible) {
0263                             completionMenu.complete();
0264                         }
0265                     }
0266                     Keys.onPressed: event => {
0267                         if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
0268                             event.accepted = _private.pasteImage();
0269                         } else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
0270                             root.currentRoom.replyLastMessage();
0271                         } else if (event.key === Qt.Key_Up && textField.text.length === 0) {
0272                             root.currentRoom.editLastMessage();
0273                         } else if (event.key === Qt.Key_Up && completionMenu.visible) {
0274                             completionMenu.decrementIndex();
0275                         } else if (event.key === Qt.Key_Down && completionMenu.visible) {
0276                             completionMenu.incrementIndex();
0277                         } else if (event.key === Qt.Key_Backspace) {
0278                             if (textField.text == selectedText || textField.text.length <= 1) {
0279                                 root.currentRoom.sendTypingNotification(false);
0280                                 repeatTimer.stop();
0281                             }
0282                             if (quickFormatBar.visible && selectedText.length > 0) {
0283                                 quickFormatBar.close();
0284                             }
0285                         }
0286                     }
0287                     Keys.onShortcutOverride: event => {
0288                         if (completionMenu.visible) {
0289                             completionMenu.close();
0290                         } else if ((_private.chatBarCache.isReplying || _private.chatBarCache.attachmentPath.length > 0) && event.key === Qt.Key_Escape) {
0291                             _private.chatBarCache.attachmentPath = "";
0292                             _private.chatBarCache.replyId = "";
0293                             _private.chatBarCache.threadId = "";
0294                             event.accepted = true;
0295                         }
0296                     }
0297 
0298                     background: MouseArea {
0299                         acceptedButtons: Qt.NoButton
0300                         cursorShape: Qt.IBeamCursor
0301                         z: 1
0302                     }
0303                 }
0304             }
0305             RowLayout {
0306                 id: actionsRow
0307                 spacing: 0
0308                 Layout.alignment: Qt.AlignBottom
0309                 Layout.bottomMargin: Kirigami.Units.smallSpacing * 1.5
0310 
0311                 Repeater {
0312                     model: root.actions
0313                     delegate: QQC2.ToolButton {
0314                         Layout.alignment: Qt.AlignVCenter
0315                         icon.name: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source)
0316                         onClicked: modelData.trigger()
0317 
0318                         QQC2.ToolTip.visible: hovered
0319                         QQC2.ToolTip.text: modelData.tooltip
0320                         QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
0321 
0322                         PieProgressBar {
0323                             visible: modelData.isBusy
0324                             progress: root.currentRoom.fileUploadingProgress
0325                         }
0326                     }
0327                 }
0328             }
0329         }
0330     }
0331 
0332     DelegateSizeHelper {
0333         id: chatBarSizeHelper
0334         startBreakpoint: Kirigami.Units.gridUnit * 46
0335         endBreakpoint: Kirigami.Units.gridUnit * 66
0336         startPercentWidth: 100
0337         endPercentWidth: Config.compactLayout ? 100 : 85
0338         maxWidth: Config.compactLayout ? -1 : Kirigami.Units.gridUnit * 60
0339 
0340         parentWidth: root.width
0341     }
0342 
0343     Component {
0344         id: replyPane
0345         ReplyPane {
0346             userName: _private.chatBarCache.relationUser.displayName
0347             userColor: _private.chatBarCache.relationUser.color
0348             userAvatar: _private.chatBarCache.relationUser.avatarSource
0349             text: _private.chatBarCache.relationMessage
0350 
0351             onCancel: {
0352                 _private.chatBarCache.replyId = "";
0353                 _private.chatBarCache.attachmentPath = "";
0354             }
0355         }
0356     }
0357     Component {
0358         id: attachmentPane
0359         AttachmentPane {
0360             attachmentPath: _private.chatBarCache.attachmentPath
0361 
0362             onAttachmentCancelled: {
0363                 _private.chatBarCache.attachmentPath = "";
0364                 root.forceActiveFocus();
0365             }
0366         }
0367     }
0368 
0369     QtObject {
0370         id: _private
0371         property ChatBarCache chatBarCache
0372         onChatBarCacheChanged: documentHandler.chatBarCache = chatBarCache
0373 
0374         function postMessage() {
0375             root.actionsHandler.handleMessageEvent(_private.chatBarCache);
0376             repeatTimer.stop();
0377             root.currentRoom.markAllMessagesAsRead();
0378             textField.clear();
0379             _private.chatBarCache.replyId = "";
0380             messageSent();
0381         }
0382 
0383         function formatText(format, selectionStart, selectionEnd) {
0384             let index = textField.cursorPosition;
0385 
0386             /*
0387             * There cannot be white space at the beginning or end of the string for the
0388             * formatting to work so move the sectionStart and sectionEnd markers past any whitespace.
0389             */
0390             let innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
0391             if (innerText.charAt(innerText.length - 1) === " ") {
0392                 let trimmedRightString = innerText.replace(/\s*$/, "");
0393                 let trimDifference = innerText.length - trimmedRightString.length;
0394                 selectionEnd -= trimDifference;
0395             }
0396             if (innerText.charAt(0) === " ") {
0397                 let trimmedLeftString = innerText.replace(/^\s*/, "");
0398                 let trimDifference = innerText.length - trimmedLeftString.length;
0399                 selectionStart = selectionStart + trimDifference;
0400             }
0401             let startText = textField.text.substr(0, selectionStart);
0402             // Needs updating with the new selectionStart and selectionEnd with white space trimmed.
0403             innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
0404             let endText = textField.text.substr(selectionEnd);
0405             textField.text = "";
0406             textField.text = startText + format.start + innerText + format.end + format.extra + endText;
0407 
0408             /*
0409             * Put the cursor where it was when the popup was opened accounting for the
0410             * new markup.
0411             *
0412             * The exception is for a hyperlink where it is placed ready to start typing
0413             * the url.
0414             */
0415             if (format.extra !== "") {
0416                 textField.cursorPosition = selectionEnd + format.start.length + format.end.length;
0417             } else if (index == selectionStart) {
0418                 textField.cursorPosition = index;
0419             } else {
0420                 textField.cursorPosition = index + format.start.length + format.end.length;
0421             }
0422         }
0423 
0424         function pasteImage() {
0425             let localPath = Clipboard.saveImage();
0426             if (localPath.length === 0) {
0427                 return false;
0428             }
0429             _private.chatBarCache.attachmentPath = localPath;
0430             return true;
0431         }
0432     }
0433 
0434     ChatDocumentHandler {
0435         id: documentHandler
0436         document: textField.textDocument
0437         cursorPosition: textField.cursorPosition
0438         selectionStart: textField.selectionStart
0439         selectionEnd: textField.selectionEnd
0440         mentionColor: Kirigami.Theme.linkColor
0441         errorColor: Kirigami.Theme.negativeTextColor
0442         Component.onCompleted: {
0443             RoomManager.chatDocumentHandler = documentHandler;
0444         }
0445     }
0446 
0447     Component {
0448         id: openFileDialog
0449 
0450         OpenFileDialog {
0451             parentWindow: Window.window
0452             currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
0453         }
0454     }
0455 
0456     Component {
0457         id: attachDialog
0458 
0459         AttachDialog {
0460             anchors.centerIn: parent
0461         }
0462     }
0463 
0464     Component {
0465         id: locationChooser
0466         LocationChooser {}
0467     }
0468 
0469     CompletionMenu {
0470         id: completionMenu
0471         chatDocumentHandler: documentHandler
0472         connection: root.connection
0473 
0474         x: 1
0475         y: -height
0476         width: parent.width - 1
0477         Behavior on height {
0478             NumberAnimation {
0479                 property: "height"
0480                 duration: Kirigami.Units.shortDuration
0481                 easing.type: Easing.OutCubic
0482             }
0483         }
0484     }
0485 
0486     EmojiDialog {
0487         id: emojiDialog
0488 
0489         x: root.width - width
0490         y: -implicitHeight
0491 
0492         modal: false
0493         includeCustom: true
0494         closeOnChosen: false
0495 
0496         currentRoom: root.currentRoom
0497 
0498         onChosen: emoji => insertText(emoji)
0499         onClosed: if (emojiAction.checked) {
0500             emojiAction.checked = false;
0501         }
0502     }
0503 
0504     function insertText(text) {
0505         let initialCursorPosition = textField.cursorPosition;
0506         textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition);
0507         textField.cursorPosition = initialCursorPosition + text.length;
0508     }
0509 }