From 38e2c7222b3e30b30c07d25fb7402b0ae820d93d Mon Sep 17 00:00:00 2001 From: Noah Davis Date: Wed, 17 Mar 2021 23:48:06 +0000 Subject: [PATCH] This splits ChatTextInput into ChatBox and a handful of subcomponents. - ChatBar: Contains the main TextArea and standard buttons. - Usually visible, but can be disabled when necessary. - AttachmentPane: Contains an image when attaching an image and also a filename with mimetype icon. - Has a toolbar to cancel the attachment or edit it if it's an image. - Shown when there is an attachment. - ReplyPane: Shows who you are replying to and the content of their message. - Also shows edits and has a button to cancel replies/edits - Shown when replying or editing - CompletionMenu - Now a vertical list using a QQC2.Popup - Either a Pane or a Menu/Popup - EmojiPickerPane @teams/vdg --- .../Component/ChatBox/AttachmentPane.qml | 202 ++++++ imports/NeoChat/Component/ChatBox/ChatBar.qml | 342 +++++++++ imports/NeoChat/Component/ChatBox/ChatBox.qml | 294 ++++++++ .../Component/ChatBox/CompletionMenu.qml | 114 +++ .../NeoChat/Component/ChatBox/ReplyPane.qml | 116 +++ imports/NeoChat/Component/ChatBox/qmldir | 7 + imports/NeoChat/Component/ChatTextInput.qml | 671 ------------------ .../Component/Timeline/TimelineContainer.qml | 3 +- imports/NeoChat/Page/RoomPage.qml | 36 +- res.qrc | 8 +- 10 files changed, 1103 insertions(+), 690 deletions(-) create mode 100644 imports/NeoChat/Component/ChatBox/AttachmentPane.qml create mode 100644 imports/NeoChat/Component/ChatBox/ChatBar.qml create mode 100644 imports/NeoChat/Component/ChatBox/ChatBox.qml create mode 100644 imports/NeoChat/Component/ChatBox/CompletionMenu.qml create mode 100644 imports/NeoChat/Component/ChatBox/ReplyPane.qml create mode 100644 imports/NeoChat/Component/ChatBox/qmldir delete mode 100644 imports/NeoChat/Component/ChatTextInput.qml diff --git a/imports/NeoChat/Component/ChatBox/AttachmentPane.qml b/imports/NeoChat/Component/ChatBox/AttachmentPane.qml new file mode 100644 index 000000000..f07aba717 --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/AttachmentPane.qml @@ -0,0 +1,202 @@ +/* SPDX-FileCopyrightText: 2020 Carl Schwan + * SPDX-FileCopyrightText: 2020 Noah Davis + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import org.kde.kirigami 2.14 as Kirigami + +import NeoChat.Page 1.0 +import org.kde.neochat 1.0 + +Loader { + id: root + + property string attachmentPath: "" + property var attachmentMimetype: FileType.mimeTypeForUrl(attachmentPath) + readonly property bool hasImage: attachmentMimetype.valid && FileType.supportedImageFormats.includes(attachmentMimetype.preferredSuffix) + + signal clearAttachmentTriggered() + + active: visible + sourceComponent: Component { + Pane { + id: attachmentPane + property string baseFileName: attachmentPath.toString().substring(attachmentPath.toString().lastIndexOf('/') + 1, attachmentPath.length) + Kirigami.Theme.colorSet: Kirigami.Theme.View + + contentItem: Item { + property real spacing: attachmentPane.spacing > 0 ? attachmentPane.spacing : toolBar.spacing + implicitWidth: Math.max(image.implicitWidth, imageBusyIndicator.implicitWidth, fileInfoLayout.implicitWidth, toolBar.implicitWidth) + implicitHeight: Math.max( + (hasImage ? Math.max(image.preferredHeight, imageBusyIndicator.implicitHeight) + spacing : 0) + + fileInfoLayout.implicitHeight, + toolBar.implicitHeight + ) + + Image { + id: image + property real preferredHeight: Math.min(implicitHeight, Kirigami.Units.gridUnit * 8) + height: preferredHeight + anchors { + horizontalCenter: parent.horizontalCenter + bottom: fileInfoLayout.top + bottomMargin: parent.spacing + } + width: Math.min(implicitWidth, attachmentPane.availableWidth) + asynchronous: true + cache: false // Cache is not needed. Images will rarely be shown repeatedly. + smooth: height == preferredHeight && parent.height == parent.implicitHeight // Don't smooth until height animation stops + source: hasImage ? attachmentPath : "" + visible: hasImage + fillMode: Image.PreserveAspectFit + + onSourceChanged: { + // Reset source size height, which affect implicitHeight + sourceSize.height = -1 + } + + onSourceSizeChanged: { + if (implicitHeight > Kirigami.Units.gridUnit * 8) { + // This can save a lot of RAM when loading large images. + // It also improves visual quality for large images. + sourceSize.height = Kirigami.Units.gridUnit * 8 + } + } + + Behavior on height { + NumberAnimation { + property: "height" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + } + + BusyIndicator { + id: imageBusyIndicator + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + bottom: fileInfoLayout.top + bottomMargin: parent.spacing + } + visible: running + running: image.visible && image.progress < 1 + } + + RowLayout { + id: fileInfoLayout + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: undefined + anchors.bottom: parent.bottom + spacing: parent.spacing + + Kirigami.Icon { + id: mimetypeIcon + implicitHeight: Kirigami.Units.fontMetrics.roundedIconSize(fileLabel.implicitHeight) + implicitWidth: implicitHeight + source: attachmentMimetype.iconName + } + + Label { + id: fileLabel + text: baseFileName + } + + states: State { + when: !hasImage + AnchorChanges { + target: fileInfoLayout + anchors.bottom: undefined + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Using a toolbar to get a button spacing consistent with what the QQC2 style normally has + // Also has some accessibility info + ToolBar { + id: toolBar + width: parent.width + anchors.top: parent.top + + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + + Kirigami.Theme.inherit: true + Kirigami.Theme.colorSet: Kirigami.Theme.View + + contentItem: RowLayout { + spacing: parent.spacing + Label { + Layout.leftMargin: -attachmentPane.leftPadding + Layout.topMargin: -attachmentPane.topPadding + leftPadding: cancelAttachmentButton.leftPadding + 1 + attachmentPane.leftPadding + rightPadding: cancelAttachmentButton.rightPadding + 1 + topPadding: cancelAttachmentButton.topPadding + attachmentPane.topPadding + bottomPadding: cancelAttachmentButton.bottomPadding + text: i18n("Attachment:") + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + background: Kirigami.ShadowedRectangle { + property real cornerRadius: cancelAttachmentButton.background.hasOwnProperty("radius") ? + Math.min(cancelAttachmentButton.background.radius, height/2) : 0 + corners.bottomLeftRadius: toolBar.mirrored ? cornerRadius : 0 + corners.bottomRightRadius: toolBar.mirrored ? 0 : cornerRadius + color: Kirigami.Theme.backgroundColor + opacity: 0.75 + } + } + Item { + Layout.fillWidth: true + } + Button { + id: editImageButton + visible: hasImage + icon.name: "document-edit" + text: i18n("Edit") + display: AbstractButton.IconOnly + + Component { + id: imageEditorPage + ImageEditorPage { + imagePath: attachmentPath + } + } + onClicked: { + let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage); + imageEditor.newPathChanged.connect(function(newPath) { + applicationWindow().pageStack.layers.pop(); + attachmentPath = newPath; + }); + } + ToolTip.text: text + ToolTip.visible: hovered + } + Button { + id: cancelAttachmentButton + icon.name: "dialog-cancel" + text: i18n("Cancel") + display: AbstractButton.IconOnly + onClicked: { + clearAttachmentTriggered(); + } + ToolTip.text: text + ToolTip.visible: hovered + } + } + background: null + } + } + + background: Rectangle { + color: Kirigami.Theme.backgroundColor + } + } + } +} diff --git a/imports/NeoChat/Component/ChatBox/ChatBar.qml b/imports/NeoChat/Component/ChatBox/ChatBar.qml new file mode 100644 index 000000000..01068eb4b --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/ChatBar.qml @@ -0,0 +1,342 @@ +/* SPDX-FileCopyrightText: 2020 Carl Schwan + * SPDX-FileCopyrightText: 2020 Noah Davis + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import Qt.labs.platform 1.0 as Platform +import org.kde.kirigami 2.14 as Kirigami + +import org.kde.neochat 1.0 + +ToolBar { + id: chatBar + property string replyEventId: "" + property string editEventId: "" + property string inputFieldText: currentRoom ? currentRoom.cachedInput : "" + property alias textField: inputField + property alias emojiPaneOpened: emojiButton.checked + + // 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: ({}) + + signal attachTriggered(string localPath) + signal closeAllTriggered() + signal inputFieldForceActiveFocusTriggered() + signal messageSent() + signal pasteImageTriggered() + + property alias isCompleting: completionMenu.visible + + onInputFieldForceActiveFocusTriggered: inputField.forceActiveFocus() + + position: ToolBar.Footer + + Kirigami.Theme.colorSet: Kirigami.Theme.View + + // Using a custom background because some styles like Material + // or Fusion might have ugly colors for a TextArea placed inside + // of a toolbar. ToolBar is otherwise the closest QQC2 component + // to what we want because of the padding and spacing values. + background: Rectangle { + color: Kirigami.Theme.backgroundColor + } + + contentItem: RowLayout { + spacing: chatBar.spacing + + ScrollView { + Layout.fillHeight: true + Layout.fillWidth: true + Layout.minimumHeight: inputField.implicitHeight + // lineSpacing is height+leading, so subtract leading once since leading only exists between lines. + Layout.maximumHeight: fontMetrics.lineSpacing * 8 - fontMetrics.leading + + inputField.topPadding + inputField.bottomPadding + + FontMetrics { + id: fontMetrics + font: inputField.font + } + TextArea { + id: inputField + focus: true + /* Some QQC2 styles will have their own predefined backgrounds for TextAreas. + * Make sure there is no background since we are using the ToolBar background. + * + * This could cause a problem if the QQC2 style was designed around TextArea + * background colors being very different from the QPalette::Base color. + * Luckily, none of the Qt QQC2 styles do that and neither do KDE's QQC2 styles. + */ + background: null + leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing + rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing + topPadding: 0 + bottomPadding: 0 + + property real progress: 0 + property bool autoAppeared: false + //property int lineHeight: contentHeight / lineCount + + text: inputFieldText + placeholderText: editEventId.length > 0 ? i18n("Edit Message") : i18n("Write your message...") + verticalAlignment: TextEdit.AlignVCenter + horizontalAlignment: TextEdit.AlignLeft + wrapMode: Text.Wrap + + ChatDocumentHandler { + id: documentHandler + document: inputField.textDocument + cursorPosition: inputField.cursorPosition + selectionStart: inputField.selectionStart + selectionEnd: inputField.selectionEnd + room: currentRoom ?? null + } + + 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 (isCompleting) { + chatBar.complete(); + + isCompleting = false; + return; + } + if (event.modifiers & Qt.ShiftModifier) { + inputField.insert(cursorPosition, "\n") + } else { + chatBar.postMessage() + } + } + + Keys.onEscapePressed: { + closeAllTriggered() + } + + 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) { + chatBar.pasteImage(); + } + } + + Keys.onBacktabPressed: { + if (event.modifiers & Qt.ControlModifier) { + switchRoomUp(); + return; + } + if (isCompleting) { + let decrementedIndex = completionMenu.currentIndex - 1 + // Wrap around to the last item + if (decrementedIndex < 0) { + decrementedIndex = Math.max(completionMenu.count - 1, 0) // 0 if count == 0 + } + completionMenu.currentIndex = decrementedIndex + } + } + + Keys.onTabPressed: { + if (event.modifiers & Qt.ControlModifier) { + switchRoomDown(); + return; + } + if (!isCompleting) { + return; + } + + // TODO detect moved cursor + + // ignore first time tab was clicked so that user can select + // first emoji/user + if (autoAppeared === false) { + let incrementedIndex = completionMenu.currentIndex + 1; + // Wrap around to the first item + if (incrementedIndex > completionMenu.count - 1) { + incrementedIndex = 0 + } + completionMenu.currentIndex = incrementedIndex; + } else { + autoAppeared = false; + } + + chatBar.complete(); + } + + onTextChanged: { + timeoutTimer.restart() + repeatTimer.start() + currentRoom.cachedInput = text + autoAppeared = false; + + const completionInfo = documentHandler.getAutocompletionInfo(); + + if (completionInfo.type === ChatDocumentHandler.Ignore) { + return; + } + if (completionInfo.type === ChatDocumentHandler.None) { + isCompleting = false; + return; + } + + if (completionInfo.type === ChatDocumentHandler.User) { + completionMenu.isCompletingEmoji = false + completionMenu.model = currentRoom.getUsers(completionInfo.keyword); + } else { + completionMenu.isCompletingEmoji = true + completionMenu.model = completionMenu.emojiModel.filterModel(completionInfo.keyword); + } + + if (completionMenu.model.length === 0) { + isCompleting = false; + return; + } + isCompleting = true + autoAppeared = true; + completionMenu.endPosition = cursorPosition + } + } + } + + Item { + visible: !isReply && (!hasAttachment || uploadingBusySpinner.running) + implicitWidth: uploadButton.implicitWidth + implicitHeight: uploadButton.implicitHeight + ToolButton { + id: uploadButton + anchors.fill: parent + // Matrix does not allow sending attachments in replies + visible: !isReply && !hasAttachment && !uploadingBusySpinner.running + icon.name: "mail-attachment" + text: i18n("Attach an image or file") + display: AbstractButton.IconOnly + + onClicked: { + if (Clipboard.hasImage) { + attachDialog.open() + } else { + var fileDialog = openFileDialog.createObject(ApplicationWindow.overlay) + fileDialog.chosen.connect((path) => { + if (!path) { return } + attachTriggered(path) + }) + fileDialog.open() + } + } + + ToolTip.text: text + ToolTip.visible: hovered + } + BusyIndicator { + id: uploadingBusySpinner + anchors.fill: parent + visible: running + running: currentRoom && currentRoom.hasFileUploading + } + } + + ToolButton { + id: emojiButton + icon.name: "preferences-desktop-emoticons" + text: i18n("Add an Emoji") + display: AbstractButton.IconOnly + checkable: true + + ToolTip.text: text + ToolTip.visible: hovered + } + + ToolButton { + id: sendButton + icon.name: "document-send" + text: i18n("Send message") + display: AbstractButton.IconOnly + + onClicked: { + chatBar.postMessage() + } + + ToolTip.text: text + ToolTip.visible: hovered + } + } + + Action { + id: pasteAction + shortcut: StandardKey.Paste + onTriggered: { + if (Clipboard.hasImage) { + pasteImageTriggered(); + } + activeFocusItem.paste(); + } + } + + CompletionMenu { + id: completionMenu + width: parent.width + //height: 80 //Math.min(implicitHeight, delegate.implicitHeight * 6) + height: implicitHeight + y: -height - 1 + z: 1 + Behavior on height { + NumberAnimation { + property: "height" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + onCompleteTriggered: { + complete() + isCompleting = false; + } + } + + function pasteImage() { + let localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png"; + if (!Clipboard.saveImage(localPath)) { + return; + } + attachTriggered(localPath) + } + + function postMessage() { + checkForFancyEffectsReason(); + roomManager.actionsHandler.postMessage(inputField.text.trim(), attachmentPath, + replyEventId, editEventId, userAutocompleted); + currentRoom.markAllMessagesAsRead(); + inputField.clear(); + inputField.text = Qt.binding(function() { + return currentRoom ? currentRoom.cachedInput : ""; + }); + messageSent() + } + + function complete() { + documentHandler.replaceAutoComplete(completionMenu.currentDisplayText); + if (!completionMenu.isCompletingEmoji) { + userAutocompleted[completionMenu.currentDisplayText] = completionMenu.currentUserId; + } + } +} diff --git a/imports/NeoChat/Component/ChatBox/ChatBox.qml b/imports/NeoChat/Component/ChatBox/ChatBox.qml new file mode 100644 index 000000000..aa408d467 --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/ChatBox.qml @@ -0,0 +1,294 @@ +/* SPDX-FileCopyrightText: 2020 Carl Schwan + * SPDX-FileCopyrightText: 2020 Noah Davis + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick 2.15 +import Qt.labs.platform 1.0 as Platform +import org.kde.kirigami 2.14 as Kirigami + +import NeoChat.Component.ChatBox 1.0 +import NeoChat.Component.Emoji 1.0 +import org.kde.neochat 1.0 + +Item { + id: root + readonly property bool isReply: replyEventId.length > 0 + property var replyUser + property alias replyEventId: chatBar.replyEventId + property string replyContent: "" + + readonly property bool hasAttachment: attachmentPath.length > 0 + property string attachmentPath: "" + + property alias inputFieldText: chatBar.inputFieldText + + readonly property bool isEdit: editEventId.length > 0 + property alias editEventId: chatBar.editEventId + + signal fancyEffectsReasonFound(string fancyEffect) + + Kirigami.Theme.colorSet: Kirigami.Theme.View + + implicitWidth: { + let w = 0 + for(let i = 0; i < visibleChildren.length; ++i) { + w = Math.max(w, Math.ceil(visibleChildren[i].implicitWidth)) + } + return w + } + implicitHeight: { + let h = 0 + for(let i = 0; i < visibleChildren.length; ++i) { + h += Math.ceil(visibleChildren[i].implicitHeight) + } + return h + } + + // For some reason, this is needed to make the height animation work even though + // it used to work and height should be directly affected by implicitHeight + height: implicitHeight + + Behavior on height { + NumberAnimation { + property: "height" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + + Kirigami.Separator { + id: emojiPickerLoaderSeparator + visible: emojiPickerLoader.visible + width: parent.width + height: visible ? implicitHeight : 0 + anchors.bottom: emojiPickerLoader.top + z: 1 + } + + Loader { + id: emojiPickerLoader + active: visible + visible: chatBar.emojiPaneOpened + width: parent.width + height: visible ? implicitHeight : 0 + anchors.bottom: replySeparator.top + sourceComponent: EmojiPicker{ + textArea: chatBar.textField + emojiModel: EmojiModel { id: emojiModel } + } + Behavior on height { + NumberAnimation { + property: "height" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + + } + + Kirigami.Separator { + id: replySeparator + visible: replyPane.visible + width: parent.width + height: visible ? implicitHeight : 0 + anchors.bottom: replyPane.top + } + + ReplyPane { + id: replyPane + visible: isReply || isEdit + isEdit: root.isEdit + user: root.replyUser + content: root.replyContent + width: parent.width + height: visible ? implicitHeight : 0 + anchors.bottom: attachmentSeparator.top + Behavior on height { + NumberAnimation { + property: "height" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + } + + Kirigami.Separator { + id: attachmentSeparator + visible: attachmentPane.visible + width: parent.width + height: visible ? implicitHeight : 0 + anchors.bottom: attachmentPane.top + } + + AttachmentPane { + id: attachmentPane + attachmentPath: root.attachmentPath + visible: hasAttachment + width: parent.width + height: visible ? implicitHeight : 0 + anchors.bottom: chatBarSeparator.top + Behavior on height { + NumberAnimation { + property: "height" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + } + + Kirigami.Separator { + id: chatBarSeparator + visible: chatBar.visible + width: parent.width + height: visible ? implicitHeight : 0 + anchors.bottom: chatBar.top + } + + ChatBar { + id: chatBar + visible: currentRoom.canSendEvent("m.room.message") + width: parent.width + height: visible ? implicitHeight : 0 + anchors.bottom: parent.bottom + + Behavior on height { + NumberAnimation { + property: "height" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + } + + Connections { + target: replyPane + function onClearEditReplyTriggered() { + if (isEdit) { + clearEdit() + } + if (isReply) { + clearReply() + } + } + } + + Connections { + target: attachmentPane + function onClearAttachmentTriggered() { + clearAttachment() + } + } + + Connections { + target: chatBar + function onAttachTriggered(localPath) { + attach(localPath) + } + function onCloseAllTriggered() { + closeAll() + } + function onMessageSent() { + closeAll() + checkForFancyEffectsReason() + } + } + + function checkForFancyEffectsReason() { + if (!Config.showFancyEffects) { + return + } + + let text = root.inputFieldText.trim() + if (text.includes('\u{2744}')) { + root.fancyEffectsReasonFound("snowflake") + } + if (text.includes('\u{1F386}')) { + root.fancyEffectsReasonFound("fireworks") + } + if (text.includes('\u{1F387}')) { + root.fancyEffectsReasonFound("fireworks") + } + if (text.includes('\u{1F389}')) { + root.fancyEffectsReasonFound("confetti") + } + if (text.includes('\u{1F38A}')) { + root.fancyEffectsReasonFound("confetti") + } + } + + function addText(text) { + root.inputFieldText = inputFieldText + text + } + + function insertText(str) { + root.inputFieldText = inputFieldText.substr(0, inputField.cursorPosition) + str + inputFieldText.substr(inputField.cursorPosition) + } + + function clearText() { + // ChatBar's TextArea syncs currentRoom.cachedInput with the TextArea's text property + root.inputFieldText = "" + } + + function focusInputField() { + chatBar.inputFieldForceActiveFocusTriggered() + } + + function edit(editContent, editFormatedContent, editEventId) { + // Set the input field in edit mode + root.inputFieldText = editContent; + root.editEventId = editEventId; + root.replyContent = editContent; + + // clean autocompletion list + chatBar.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) { + chatBar.userAutocompleted[match[2]] = match[1]; + } + } + + function clearEdit() { + // Clear input when edits are cancelled. + // Cached input will be + clearText() + clearReply() + root.editEventId = ""; + } + + function attach(localPath) { + root.attachmentPath = localPath + } + + function clearAttachment() { + root.attachmentPath = "" + } + + function clearReply() { + replyUser = null; + root.replyContent = ""; + root.replyEventId = ""; + // Don't clear input when replies are cancelled + } + + function closeAll() { + if (hasAttachment) { + clearAttachment(); + } + if (isEdit) { + clearEdit(); + } + if (isReply) { + clearReply(); + } + chatBar.emojiPaneOpened = false; + } +} diff --git a/imports/NeoChat/Component/ChatBox/CompletionMenu.qml b/imports/NeoChat/Component/ChatBox/CompletionMenu.qml new file mode 100644 index 000000000..5d72de09b --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/CompletionMenu.qml @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2020 Carl Schwan +// SPDX-FileCopyrightText: 2020 Noah Davis +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import org.kde.kirigami 2.14 as Kirigami + +import org.kde.neochat 1.0 + +Popup { + id: control + + // Expose internal ListView properties. + property alias model: completionListView.model + property alias currentIndex: completionListView.currentIndex + property alias currentItem: completionListView.currentItem + property alias count: completionListView.count + property alias delegate: completionListView.delegate + + // Autocomplee text + property string currentDisplayText: currentItem && currentItem.displayName ? currentItem.displayName : "" + property string currentUserId: currentItem && currentItem.id ? currentItem.id : "" + + //FIXME: EmojiModel should probably be a singleton + property var emojiModel: EmojiModel {} + property bool isCompletingEmoji: false + property int beginPosition: 0 + property int endPosition: 0 + + signal completeTriggered() + + Kirigami.Theme.colorSet: Kirigami.Theme.View + + bottomPadding: 0 + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + clip: true + + onVisibleChanged: if (!visible) { + completionListView.currentIndex = 0; + } + + implicitHeight: Math.min(completionListView.contentHeight, Kirigami.Units.gridUnit * 5) + + contentItem: ScrollView { + ListView { + id: completionListView + implicitWidth: contentWidth + model: control.model + delegate: isCompletingEmoji ? emojiDelegate : usernameDelegate + + keyNavigationWraps: true + + //interactive: Window.window ? contentHeight + control.topPadding + control.bottomPadding > Window.window.height : false + clip: true + currentIndex: control.currentIndex || 0 + } + } + + background: Rectangle { + color: Kirigami.Theme.backgroundColor + } + + Component { + id: usernameDelegate + Kirigami.BasicListItem { + id: usernameItem + width: ListView.view.width ?? implicitWidth + property string displayName: modelData.displayName + leading: Kirigami.Avatar { + implicitHeight: Kirigami.Units.gridUnit + implicitWidth: implicitHeight + source: modelData.avatarMediaId ? ("image://mxc/" + modelData.avatarMediaId) : "" + color: modelData.color ? Qt.darker(modelData.color, 1.1) : null + } + text: modelData.displayName + onClicked: completeTriggered(); + Component.onCompleted: { + completionMenu.currentUserId = Qt.binding(() => { + return modelData.id ?? ""; + }); + } + } + } + + Component { + id: emojiDelegate + Kirigami.BasicListItem { + id: emojiItem + width: ListView.view.width ?? implicitWidth + property string displayName: modelData.unicode + text: modelData.unicode + " " + modelData.shortname + + leading: Label { + id: unicodeLabel + Layout.preferredHeight: Kirigami.Units.gridUnit + Layout.preferredWidth: textMetrics.tightBoundingRect.width + font.pointSize: Kirigami.Units.gridUnit * 0.75 + text: emojiItem.modelData.unicode + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + TextMetrics { + id: textMetrics + text: modelData.unicode + font: unicodeLabel.font + } + onClicked: completeTriggered(); + } + } +} diff --git a/imports/NeoChat/Component/ChatBox/ReplyPane.qml b/imports/NeoChat/Component/ChatBox/ReplyPane.qml new file mode 100644 index 000000000..21074dd60 --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/ReplyPane.qml @@ -0,0 +1,116 @@ +/* SPDX-FileCopyrightText: 2020 Carl Schwan + * SPDX-FileCopyrightText: 2020 Noah Davis + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import org.kde.kirigami 2.14 as Kirigami + +Loader { + id: root + property bool isEdit: false + property var user: null + property string content: "" + property string avatarMediaUrl: user ? "image://mxc/" + replyUser.avatarMediaId : "" + + signal clearEditReplyTriggered() + + active: visible + sourceComponent: Pane { + id: replyPane + + Kirigami.Theme.colorSet: Kirigami.Theme.View + + spacing: leftPadding + + contentItem: RowLayout { + Layout.fillWidth: true + spacing: replyPane.spacing + + FontMetrics { + id: fontMetrics + font: textArea.font + } + + Kirigami.Avatar { + id: avatar + Layout.alignment: textContentLayout.height > avatar.height ? Qt.AlignHCenter | Qt.AlignTop : Qt.AlignCenter + Layout.preferredWidth: Layout.preferredHeight + Layout.preferredHeight: fontMetrics.lineSpacing * 2 - fontMetrics.leading + source: root.avatarMediaUrl + name: user ? user.displayName : "" + color: user ? user.color : "transparent" + visible: Boolean(user) + } + + ColumnLayout { + id: textContentLayout + Layout.alignment: Qt.AlignCenter + Layout.fillWidth: true + spacing: fontMetrics.leading + Label { + textFormat: TextEdit.RichText + wrapMode: Label.Wrap + text: { + let stylesheet = "" + let heading = "%1" + let userName = user ? "" + user.displayName + "" : "" + if (isEdit) { + heading = heading.arg(i18n("Editing message:")) + "
" + } else { + heading = heading.arg(i18n("Replying to %1:")) + heading = heading.arg(userName) + "
" + } + + return stylesheet + heading + } + } + ScrollView { + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.fillWidth: true + Layout.maximumHeight: fontMetrics.lineSpacing * 8 - fontMetrics.leading + TextArea { + id: textArea + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + text: { + let stylesheet = "" + let userName = user ? "" + user.displayName + "" : "" + return stylesheet + content + } + selectByMouse: true + selectByKeyboard: true + readOnly: true + wrapMode: Label.Wrap + textFormat: TextEdit.RichText + background: null + HoverHandler { + cursorShape: textArea.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor + } + } + } + } + + Button { + id: cancelReplyButton + Layout.alignment: avatar.Layout.alignment + icon.name: "dialog-cancel" + text: i18n("Cancel") + display: AbstractButton.IconOnly + onClicked: { + clearEditReplyTriggered() + } + ToolTip.text: text + ToolTip.visible: hovered + } + } + + background: Rectangle { + color: Kirigami.Theme.backgroundColor + } + } +} diff --git a/imports/NeoChat/Component/ChatBox/qmldir b/imports/NeoChat/Component/ChatBox/qmldir new file mode 100644 index 000000000..28b7a8a94 --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/qmldir @@ -0,0 +1,7 @@ +module NeoChat.Component.ChatBox +ChatBox 1.0 ChatBox.qml +ChatBar 1.0 ChatBar.qml +ReplyPane 1.0 ReplyPane.qml +AttachmentPane 1.0 AttachmentPane.qml +CompletionMenu 1.0 CompletionMenu.qml +EmojiPickerPane 1.0 EmojiPickerPane.qml diff --git a/imports/NeoChat/Component/ChatTextInput.qml b/imports/NeoChat/Component/ChatTextInput.qml deleted file mode 100644 index bdfb0aa2a..000000000 --- a/imports/NeoChat/Component/ChatTextInput.qml +++ /dev/null @@ -1,671 +0,0 @@ -/** - * 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); - } -} diff --git a/imports/NeoChat/Component/Timeline/TimelineContainer.qml b/imports/NeoChat/Component/Timeline/TimelineContainer.qml index cfa56871e..572cd85b2 100644 --- a/imports/NeoChat/Component/Timeline/TimelineContainer.qml +++ b/imports/NeoChat/Component/Timeline/TimelineContainer.qml @@ -35,6 +35,7 @@ Item { signal openExternally() signal replyClicked(string eventID) signal replyToMessageClicked(var replyUser, string replyContent, string eventID) + signal edit(string message, string formattedBody, string eventId) property alias hovered: controlContainer.hovered @@ -53,7 +54,7 @@ Item { hoverComponent.editClicked = () => { if (hoverComponent.showEdit) { - chatTextInput.edit(message, model.formattedBody, eventId); + edit(message, model.formattedBody, eventId); } }; hoverComponent.replyClicked = () => { diff --git a/imports/NeoChat/Page/RoomPage.qml b/imports/NeoChat/Page/RoomPage.qml index a989e59e2..042402bed 100644 --- a/imports/NeoChat/Page/RoomPage.qml +++ b/imports/NeoChat/Page/RoomPage.qml @@ -16,6 +16,7 @@ import org.kde.kitemmodels 1.0 import org.kde.neochat 1.0 import NeoChat.Component 1.0 +import NeoChat.Component.ChatBox 1.0 import NeoChat.Component.Timeline 1.0 import NeoChat.Dialog 1.0 import NeoChat.Menu.Timeline 1.0 @@ -121,8 +122,8 @@ Kirigami.ScrollablePage { switchRoomDown(); } else if (!(event.modifiers & Qt.ControlModifier) && event.key < Qt.Key_Escape) { event.accepted = true; - chatTextInput.addText(event.text); - chatTextInput.focus(); + chatBox.addText(event.text); + chatBox.focus(); return; } } @@ -181,7 +182,7 @@ Kirigami.ScrollablePage { ListView { id: messageListView - + pixelAligned: true visible: !invitation.visible readonly property int largestVisibleIndex: count > 0 ? indexAt(contentX + (width / 2), contentY + height - 1) : -1 @@ -203,7 +204,7 @@ Kirigami.ScrollablePage { function updateReadMarker() { if(!noNeedMoreContent && contentY - 5000 < originY) currentRoom.getPreviousContent(20); - const index = eventToIndex(currentRoom.readMarkerEventId) + const index = currentRoom.readMarkerEventId ? eventToIndex(currentRoom.readMarkerEventId) : 0 if(index === -1) { return } @@ -245,7 +246,7 @@ Kirigami.ScrollablePage { fileDialog.chosen.connect(function(path) { if (!path) return - chatTextInput.attach(path) + chatBox.attach(path) }) fileDialog.open() @@ -265,7 +266,7 @@ Kirigami.ScrollablePage { onClicked: { var localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png" if (!Clipboard.saveImage(localPath)) return - chatTextInput.attach(localPath) + chatBox.attach(localPath) attachDialog.close() } } @@ -352,6 +353,7 @@ Kirigami.ScrollablePage { } onReplyClicked: goToEvent(eventID) onReplyToMessageClicked: replyToMessage(replyUser, replyContent, eventId); + onEdit: chatBox.edit(message, formattedBody, eventId) hoverComponent: hoverActions @@ -373,6 +375,7 @@ Kirigami.ScrollablePage { isLoaded: timelineDelegateChooser.delegateLoaded onReplyClicked: goToEvent(eventID) onReplyToMessageClicked: replyToMessage(replyUser, replyContent, eventId); + onEdit: chatBox.edit(message, formattedBody, eventId) hoverComponent: hoverActions @@ -399,6 +402,7 @@ Kirigami.ScrollablePage { isLoaded: timelineDelegateChooser.delegateLoaded onReplyClicked: goToEvent(eventID) onReplyToMessageClicked: replyToMessage(replyUser, replyContent, eventId); + onEdit: chatBox.edit(message, formattedBody, eventId) hoverComponent: hoverActions innerObject: TextDelegate { @@ -597,7 +601,7 @@ Kirigami.ScrollablePage { DropArea { id: dropAreaFile anchors.fill: parent - onDropped: chatTextInput.attach(drop.urls[0]) + onDropped: chatBox.attach(drop.urls[0]) } QQC2.Pane { @@ -639,11 +643,9 @@ Kirigami.ScrollablePage { } } - footer: ChatTextInput { - id: chatTextInput - + footer: ChatBox { + id: chatBox visible: !invitation.visible && !(messageListView.count === 0 && !currentRoom.allHistoryLoaded) - Layout.fillWidth: true } background: FancyEffectsContainer { @@ -673,7 +675,7 @@ Kirigami.ScrollablePage { Connections { enabled: Config.showFancyEffects - target: chatTextInput + target: chatBox onFancyEffectsReasonFound: { fancyEffectsContainer.processFancyEffectsReason(fancyEffect) } @@ -783,10 +785,10 @@ Kirigami.ScrollablePage { } function replyToMessage(replyUser, replyContent, eventId) { - chatTextInput.replyUser = replyUser; - chatTextInput.replyEventID = eventId; - chatTextInput.replyContent = replyContent; - chatTextInput.isReply = true; - chatTextInput.focus(); + chatBox.editEventId = ""; + chatBox.replyUser = replyUser; + chatBox.replyEventId = eventId; + chatBox.replyContent = replyContent; + chatBox.focusInputField(); } } diff --git a/res.qrc b/res.qrc index ce7809b13..9562578a4 100644 --- a/res.qrc +++ b/res.qrc @@ -15,10 +15,16 @@ imports/NeoChat/Page/DevicesPage.qml imports/NeoChat/Page/WelcomePage.qml imports/NeoChat/Component/qmldir - imports/NeoChat/Component/ChatTextInput.qml imports/NeoChat/Component/AutoMouseArea.qml imports/NeoChat/Component/FullScreenImage.qml imports/NeoChat/Component/FancyEffectsContainer.qml + imports/NeoChat/Component/ChatBox + imports/NeoChat/Component/ChatBox/ChatBox.qml + imports/NeoChat/Component/ChatBox/ChatBar.qml + imports/NeoChat/Component/ChatBox/AttachmentPane.qml + imports/NeoChat/Component/ChatBox/ReplyPane.qml + imports/NeoChat/Component/ChatBox/CompletionMenu.qml + imports/NeoChat/Component/ChatBox/qmldir imports/NeoChat/Component/Emoji/EmojiPicker.qml imports/NeoChat/Component/Emoji/qmldir imports/NeoChat/Component/Timeline/qmldir