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 }