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 }