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 }