/** * SPDX-FileCopyrightText: 2020 Carl Schwan * * SPDX-License-Identifier: GPL-2.0-or-later */ import QtQuick 2.12 import QtQuick.Controls 2.12 import QtQuick.Layouts 1.12 import Qt.labs.platform 1.0 as Platform import org.kde.kirigami 2.13 as Kirigami import NeoChat.Component 1.0 import NeoChat.Component.Emoji 1.0 import NeoChat.Dialog 1.0 import NeoChat.Page 1.0 import org.kde.neochat 1.0 ToolBar { id: root property alias isReply: replyItem.visible property bool isReaction: false property var replyUser property string replyEventID: "" property string replyContent: "" property string editEventId property alias isAutoCompleting: autoCompleteListView.visible property var autoCompleteModel property int autoCompleteBeginPosition property int autoCompleteEndPosition property bool hasAttachment: false property url attachmentPath: "" property var attachmentMimetype: FileType.mimeTypeForUrl(attachmentPath) property bool hasImageAttachment: hasAttachment && attachmentMimetype.valid && FileType.supportedImageFormats.includes(attachmentMimetype.preferredSuffix) signal fancyEffectsReasonFound(string fancyEffect) position: ToolBar.Footer function addText(text) { inputField.insert(inputField.length, text) } Kirigami.Theme.colorSet: Kirigami.Theme.View Action { id: pasteAction shortcut: StandardKey.Paste onTriggered: { if (Clipboard.hasImage) { root.pasteImage(); } activeFocusItem.paste(); } } contentItem: ColumnLayout { id: layout spacing: 0 EmojiPicker { id: emojiPicker Layout.fillWidth: true visible: false textArea: inputField emojiModel: EmojiModel { id: emojiModel } onChosen: { textArea.insert(textArea.cursorPosition, emoji); textArea.forceActiveFocus(); } } ColumnLayout { Layout.fillWidth: true Layout.margins: 8 id: replyItem visible: false RowLayout { Kirigami.Avatar { Layout.preferredWidth: Kirigami.Units.gridUnit Layout.preferredHeight: Kirigami.Units.gridUnit source: replyUser ? ("image://mxc/" + replyUser.avatarMediaId) : "" name: replyUser ? replyUser.name : i18n("No name") } Label { Layout.alignment: Qt.AlignVCenter text: replyUser ? replyUser.displayName : i18n("No name") rightPadding: 8 } } TextEdit { Layout.fillWidth: true text: "" + replyContent color: Kirigami.Theme.textColor selectByMouse: true readOnly: true wrapMode: Label.Wrap textFormat: Text.RichText selectedTextColor: "white" } } ListView { Layout.fillWidth: true Layout.preferredHeight: 36 Layout.margins: 8 id: autoCompleteListView visible: false model: autoCompleteModel clip: true spacing: 4 orientation: ListView.Horizontal highlightFollowsCurrentItem: true keyNavigationWraps: true delegate: Control { readonly property string userId: modelData.id ?? "" readonly property string displayText: modelData.displayName ?? modelData.unicode readonly property bool isEmoji: modelData.unicode != null readonly property bool highlighted: autoCompleteListView.currentIndex == index padding: Kirigami.Units.smallSpacing contentItem: RowLayout { spacing: Kirigami.Units.largeSpacing Label { width: Kirigami.Units.gridUnit height: Kirigami.Units.gridUnit visible: isEmoji text: displayText font.family: "Emoji" font.pointSize: 20 verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignHCenter } Kirigami.Avatar { Layout.preferredWidth: Kirigami.Units.gridUnit Layout.preferredHeight: Kirigami.Units.gridUnit source: modelData.avatarMediaId ? ("image://mxc/" + modelData.avatarMediaId) : "" color: modelData.color ? Qt.darker(modelData.color, 1.1) : null visible: !isEmoji } Label { Layout.fillHeight: true visible: !isEmoji text: displayText color: highlighted ? Kirigami.Theme.highlightTextColor : Kirigami.Theme.textColor font.underline: highlighted verticalAlignment: Text.AlignVCenter rightPadding: Kirigami.Units.largeSpacing } } MouseArea { anchors.fill: parent onClicked: { autoCompleteListView.currentIndex = index inputField.autoComplete(); } } } } Kirigami.Separator { Layout.fillWidth: true Layout.preferredHeight: 1 visible: emojiPicker.visible || replyItem.visible || autoCompleteListView.visible } Image { Layout.preferredHeight: Kirigami.Units.gridUnit * 10 source: attachmentPath visible: hasImageAttachment fillMode: Image.PreserveAspectFit Layout.preferredWidth: paintedWidth RowLayout { anchors.right: parent.right Button { icon.name: "document-edit" // HACK: Use a component because an url doesn't work Component { id: imageEditorPage ImageEditorPage { imagePath: attachmentPath } } onClicked: { let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage, { imagePath: attachmentPath }); imageEditor.newPathChanged.connect(function(newPath) { applicationWindow().pageStack.layers.pop(); attachmentPath = newPath; }); } ToolTip { text: i18n("Edit") } } Button { icon.name: "dialog-cancel" onClicked: { hasAttachment = false; attachmentPath = ""; } ToolTip { text: i18n("Cancel") } } } Rectangle { color: Qt.rgba(255, 255, 255, 40) anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom implicitHeight: fileLabel.implicitHeight Label { id: fileLabel Layout.alignment: Qt.AlignVCenter text: attachmentPath !== "" ? attachmentPath.toString().substring(attachmentPath.toString().lastIndexOf('/') + 1, attachmentPath.length) : "" } } } RowLayout { visible: hasAttachment && !hasImageAttachment ToolButton { icon.name: "dialog-cancel" onClicked: { hasAttachment = false; attachmentPath = ""; } } Kirigami.Icon { id: mimetypeIcon implicitHeight: Kirigami.Units.fontMetrics.roundedIconSize(horizontalFileLabel.implicitHeight) implicitWidth: implicitHeight source: attachmentMimetype.iconName } Label { id: horizontalFileLabel Layout.alignment: Qt.AlignVCenter text: attachmentPath !== "" ? attachmentPath.toString().substring(attachmentPath.toString().lastIndexOf('/') + 1, attachmentPath.length) : "" } } RowLayout { visible: editEventId.length > 0 ToolButton { icon.name: "dialog-cancel" onClicked: clearEditReply(); } Label { Layout.alignment: Qt.AlignVCenter text: i18n("Edit Message") } } Kirigami.Separator { Layout.fillWidth: true Layout.preferredHeight: 1 visible: hasAttachment } RowLayout { Layout.fillWidth: true spacing: 0 //Kirigami.Units.smallSpacing Button { id: cancelReplyButton visible: isReply icon.name: "dialog-cancel" onClicked: clearEditReply() } RowLayout { Layout.fillHeight: true Layout.preferredWidth: replyItem.visible ? Kirigami.Units.largeSpacing : Kirigami.Units.gridUnit * 2 + Kirigami.Units.smallSpacing + Kirigami.Units.largeSpacing ToolButton { id: uploadButton visible: !isReply && !hasAttachment icon.name: "mail-attachment" onClicked: { if (Clipboard.hasImage) { attachDialog.open() } else { var fileDialog = openFileDialog.createObject(ApplicationWindow.overlay) fileDialog.chosen.connect(function(path) { if (!path) return root.attach(path) }) fileDialog.open() } } ToolTip { text: i18n("Attach an image or file") } BusyIndicator { anchors.fill: parent running: currentRoom && currentRoom.hasFileUploading } } } ScrollView { Layout.fillWidth: true Layout.maximumHeight: inputField.lineHeight * 8 TextArea { id: inputField property real progress: 0 property bool autoAppeared: false // store each user we autoComplete here, this will be helpful later to generate // the matrix.to links. // This use an hack to define: https://doc.qt.io/qt-5/qml-var.html#property-value-initialization-semantics property var userAutocompleted: ({}) ChatDocumentHandler { id: documentHandler document: inputField.textDocument cursorPosition: inputField.cursorPosition selectionStart: inputField.selectionStart selectionEnd: inputField.selectionEnd room: currentRoom ?? null } property int lineHeight: contentHeight / lineCount wrapMode: Text.Wrap placeholderText: i18n("Write your message...") topPadding: 0 bottomPadding: 0 leftPadding: Kirigami.Units.smallSpacing selectByMouse: true verticalAlignment: TextEdit.AlignVCenter enabled: room.canSendEvent("m.room.message") text: currentRoom != null ? currentRoom.cachedInput : "" background: MouseArea { acceptedButtons: Qt.NoButton cursorShape: Qt.IBeamCursor z: 1 } Rectangle { width: currentRoom && currentRoom.hasFileUploading ? parent.width * currentRoom.fileUploadingProgress / 100 : 0 height: parent.height opacity: 0.2 } Timer { id: timeoutTimer repeat: false interval: 2000 onTriggered: { repeatTimer.stop() currentRoom.sendTypingNotification(false) } } Timer { id: repeatTimer repeat: true interval: 5000 triggeredOnStart: true onTriggered: currentRoom.sendTypingNotification(true) } Keys.onReturnPressed: { if (isAutoCompleting) { inputField.autoComplete(); isAutoCompleting = false; return; } if (event.modifiers & Qt.ShiftModifier) { insert(cursorPosition, "\n") } else { postMessage() text = "" clearEditReply() closeAll() } } Keys.onEscapePressed: { clearEditReply(); closeAll(); } Keys.onPressed: { if (event.key === Qt.Key_PageDown) { switchRoomDown(); } else if (event.key === Qt.Key_PageUp) { switchRoomUp(); } else if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { root.pasteImage(); } } Keys.onBacktabPressed: { if (event.modifiers & Qt.ControlModifier) { switchRoomUp(); return; } if (isAutoCompleting) { autoCompleteListView.decrementCurrentIndex(); } } Keys.onTabPressed: { if (event.modifiers & Qt.ControlModifier) { switchRoomDown(); return; } if (!isAutoCompleting) { return; } // TODO detect moved cursor // ignore first time tab was clicked so that user can select // first emoji/user if (autoAppeared === false) { autoCompleteListView.incrementCurrentIndex() } else { autoAppeared = false; } inputField.autoComplete(); } onTextChanged: { timeoutTimer.restart() repeatTimer.start() currentRoom.cachedInput = text autoAppeared = false; const autocompletionInfo = documentHandler.getAutocompletionInfo(); if (autocompletionInfo.type === ChatDocumentHandler.Ignore) { return; } if (autocompletionInfo.type === ChatDocumentHandler.None) { isAutoCompleting = false; autoCompleteListView.currentIndex = 0; return; } if (autocompletionInfo.type === ChatDocumentHandler.User) { autoCompleteModel = currentRoom.getUsers(autocompletionInfo.keyword); } else { autoCompleteModel = emojiModel.filterModel(autocompletionInfo.keyword); } if (autoCompleteModel.length === 0) { isAutoCompleting = false; autoCompleteListView.currentIndex = 0; return; } isAutoCompleting = true autoAppeared = true; autoCompleteEndPosition = cursorPosition } function checkForFancyEffectsReason() { if (!Config.showFancyEffects) { return } var inputFieldText = inputField.text.trim() if (inputFieldText.includes('\u{2744}')) { root.fancyEffectsReasonFound("snowflake") } if (inputFieldText.includes('\u{1F386}')) { root.fancyEffectsReasonFound("fireworks") } if (inputFieldText.includes('\u{1F387}')) { root.fancyEffectsReasonFound("fireworks") } if (inputFieldText.includes('\u{1F389}')) { root.fancyEffectsReasonFound("confetti") } if (inputFieldText.includes('\u{1F38A}')) { root.fancyEffectsReasonFound("confetti") } } function postMessage() { checkForFancyEffectsReason(); roomManager.actionsHandler.postMessage(inputField.text.trim(), attachmentPath, replyEventID, editEventId, inputField.userAutocompleted); clearAttachment(); currentRoom.markAllMessagesAsRead(); clear(); text = Qt.binding(function() { return currentRoom != null ? currentRoom.cachedInput : ""; }); } function autoComplete() { documentHandler.replaceAutoComplete(autoCompleteListView.currentItem.displayText) if (!autoCompleteListView.currentItem.isEmoji) { inputField.userAutocompleted[autoCompleteListView.currentItem.displayText] = autoCompleteListView.currentItem.userId; } } } } ToolButton { id: emojiButton icon.name: "preferences-desktop-emoticons" icon.color: "transparent" checkable: true checked: emojiPicker.visible onToggled: emojiPicker.visible = !emojiPicker.visible ToolTip { text: i18n("Add an Emoji") } } ToolButton { icon.name: "document-send" icon.color: "transparent" enabled: inputField.length > 0 || hasAttachment onClicked: { inputField.postMessage() inputField.text = "" root.clearEditReply() root.closeAll() } ToolTip { text: i18n("Send message") } } } } background: Rectangle { implicitHeight: 40 color: Kirigami.Theme.backgroundColor Kirigami.Separator { anchors { left: parent.left right: parent.right top: parent.top } } } function insert(str) { inputField.insert(inputField.cursorPosition, str) } function clear() { inputField.clear(); inputField.userAutocompleted = {}; } function clearEditReply() { isReply = false; replyUser = null; clear(); root.replyContent = ""; root.replyEventID = ""; root.editEventId = ""; focus(); } function focus() { inputField.forceActiveFocus() } function edit(editContent, editFormatedContent, editEventId) { console.log("Editing ", editContent, "html:", editFormatedContent) // Set the input field in edit mode inputField.text = editContent; root.editEventId = editEventId // clean autocompletion list inputField.userAutocompleted = {}; // Fill autocompletion list with values extracted from message. // We can't just iterate on every user in the list and try to // find matching display name since some users have display name // matching frequent words and this will marks too many words as // mentions. const regex = /([^<]*)<\/a>/g; let match; while ((match = regex.exec(editFormatedContent.toString())) !== null) { inputField.userAutocompleted[match[2]] = match[1]; } } function closeAll() { replyItem.visible = false autoCompleteListView.visible = false emojiPicker.visible = false } function attach(localPath) { hasAttachment = true attachmentPath = localPath } function clearAttachment() { hasAttachment = false attachmentPath = "" } function pasteImage() { var localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png"; if (!Clipboard.saveImage(localPath)) { return; } root.attach(localPath); } }