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, {room: root.currentRoom}).open()
0102             }
0103             tooltip: text
0104         },
0105         Kirigami.Action {
0106             id: sendAction
0107 
0108             property bool isBusy: false
0109 
0110             icon.name: "document-send"
0111             text: i18n("Send message")
0112             displayHint: Kirigami.DisplayHint.IconOnly
0113             checkable: true
0114 
0115             onTriggered: {
0116                 _private.postMessage()
0117             }
0118 
0119             tooltip: text
0120         }
0121     ]
0122 
0123     /**
0124      * @brief A message has been sent from the chat bar.
0125      */
0126     signal messageSent()
0127 
0128     spacing: 0
0129 
0130     Kirigami.Theme.colorSet: Kirigami.Theme.View
0131     Kirigami.Theme.inherit: false
0132 
0133     background: Rectangle {
0134         color: Kirigami.Theme.backgroundColor
0135         Kirigami.Separator {
0136             anchors.left: parent.left
0137             anchors.right:parent.right
0138             anchors.top: parent.top
0139         }
0140     }
0141 
0142     leftPadding: rightPadding
0143     rightPadding: (root.width - chatBarSizeHelper.currentWidth) / 2
0144     topPadding: 0
0145     bottomPadding: 0
0146 
0147     contentItem: ColumnLayout {
0148         spacing: 0
0149         Item { // Required to adjust for the top separator
0150             Layout.preferredHeight: 1
0151             Layout.fillWidth: true
0152         }
0153         Loader {
0154             id: paneLoader
0155 
0156             Layout.fillWidth: true
0157             Layout.margins: Kirigami.Units.largeSpacing
0158 
0159             active: visible
0160             visible: root.currentRoom.mainCache.replyId.length > 0 || root.currentRoom.mainCache.attachmentPath.length > 0
0161             sourceComponent: root.currentRoom.mainCache.replyId.length > 0 ? replyPane : attachmentPane
0162         }
0163         RowLayout {
0164             QQC2.ScrollView {
0165                 id: chatBarScrollView
0166 
0167                 Layout.fillWidth: true
0168                 Layout.maximumHeight: Kirigami.Units.gridUnit * 8
0169 
0170                 Layout.topMargin: Kirigami.Units.smallSpacing
0171                 Layout.bottomMargin: Kirigami.Units.smallSpacing
0172                 Layout.minimumHeight: Kirigami.Units.gridUnit * 2
0173 
0174                 // HACK: This is to stop the ScrollBar flickering on and off as the height is increased
0175                 QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded
0176 
0177                 Behavior on implicitHeight {
0178                     NumberAnimation {
0179                         id: chatBarHeightAnimation
0180                         duration: Kirigami.Units.shortDuration
0181                         easing.type: Easing.InOutCubic
0182                     }
0183                 }
0184 
0185                 QQC2.TextArea {
0186                     id: textField
0187 
0188                     placeholderText: root.currentRoom.usesEncryption ? i18n("Send an encrypted message…") : root.currentRoom.mainCache.attachmentPath.length > 0 ? i18n("Set an attachment caption…") : i18n("Send a message…")
0189                     verticalAlignment: TextEdit.AlignVCenter
0190                     wrapMode: TextEdit.Wrap
0191 
0192                     Accessible.description: placeholderText
0193 
0194                     Kirigami.SpellCheck.enabled: false
0195 
0196                     Timer {
0197                         id: repeatTimer
0198                         interval: 5000
0199                     }
0200 
0201                     onTextChanged: {
0202                         if (!repeatTimer.running && Config.typingNotifications) {
0203                             var textExists = text.length > 0
0204                             root.currentRoom.sendTypingNotification(textExists)
0205                             textExists ? repeatTimer.start() : repeatTimer.stop()
0206                         }
0207                         _private.chatBarCache.text = text
0208                     }
0209                     onSelectedTextChanged: {
0210                         if (selectedText.length > 0) {
0211                             quickFormatBar.selectionStart = selectionStart
0212                             quickFormatBar.selectionEnd = selectionEnd
0213                             quickFormatBar.open()
0214                         }
0215                     }
0216 
0217                     QuickFormatBar {
0218                         id: quickFormatBar
0219 
0220                         x: textField.cursorRectangle.x
0221                         y: textField.cursorRectangle.y - height
0222 
0223                         onFormattingSelected: root.formatText(format, selectionStart, selectionEnd)
0224                     }
0225 
0226                     Keys.onDeletePressed: {
0227                         if (selectedText.length > 0) {
0228                             remove(selectionStart, selectionEnd)
0229                         } else {
0230                             remove(cursorPosition, cursorPosition + 1)
0231                         }
0232                         if (textField.text == selectedText || textField.text.length <= 1) {
0233                             root.currentRoom.sendTypingNotification(false)
0234                             repeatTimer.stop()
0235                         }
0236                         if (quickFormatBar.visible) {
0237                             quickFormatBar.close()
0238                         }
0239                     }
0240                     Keys.onEnterPressed: event => {
0241                         if (completionMenu.visible) {
0242                             completionMenu.complete()
0243                         } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) {
0244                             textField.insert(cursorPosition, "\n")
0245                         } else {
0246                             _private.postMessage();
0247                         }
0248                     }
0249                     Keys.onReturnPressed: event => {
0250                         if (completionMenu.visible) {
0251                             completionMenu.complete()
0252                         } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) {
0253                             textField.insert(cursorPosition, "\n")
0254                         } else {
0255                             _private.postMessage();
0256                         }
0257                     }
0258                     Keys.onTabPressed: {
0259                         if (completionMenu.visible) {
0260                             completionMenu.complete()
0261                         }
0262                     }
0263                     Keys.onPressed: (event) => {
0264                         if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
0265                             event.accepted = _private.pasteImage();
0266                         } else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
0267                             root.currentRoom.replyLastMessage();
0268                         } else if (event.key === Qt.Key_Up && textField.text.length === 0) {
0269                             root.currentRoom.editLastMessage();
0270                         } else if (event.key === Qt.Key_Up && completionMenu.visible) {
0271                             completionMenu.decrementIndex()
0272                         } else if (event.key === Qt.Key_Down && completionMenu.visible) {
0273                             completionMenu.incrementIndex()
0274                         } else if (event.key === Qt.Key_Backspace) {
0275                             if (textField.text == selectedText || textField.text.length <= 1) {
0276                                 root.currentRoom.sendTypingNotification(false)
0277                                 repeatTimer.stop()
0278                             }
0279                             if (quickFormatBar.visible && selectedText.length > 0) {
0280                                 quickFormatBar.close()
0281                             }
0282                         }
0283                     }
0284                     Keys.onShortcutOverride: event => {
0285                         if (completionMenu.visible) {
0286                             completionMenu.close()
0287                         } else if ((_private.chatBarCache.isReplying || _private.chatBarCache.attachmentPath.length > 0) && event.key === Qt.Key_Escape) {
0288                             _private.chatBarCache.attachmentPath = ""
0289                             _private.chatBarCache.replyId = ""
0290                             _private.chatBarCache.threadId = ""
0291                             event.accepted = true;
0292                         }
0293                     }
0294 
0295                     background: MouseArea {
0296                         acceptedButtons: Qt.NoButton
0297                         cursorShape: Qt.IBeamCursor
0298                         z: 1
0299                     }
0300                 }
0301             }
0302             RowLayout {
0303                 id: actionsRow
0304                 spacing: 0
0305                 Layout.alignment: Qt.AlignBottom
0306                 Layout.bottomMargin: Kirigami.Units.smallSpacing * 1.5
0307 
0308                 Repeater {
0309                     model: root.actions
0310                     delegate: QQC2.ToolButton {
0311                         Layout.alignment: Qt.AlignVCenter
0312                         icon.name: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source)
0313                         onClicked: modelData.trigger()
0314 
0315                         QQC2.ToolTip.visible: hovered
0316                         QQC2.ToolTip.text: modelData.tooltip
0317                         QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
0318 
0319                         PieProgressBar {
0320                             visible: modelData.isBusy
0321                             progress: root.currentRoom.fileUploadingProgress
0322                         }
0323                     }
0324                 }
0325             }
0326         }
0327     }
0328 
0329     DelegateSizeHelper {
0330         id: chatBarSizeHelper
0331         startBreakpoint: Kirigami.Units.gridUnit * 46
0332         endBreakpoint: Kirigami.Units.gridUnit * 66
0333         startPercentWidth: 100
0334         endPercentWidth: Config.compactLayout ? 100 : 85
0335         maxWidth: Config.compactLayout ? -1 : Kirigami.Units.gridUnit * 60
0336 
0337         parentWidth: root.width
0338     }
0339 
0340     Component {
0341         id: replyPane
0342         ReplyPane {
0343             userName: _private.chatBarCache.relationUser.displayName
0344             userColor: _private.chatBarCache.relationUser.color
0345             userAvatar: _private.chatBarCache.relationUser.avatarSource
0346             text: _private.chatBarCache.relationMessage
0347 
0348             onCancel: {
0349                 _private.chatBarCache.replyId = "";
0350                 _private.chatBarCache.attachmentPath = "";
0351             }
0352         }
0353     }
0354     Component {
0355         id: attachmentPane
0356         AttachmentPane {
0357             attachmentPath: _private.chatBarCache.attachmentPath
0358 
0359             onAttachmentCancelled: {
0360                 _private.chatBarCache.attachmentPath = "";
0361                 root.forceActiveFocus()
0362             }
0363         }
0364     }
0365 
0366     QtObject {
0367         id: _private
0368         property ChatBarCache chatBarCache
0369         onChatBarCacheChanged: documentHandler.chatBarCache = chatBarCache
0370 
0371         function postMessage() {
0372             root.actionsHandler.handleMessageEvent(_private.chatBarCache);
0373             repeatTimer.stop()
0374             root.currentRoom.markAllMessagesAsRead();
0375             textField.clear();
0376             _private.chatBarCache.replyId = "";
0377             messageSent()
0378         }
0379 
0380         function formatText(format, selectionStart, selectionEnd) {
0381             let index = textField.cursorPosition;
0382 
0383             /*
0384             * There cannot be white space at the beginning or end of the string for the
0385             * formatting to work so move the sectionStart and sectionEnd markers past any whitespace.
0386             */
0387             let innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
0388             if (innerText.charAt(innerText.length - 1) === " ") {
0389                 let trimmedRightString = innerText.replace(/\s*$/,"");
0390                 let trimDifference = innerText.length - trimmedRightString.length;
0391                 selectionEnd -= trimDifference;
0392             }
0393             if (innerText.charAt(0) === " ") {
0394                 let trimmedLeftString = innerText.replace(/^\s*/,"");
0395                 let trimDifference = innerText.length - trimmedLeftString.length;
0396                 selectionStart = selectionStart + trimDifference;
0397             }
0398 
0399             let startText = textField.text.substr(0, selectionStart);
0400             // Needs updating with the new selectionStart and selectionEnd with white space trimmed.
0401             innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
0402             let endText = textField.text.substr(selectionEnd);
0403 
0404             textField.text = "";
0405             textField.text = startText + format.start + innerText + format.end + format.extra + endText;
0406 
0407             /*
0408             * Put the cursor where it was when the popup was opened accounting for the
0409             * new markup.
0410             *
0411             * The exception is for a hyperlink where it is placed ready to start typing
0412             * the url.
0413             */
0414             if (format.extra !== "") {
0415                 textField.cursorPosition = selectionEnd + format.start.length + format.end.length;
0416             } else if (index == selectionStart) {
0417                 textField.cursorPosition = index;
0418             } else {
0419                 textField.cursorPosition = index + format.start.length + format.end.length;
0420             }
0421         }
0422 
0423         function pasteImage() {
0424             let localPath = Clipboard.saveImage();
0425             if (localPath.length === 0) {
0426                 return false;
0427             }
0428             _private.chatBarCache.attachmentPath = localPath;
0429             return true;
0430         }
0431     }
0432 
0433     ChatDocumentHandler {
0434         id: documentHandler
0435         document: textField.textDocument
0436         cursorPosition: textField.cursorPosition
0437         selectionStart: textField.selectionStart
0438         selectionEnd: textField.selectionEnd
0439         mentionColor: Kirigami.Theme.linkColor
0440         errorColor: Kirigami.Theme.negativeTextColor
0441         Component.onCompleted: {
0442             RoomManager.chatDocumentHandler = documentHandler;
0443         }
0444     }
0445 
0446     Component {
0447         id: openFileDialog
0448 
0449         OpenFileDialog {
0450             parentWindow: Window.window
0451             currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
0452         }
0453     }
0454 
0455     Component {
0456         id: attachDialog
0457 
0458         AttachDialog {
0459             anchors.centerIn: parent
0460         }
0461     }
0462 
0463     Component {
0464         id: locationChooser
0465         LocationChooser {}
0466     }
0467 
0468     CompletionMenu {
0469         id: completionMenu
0470         chatDocumentHandler: documentHandler
0471         connection: root.connection
0472 
0473         x: 1
0474         y: -height
0475         width: parent.width - 1
0476         Behavior on height {
0477             NumberAnimation {
0478                 property: "height"
0479                 duration: Kirigami.Units.shortDuration
0480                 easing.type: Easing.OutCubic
0481             }
0482         }
0483     }
0484 
0485     EmojiDialog {
0486         id: emojiDialog
0487 
0488         x: root.width - width
0489         y: -implicitHeight
0490 
0491         modal: false
0492         includeCustom: true
0493         closeOnChosen: false
0494 
0495         currentRoom: root.currentRoom
0496 
0497         onChosen: emoji => insertText(emoji)
0498         onClosed: if (emojiAction.checked) emojiAction.checked = false
0499     }
0500 
0501     function insertText(text) {
0502         let initialCursorPosition = textField.cursorPosition;
0503 
0504         textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition)
0505         textField.cursorPosition = initialCursorPosition + text.length
0506     }
0507 }