diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c5d56ee6b..35dd407f6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -173,7 +173,6 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/TypingPane.qml qml/QuickSwitcher.qml qml/HoverActions.qml - qml/ChatBox.qml qml/ChatBar.qml qml/AttachmentPane.qml qml/ReplyPane.qml @@ -290,6 +289,7 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/Security.qml qml/QrCodeMaximizeComponent.qml qml/SelectSpacesDialog.qml + qml/AttachDialog.qml RESOURCES qml/confetti.png qml/glowdot.png diff --git a/src/models/customemojimodel.cpp b/src/models/customemojimodel.cpp index d5fef291b..88956bfe5 100644 --- a/src/models/customemojimodel.cpp +++ b/src/models/customemojimodel.cpp @@ -142,6 +142,7 @@ CustomEmojiModel::CustomEmojiModel(QObject *parent) fetchEmojis(); }); }); + CustomEmojiModel::fetchEmojis(); } QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const diff --git a/src/qml/AttachDialog.qml b/src/qml/AttachDialog.qml new file mode 100644 index 000000000..9011f3381 --- /dev/null +++ b/src/qml/AttachDialog.qml @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2020 Carl Schwan +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtCore +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +import org.kde.neochat + +QQC2.Popup { + id: root + + padding: 16 + + signal chosen(string path) + + contentItem: RowLayout { + QQC2.ToolButton { + Layout.preferredWidth: 160 + Layout.fillHeight: true + + icon.name: 'mail-attachment' + + text: i18n("Choose local file") + + onClicked: { + root.close() + + var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay) + fileDialog.chosen.connect(path => root.chosen(path)) + fileDialog.open() + } + } + + Kirigami.Separator {} + + QQC2.ToolButton { + Layout.preferredWidth: 160 + Layout.fillHeight: true + + padding: 16 + + icon.name: 'insert-image' + text: i18n("Clipboard image") + onClicked: { + const path = StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] + "/screenshots/" + (new Date()).getTime() + ".png" + if (!Clipboard.saveImage(path)) { + return; + } + root.chosen(path) + root.close(); + } + } + } + Component { + id: openFileDialog + + OpenFileDialog { + parentWindow: Window.window + currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] + } + } +} diff --git a/src/qml/ChatBar.qml b/src/qml/ChatBar.qml index 4c49936df..63a295231 100644 --- a/src/qml/ChatBar.qml +++ b/src/qml/ChatBar.qml @@ -2,35 +2,25 @@ // SPDX-FileCopyrightText: 2020 Noah Davis // SPDX-License-Identifier: GPL-2.0-or-later +import QtCore import QtQuick -import QtQuick.Layouts import QtQuick.Controls as QQC2 -import QtQuick.Window -import Qt.labs.platform as Platform +import QtQuick.Layouts import org.kde.kirigami as Kirigami import org.kde.neochat import org.kde.neochat.config /** - * @brief The component which handles the message sending. + * @brief A component for typing and sending chat messages. * - * The ChatBox deals with laying out the visual elements with the ChatBar handling - * the core functionality of displaying the current message composition before sending. + * This is designed to go to the bottom of the timeline and provides all the functionality + * required for the user to send messages to the room. * - * This includes support for the following message types: - * - text - * - media (video, image, file) - * - emojis/stickers - * - location - * - * In addition, when replying, this component supports showing the message that is being + * In addition when replying this component supports showing the message that is being * replied to. * - * @note There is no edit functionality here this, is handled inline by the timeline - * text delegate. - * - * @sa ChatBox + * @sa ChatBar */ QQC2.Control { id: root @@ -39,17 +29,13 @@ QQC2.Control { * @brief The current room that user is viewing. */ required property NeoChatRoom currentRoom + + required property NeoChatConnection connection + + onActiveFocusChanged: textField.forceActiveFocus() + onCurrentRoomChanged: _private.chatBarCache = currentRoom.mainCache - /** - * @brief The QQC2.TextArea object. - * - * @sa QQC2.TextArea - */ - property alias textField: textField - - property NeoChatConnection connection - /** * @brief The ActionsHandler object to use. * @@ -64,31 +50,22 @@ QQC2.Control { * Each of these will be visualised in the ChatBar so new actions can be added * by appending to this list. */ - property list actions : [ + property list actions: [ Kirigami.Action { id: attachmentAction property bool isBusy: root.currentRoom && root.currentRoom.hasFileUploading // Matrix does not allow sending attachments in replies - visible: _private.chatBarCache.isReplying && _private.chatBarCache.attachmentPath.length === 0 + visible: _private.chatBarCache.replyId.length === 0 && _private.chatBarCache.attachmentPath.length === 0 icon.name: "mail-attachment" text: i18n("Attach an image or file") displayHint: Kirigami.DisplayHint.IconOnly onTriggered: { - if (Clipboard.hasImage) { - attachDialog.open() - } else { - var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay) - fileDialog.chosen.connect((path) => { - if (!path) { - return; - } - _private.chatBarCache.attachmentPath = path; - }) - fileDialog.open() - } + let dialog = (Clipboard.hasImage ? attachDialog : openFileDialog).createObject(applicationWindow().overlay) + dialog.chosen.connect(path => _private.chatBarCache.attachmentPath = path) + dialog.open() } tooltip: text @@ -105,10 +82,10 @@ QQC2.Control { checkable: true onTriggered: { - if (emojiDialog.item.visible) { - emojiDialog.item.close() + if (emojiDialog.visible) { + emojiDialog.close() } else { - emojiDialog.item.open() + emojiDialog.open() } } tooltip: text @@ -121,7 +98,7 @@ QQC2.Control { displayHint: QQC2.AbstractButton.IconOnly onTriggered: { - locationChooserComponent.createObject(QQC2.ApplicationWindow.overlay, {room: root.currentRoom}).open() + locationChooser.createObject(QQC2.ApplicationWindow.overlay, {room: root.currentRoom}).open() } tooltip: text }, @@ -136,7 +113,7 @@ QQC2.Control { checkable: true onTriggered: { - root.postMessage() + _private.postMessage() } tooltip: text @@ -148,294 +125,308 @@ QQC2.Control { */ signal messageSent() - leftPadding: 0 - rightPadding: 0 + spacing: 0 + + Kirigami.Theme.colorSet: Kirigami.Theme.View + Kirigami.Theme.inherit: false + + background: Rectangle { + color: Kirigami.Theme.backgroundColor + Kirigami.Separator { + anchors.left: parent.left + anchors.right:parent.right + anchors.top: parent.top + } + } + + leftPadding: rightPadding + rightPadding: (root.width - chatBarSizeHelper.currentWidth) / 2 topPadding: 0 bottomPadding: 0 - contentItem: QQC2.ScrollView { - id: chatBarScrollView - - property var textFieldHeight: textField.height - - // HACK: This is to stop the ScrollBar flickering on and off as the height is increased - QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded - - Behavior on implicitHeight { - NumberAnimation { - id: chatBarHeightAnimation - duration: Kirigami.Units.shortDuration - easing.type: Easing.InOutCubic - } + contentItem: ColumnLayout { + spacing: 0 + Item { // Required to adjust for the top separator + Layout.preferredHeight: 1 + Layout.fillWidth: true } + Loader { + id: paneLoader - QQC2.TextArea { - id: textField + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing - x: Math.round((root.width - chatBarSizeHelper.currentWidth) / 2) - (root.width > chatBarSizeHelper.currentWidth + Kirigami.Units.largeSpacing * 2.5 ? Kirigami.Units.largeSpacing * 1.5 : 0) - topPadding: Kirigami.Units.largeSpacing + (paneLoader.visible ? paneLoader.height : 0) - bottomPadding: Kirigami.Units.largeSpacing - leftPadding: LayoutMirroring.enabled ? actionsRow.width : Kirigami.Units.largeSpacing - rightPadding: LayoutMirroring.enabled ? Kirigami.Units.largeSpacing : actionsRow.width + x * 2 + Kirigami.Units.largeSpacing * 2 - - placeholderText: root.currentRoom.usesEncryption ? i18n("Send an encrypted message…") : _private.chatBarCache.attachmentPath.length > 0 ? i18n("Set an attachment caption...") : i18n("Send a message…") - verticalAlignment: TextEdit.AlignVCenter - wrapMode: Text.Wrap - - Accessible.description: placeholderText - - // opt-out of whatever spell checker a styled TextArea might come with - Kirigami.SpellCheck.enabled: false - - Timer { - id: repeatTimer - interval: 5000 - } - - onTextChanged: { - if (!repeatTimer.running && Config.typingNotifications) { - var textExists = text.length > 0 - root.currentRoom.sendTypingNotification(textExists) - textExists ? repeatTimer.start() : repeatTimer.stop() - } - _private.chatBarCache.text = text - } - onCursorRectangleChanged: chatBarScrollView.ensureVisible(cursorRectangle) - onSelectedTextChanged: { - if (selectedText.length > 0) { - quickFormatBar.selectionStart = selectionStart - quickFormatBar.selectionEnd = selectionEnd - quickFormatBar.open() - } - } - - QuickFormatBar { - id: quickFormatBar - - x: textField.cursorRectangle.x - y: textField.cursorRectangle.y - height - - onFormattingSelected: root.formatText(format, selectionStart, selectionEnd) - } - - Keys.onDeletePressed: { - if (selectedText.length > 0) { - remove(selectionStart, selectionEnd) - } else { - remove(cursorPosition, cursorPosition + 1) - } - if (textField.text == selectedText || textField.text.length <= 1) { - root.currentRoom.sendTypingNotification(false) - repeatTimer.stop() - } - if (quickFormatBar.visible) { - quickFormatBar.close() - } - } - Keys.onEnterPressed: event => { - if (completionMenu.visible) { - completionMenu.complete() - } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) { - textField.insert(cursorPosition, "\n") - } else { - root.postMessage(); - } - } - Keys.onReturnPressed: event => { - if (completionMenu.visible) { - completionMenu.complete() - } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) { - textField.insert(cursorPosition, "\n") - } else { - root.postMessage(); - } - } - Keys.onTabPressed: { - if (completionMenu.visible) { - completionMenu.complete() - } - } - Keys.onPressed: (event) => { - if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { - event.accepted = root.pasteImage(); - } else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) { - root.currentRoom.replyLastMessage(); - } else if (event.key === Qt.Key_Up && textField.text.length === 0) { - root.currentRoom.editLastMessage(); - } else if (event.key === Qt.Key_Up && completionMenu.visible) { - completionMenu.decrementIndex() - } else if (event.key === Qt.Key_Down && completionMenu.visible) { - completionMenu.incrementIndex() - } else if (event.key === Qt.Key_Backspace) { - if (textField.text == selectedText || textField.text.length <= 1) { - root.currentRoom.sendTypingNotification(false) - repeatTimer.stop() - } - if (quickFormatBar.visible && selectedText.length > 0) { - quickFormatBar.close() - } - } - } - Keys.onShortcutOverride: event => { - // Accept the event only when there was something to cancel. Otherwise, let the event go to the RoomPage. - if (cancelButton.visible && event.key === Qt.Key_Escape) { - cancelButton.action.trigger(); - event.accepted = true; - } - } - - Loader { - id: paneLoader - - anchors.top: parent.top - anchors.left: parent.left - anchors.leftMargin: Kirigami.Units.largeSpacing - anchors.right: parent.right - anchors.rightMargin: root.width > chatBarSizeHelper.currentWidth ? 0 : (chatBarScrollView.QQC2.ScrollBar.vertical.visible ? Kirigami.Units.largeSpacing * 3.5 : Kirigami.Units.largeSpacing) - - active: visible - visible: _private.chatBarCache.isReplying || _private.chatBarCache.attachmentPath.length > 0 - sourceComponent: _private.chatBarCache.isReplying ? replyPane : attachmentPane - } - Component { - id: replyPane - ReplyPane { - userName: _private.chatBarCache.relationUser.displayName - userColor: _private.chatBarCache.relationUser.color - userAvatar: _private.chatBarCache.relationUser.avatarSource - text: _private.chatBarCache.relationMessage - } - } - Component { - id: attachmentPane - AttachmentPane { - attachmentPath: _private.chatBarCache.attachmentPath - - onAttachmentCancelled: { - _private.chatBarCache.attachmentPath = ""; - root.forceActiveFocus() - } - } - } - - background: MouseArea { - acceptedButtons: Qt.NoButton - cursorShape: Qt.IBeamCursor - z: 1 - } + active: visible + visible: root.currentRoom.mainCache.replyId.length > 0 || root.currentRoom.mainCache.attachmentPath.length > 0 + sourceComponent: root.currentRoom.mainCache.replyId.length > 0 ? replyPane : attachmentPane } + RowLayout { + QQC2.ScrollView { + id: chatBarScrollView - /** - * Because of the paneLoader we have to manage the scroll - * position manually or it doesn't keep the cursor visible properly all the time. - */ - function ensureVisible(r) { - // Find the child that is the Flickable created by ScrollView. - let flickable = undefined; - for (var index in children) { - if (children[index] instanceof Flickable) { - flickable = children[index]; + Layout.fillWidth: true + Layout.maximumHeight: Kirigami.Units.gridUnit * 8 + + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.bottomMargin: Kirigami.Units.smallSpacing + Layout.minimumHeight: Kirigami.Units.gridUnit * 2 + + // HACK: This is to stop the ScrollBar flickering on and off as the height is increased + QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded + + Behavior on implicitHeight { + NumberAnimation { + id: chatBarHeightAnimation + duration: Kirigami.Units.shortDuration + easing.type: Easing.InOutCubic + } + } + + QQC2.TextArea { + id: textField + + placeholderText: root.currentRoom.usesEncryption ? i18n("Send an encrypted message…") : root.currentRoom.mainCache.attachmentPath.length > 0 ? i18n("Set an attachment caption…") : i18n("Send a message…") + verticalAlignment: TextEdit.AlignVCenter + wrapMode: Text.Wrap + + Accessible.description: placeholderText + + Kirigami.SpellCheck.enabled: false + + Timer { + id: repeatTimer + interval: 5000 + } + + onTextChanged: { + if (!repeatTimer.running && Config.typingNotifications) { + var textExists = text.length > 0 + root.currentRoom.sendTypingNotification(textExists) + textExists ? repeatTimer.start() : repeatTimer.stop() + } + _private.chatBarCache.text = text + } + onSelectedTextChanged: { + if (selectedText.length > 0) { + quickFormatBar.selectionStart = selectionStart + quickFormatBar.selectionEnd = selectionEnd + quickFormatBar.open() + } + } + + QuickFormatBar { + id: quickFormatBar + + x: textField.cursorRectangle.x + y: textField.cursorRectangle.y - height + + onFormattingSelected: root.formatText(format, selectionStart, selectionEnd) + } + + Keys.onDeletePressed: { + if (selectedText.length > 0) { + remove(selectionStart, selectionEnd) + } else { + remove(cursorPosition, cursorPosition + 1) + } + if (textField.text == selectedText || textField.text.length <= 1) { + root.currentRoom.sendTypingNotification(false) + repeatTimer.stop() + } + if (quickFormatBar.visible) { + quickFormatBar.close() + } + } + Keys.onEnterPressed: event => { + if (completionMenu.visible) { + completionMenu.complete() + } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) { + textField.insert(cursorPosition, "\n") + } else { + _private.postMessage(); + } + } + Keys.onReturnPressed: event => { + if (completionMenu.visible) { + completionMenu.complete() + } else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) { + textField.insert(cursorPosition, "\n") + } else { + _private.postMessage(); + } + } + Keys.onTabPressed: { + if (completionMenu.visible) { + completionMenu.complete() + } + } + Keys.onPressed: (event) => { + if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { + event.accepted = _private.pasteImage(); + } else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) { + root.currentRoom.replyLastMessage(); + } else if (event.key === Qt.Key_Up && textField.text.length === 0) { + root.currentRoom.editLastMessage(); + } else if (event.key === Qt.Key_Up && completionMenu.visible) { + completionMenu.decrementIndex() + } else if (event.key === Qt.Key_Down && completionMenu.visible) { + completionMenu.incrementIndex() + } else if (event.key === Qt.Key_Backspace) { + if (textField.text == selectedText || textField.text.length <= 1) { + root.currentRoom.sendTypingNotification(false) + repeatTimer.stop() + } + if (quickFormatBar.visible && selectedText.length > 0) { + quickFormatBar.close() + } + } + } + Keys.onShortcutOverride: event => { + if (completionMenu.visible) { + completionMenu.close() + } else if ((_private.chatBarCache.isReplying || _private.chatBarCache.attachmentPath.length > 0) && event.key === Qt.Key_Escape) { + _private.chatBarCache.attachmentPath = "" + _private.chatBarCache.replyId = "" + _private.chatBarCache.threadId = "" + event.accepted = true; + } + } + + background: MouseArea { + acceptedButtons: Qt.NoButton + cursorShape: Qt.IBeamCursor + z: 1 + } } } + RowLayout { + id: actionsRow + spacing: 0 + Layout.alignment: Qt.AlignBottom + Layout.bottomMargin: Kirigami.Units.smallSpacing * 1.5 - if (flickable) { - if (flickable.contentX >= r.x) { - flickable.contentX = r.x; - } else if (flickable.contentX + width <= r.x + r.width) { - flickable.contentX = r.x + r.width - width; - } if (flickable.contentY >= r.y) { - flickable.contentY = r.y; - } else if (flickable.contentY + height <= r.y + r.height) { - flickable.contentY = r.y + r.height - height + textField.bottomPadding; + Repeater { + model: root.actions + delegate: QQC2.ToolButton { + Layout.alignment: Qt.AlignVCenter + icon.name: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source) + onClicked: modelData.trigger() + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: modelData.tooltip + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + + PieProgressBar { + visible: modelData.isBusy + progress: root.currentRoom.fileUploadingProgress + } + } } } } } - QQC2.ToolButton { - id: cancelButton - anchors.top: parent.top - anchors.right: parent.right - anchors.rightMargin: (root.width - chatBarSizeHelper.currentWidth) / 2 + Kirigami.Units.largeSpacing + (chatBarScrollView.QQC2.ScrollBar.vertical.visible && !(root.width > chatBarSizeHelper.currentWidth) ? Kirigami.Units.largeSpacing * 2.5 : 0) + DelegateSizeHelper { + id: chatBarSizeHelper + startBreakpoint: Kirigami.Units.gridUnit * 46 + endBreakpoint: Kirigami.Units.gridUnit * 66 + startPercentWidth: 100 + endPercentWidth: Config.compactLayout ? 100 : 85 + maxWidth: Config.compactLayout ? -1 : Kirigami.Units.gridUnit * 60 - visible: _private.chatBarCache.isReplying - display: QQC2.AbstractButton.IconOnly - action: Kirigami.Action { - text: i18nc("@action:button", "Cancel reply") - icon.name: "dialog-close" - onTriggered: { + parentWidth: root.width + } + + Component { + id: replyPane + ReplyPane { + userName: _private.chatBarCache.relationUser.displayName + userColor: _private.chatBarCache.relationUser.color + userAvatar: _private.chatBarCache.relationUser.avatarSource + text: _private.chatBarCache.relationMessage + + onCancel: { _private.chatBarCache.replyId = ""; _private.chatBarCache.attachmentPath = ""; + } + } + } + Component { + id: attachmentPane + AttachmentPane { + attachmentPath: _private.chatBarCache.attachmentPath + + onAttachmentCancelled: { + _private.chatBarCache.attachmentPath = ""; root.forceActiveFocus() } } - QQC2.ToolTip.text: text - QQC2.ToolTip.visible: hovered } - RowLayout { - id: actionsRow - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.leftMargin: layoutDirection === Qt.RightToLeft ? requiredMargin : 0 - anchors.rightMargin: layoutDirection === Qt.RightToLeft ? 0 : requiredMargin - anchors.bottomMargin: Kirigami.Units.smallSpacing - spacing: 0 - property var requiredMargin: (root.width - chatBarSizeHelper.currentWidth) / 2 + Kirigami.Units.largeSpacing + (chatBarScrollView.QQC2.ScrollBar.vertical.visible && !(root.width > chatBarSizeHelper.currentWidth) ? Kirigami.Units.largeSpacing * 2.5 : 0) - Repeater { - model: root.actions - delegate: QQC2.ToolButton { - Layout.alignment: Qt.AlignVCenter - icon.name: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source) - onClicked: modelData.trigger() + QtObject { + id: _private + property ChatBarCache chatBarCache + onChatBarCacheChanged: documentHandler.chatBarCache = chatBarCache - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.text: modelData.tooltip - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + function postMessage() { + root.actionsHandler.handleMessageEvent(_private.chatBarCache); + repeatTimer.stop() + root.currentRoom.markAllMessagesAsRead(); + textField.clear(); + _private.chatBarCache.replyId = ""; + messageSent() + } - PieProgressBar { - visible: modelData.isBusy - progress: root.currentRoom.fileUploadingProgress - } + function formatText(format, selectionStart, selectionEnd) { + let index = textField.cursorPosition; + + /* + * There cannot be white space at the beginning or end of the string for the + * formatting to work so move the sectionStart and sectionEnd markers past any whitespace. + */ + let innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart); + if (innerText.charAt(innerText.length - 1) === " ") { + let trimmedRightString = innerText.replace(/\s*$/,""); + let trimDifference = innerText.length - trimmedRightString.length; + selectionEnd -= trimDifference; + } + if (innerText.charAt(0) === " ") { + let trimmedLeftString = innerText.replace(/^\s*/,""); + let trimDifference = innerText.length - trimmedLeftString.length; + selectionStart = selectionStart + trimDifference; + } + + let startText = textField.text.substr(0, selectionStart); + // Needs updating with the new selectionStart and selectionEnd with white space trimmed. + innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart); + let endText = textField.text.substr(selectionEnd); + + textField.text = ""; + textField.text = startText + format.start + innerText + format.end + format.extra + endText; + + /* + * Put the cursor where it was when the popup was opened accounting for the + * new markup. + * + * The exception is for a hyperlink where it is placed ready to start typing + * the url. + */ + if (format.extra !== "") { + textField.cursorPosition = selectionEnd + format.start.length + format.end.length; + } else if (index == selectionStart) { + textField.cursorPosition = index; + } else { + textField.cursorPosition = index + format.start.length + format.end.length; } } - } - Loader { - id: emojiDialog - active: !Kirigami.Settings.isMobile - sourceComponent: EmojiDialog { - x: root.width - width - y: -implicitHeight // - Kirigami.Units.smallSpacing - - modal: false - includeCustom: true - closeOnChosen: false - - currentRoom: root.currentRoom - - onChosen: emoji => insertText(emoji) - onClosed: if (emojiAction.checked) emojiAction.checked = false - } - } - - background: Rectangle { - color: Kirigami.Theme.backgroundColor - } - - CompletionMenu { - id: completionMenu - height: implicitHeight - y: -height - 5 - z: 1 - chatDocumentHandler: documentHandler - connection: root.connection - Behavior on height { - NumberAnimation { - property: "height" - duration: Kirigami.Units.shortDuration - easing.type: Easing.OutCubic + function pasteImage() { + let localPath = Clipboard.saveImage(); + if (localPath.length === 0) { + return false; } + _private.chatBarCache.attachmentPath = localPath; + return true; } } @@ -452,21 +443,59 @@ QQC2.Control { } } - DelegateSizeHelper { - id: chatBarSizeHelper - startBreakpoint: Kirigami.Units.gridUnit * 46 - endBreakpoint: Kirigami.Units.gridUnit * 66 - startPercentWidth: 100 - endPercentWidth: Config.compactLayout ? 100 : 85 - maxWidth: Config.compactLayout ? -1 : Kirigami.Units.gridUnit * 60 + Component { + id: openFileDialog - parentWidth: root.width + OpenFileDialog { + parentWindow: Window.window + currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] + } } - function forceActiveFocus() { - textField.forceActiveFocus(); - // set the cursor to the end of the text - textField.cursorPosition = textField.length; + Component { + id: attachDialog + + AttachDialog { + anchors.centerIn: parent + } + } + + Component { + id: locationChooser + LocationChooser {} + } + + CompletionMenu { + id: completionMenu + chatDocumentHandler: documentHandler + connection: root.connection + + x: 1 + y: -height + width: parent.width - 1 + Behavior on height { + NumberAnimation { + property: "height" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } + } + } + + EmojiDialog { + id: emojiDialog + + x: root.width - width + y: -implicitHeight + + modal: false + includeCustom: true + closeOnChosen: false + + currentRoom: root.currentRoom + + onChosen: emoji => insertText(emoji) + onClosed: if (emojiAction.checked) emojiAction.checked = false } function insertText(text) { @@ -475,139 +504,4 @@ QQC2.Control { textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition) textField.cursorPosition = initialCursorPosition + text.length } - - function pasteImage() { - let localPath = Clipboard.saveImage(); - if (localPath.length === 0) { - return false; - } - _private.chatBarCache.attachmentPath = localPath; - return true; - } - - function postMessage() { - root.actionsHandler.handleMessageEvent(_private.chatBarCache); - repeatTimer.stop() - root.currentRoom.markAllMessagesAsRead(); - textField.clear(); - _private.chatBarCache.replyId = ""; - messageSent() - } - - function formatText(format, selectionStart, selectionEnd) { - let index = textField.cursorPosition; - - /* - * There cannot be white space at the beginning or end of the string for the - * formatting to work so move the sectionStart and sectionEnd markers past any whitespace. - */ - let innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart); - if (innerText.charAt(innerText.length - 1) === " ") { - let trimmedRightString = innerText.replace(/\s*$/,""); - let trimDifference = innerText.length - trimmedRightString.length; - selectionEnd -= trimDifference; - } - if (innerText.charAt(0) === " ") { - let trimmedLeftString = innerText.replace(/^\s*/,""); - let trimDifference = innerText.length - trimmedLeftString.length; - selectionStart = selectionStart + trimDifference; - } - - let startText = textField.text.substr(0, selectionStart); - // Needs updating with the new selectionStart and selectionEnd with white space trimmed. - innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart); - let endText = textField.text.substr(selectionEnd); - - textField.text = ""; - textField.text = startText + format.start + innerText + format.end + format.extra + endText; - - /* - * Put the cursor where it was when the popup was opened accounting for the - * new markup. - * - * The exception is for a hyperlink where it is placed ready to start typing - * the url. - */ - if (format.extra !== "") { - textField.cursorPosition = selectionEnd + format.start.length + format.end.length; - } else if (index == selectionStart) { - textField.cursorPosition = index; - } else { - textField.cursorPosition = index + format.start.length + format.end.length; - } - } - - Component { - id: locationChooserComponent - LocationChooser {} - } - - QQC2.Popup { - anchors.centerIn: parent - - id: attachDialog - - padding: 16 - - contentItem: RowLayout { - QQC2.ToolButton { - Layout.preferredWidth: 160 - Layout.fillHeight: true - - icon.name: 'mail-attachment' - - text: i18n("Choose local file") - - onClicked: { - attachDialog.close() - - var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay) - - fileDialog.chosen.connect(function (path) { - if (!path) { - return; - } - _private.chatBarCache.attachmentPath = path; - }) - - fileDialog.open() - } - } - - Kirigami.Separator { - } - - QQC2.ToolButton { - Layout.preferredWidth: 160 - Layout.fillHeight: true - - padding: 16 - - icon.name: 'insert-image' - text: i18n("Clipboard image") - onClicked: { - const localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png" - if (!Clipboard.saveImage(localPath)) { - return; - } - _private.chatBarCache.attachmentPath = localPath; - attachDialog.close(); - } - } - } - } - - Component { - id: openFileDialog - - OpenFileDialog { - parentWindow: Window.window - } - } - - QtObject { - id: _private - property ChatBarCache chatBarCache - onChatBarCacheChanged: documentHandler.chatBarCache = chatBarCache - } } diff --git a/src/qml/ChatBox.qml b/src/qml/ChatBox.qml deleted file mode 100644 index 064010c9d..000000000 --- a/src/qml/ChatBox.qml +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Carl Schwan -// SPDX-FileCopyrightText: 2020 Noah Davis -// SPDX-License-Identifier: GPL-2.0-or-later - -import QtQuick -import QtQuick.Layouts - -import org.kde.kirigami as Kirigami -import org.kde.neochat - -/** - * @brief A component for typing and sending chat messages. - * - * This is designed to go to the bottom of the timeline and provides all the functionality - * required for the user to send messages to the room. - * - * This includes support for the following message types: - * - text - * - media (video, image, file) - * - emojis/stickers - * - location - * - * In addition when replying this component supports showing the message that is being - * replied to. - * - * @note The main role of this component is to layout the elements. The main functionality - * is handled by ChatBar - * - * @sa ChatBar - */ -ColumnLayout { - id: root - - /** - * @brief The current room that user is viewing. - */ - required property NeoChatRoom currentRoom - - required property NeoChatConnection connection - - /** - * @brief The ActionsHandler object to use. - * - * This is expected to have the correct room set otherwise messages will be sent - * to the wrong room. - */ - required property ActionsHandler actionsHandler - - /** - * @brief A message has been sent from the chat bar. - */ - signal messageSent() - - /** - * @brief Insert the given text into the ChatBar. - * - * The text is inserted at the current cursor location. - */ - function insertText(text) { - chatBar.insertText(text) - } - - spacing: 0 - - Kirigami.Theme.colorSet: Kirigami.Theme.View - Kirigami.Theme.inherit: false - - Kirigami.Separator { - Layout.fillWidth: true - } - - ChatBar { - id: chatBar - - connection: root.connection - - visible: root.currentRoom.canSendEvent("m.room.message") - - Layout.fillWidth: true - Layout.minimumHeight: Math.max(Kirigami.Units.gridUnit * 2, Math.round(implicitHeight) + Kirigami.Units.largeSpacing) - // lineSpacing is height+leading, so subtract leading once since leading only exists between lines. - Layout.maximumHeight: chatBarFontMetrics.lineSpacing * 8 - chatBarFontMetrics.leading + textField.topPadding + textField.bottomPadding - Layout.preferredHeight: Math.round(implicitHeight) - - currentRoom: root.currentRoom - actionsHandler: root.actionsHandler - - FontMetrics { - id: chatBarFontMetrics - font: chatBar.textField.font - } - - onMessageSent: { - root.messageSent(); - } - } - - onActiveFocusChanged: chatBar.forceActiveFocus() -} diff --git a/src/qml/CompletionMenu.qml b/src/qml/CompletionMenu.qml index ec90d22d5..39b15b294 100644 --- a/src/qml/CompletionMenu.qml +++ b/src/qml/CompletionMenu.qml @@ -15,18 +15,19 @@ import org.kde.neochat QQC2.Popup { id: root - width: parent.width required property NeoChatConnection connection + required property var chatDocumentHandler visible: completions.count > 0 + onVisibleChanged: if (visible) root.open() + RoomListModel { id: roomListModel connection: root.connection } - property var chatDocumentHandler Component.onCompleted: { chatDocumentHandler.completionModel.roomListModel = roomListModel; } @@ -50,40 +51,56 @@ QQC2.Popup { implicitHeight: Math.min(completions.contentHeight, Kirigami.Units.gridUnit * 10) - contentItem: ListView { - id: completions + contentItem: ColumnLayout { + spacing: 0 + Kirigami.Separator { + Layout.fillWidth: true + } + QQC2.ScrollView { + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + Layout.maximumHeight: Kirigami.Units.gridUnit * 10 - anchors.fill: parent - model: root.chatDocumentHandler.completionModel - currentIndex: 0 - keyNavigationWraps: true - highlightMoveDuration: 100 - delegate: Delegates.RoundedItemDelegate { - id: completionDelegate + background: Rectangle { + color: Kirigami.Theme.backgroundColor + } - required property int index - required property string displayName - required property string subtitle - required property string iconName + ListView { + id: completions - text: displayName + model: root.chatDocumentHandler.completionModel + currentIndex: 0 + keyNavigationWraps: true + highlightMoveDuration: 100 + onCountChanged: currentIndex = 0 + delegate: Delegates.RoundedItemDelegate { + id: completionDelegate - contentItem: RowLayout { - KirigamiComponents.Avatar { - visible: completionDelegate.iconName !== "invalid" - Layout.preferredWidth: Kirigami.Units.iconSizes.medium - Layout.preferredHeight: Kirigami.Units.iconSizes.medium - source: completionDelegate.iconName === "invalid" ? "" : completionDelegate.iconName - name: completionDelegate.text - } - Delegates.SubtitleContentItem { - itemDelegate: completionDelegate - labelItem.textFormat: Text.PlainText - subtitle: completionDelegate.subtitle ?? "" - subtitleItem.textFormat: Text.PlainText + required property int index + required property string displayName + required property string subtitle + required property string iconName + + text: displayName + + contentItem: RowLayout { + KirigamiComponents.Avatar { + visible: completionDelegate.iconName !== "invalid" + Layout.preferredWidth: Kirigami.Units.iconSizes.medium + Layout.preferredHeight: Kirigami.Units.iconSizes.medium + source: completionDelegate.iconName === "invalid" ? "" : completionDelegate.iconName + name: completionDelegate.text + } + Delegates.SubtitleContentItem { + itemDelegate: completionDelegate + labelItem.textFormat: Text.PlainText + subtitle: completionDelegate.subtitle ?? "" + subtitleItem.textFormat: Text.PlainText + } + } + onClicked: root.chatDocumentHandler.complete(completionDelegate.index) } } - onClicked: root.chatDocumentHandler.complete(completionDelegate.index) } } diff --git a/src/qml/EmoticonEditorPage.qml b/src/qml/EmoticonEditorPage.qml index e03cb8049..9360931ba 100644 --- a/src/qml/EmoticonEditorPage.qml +++ b/src/qml/EmoticonEditorPage.qml @@ -132,7 +132,7 @@ FormCard.FormCardPage { id: openFileDialog OpenFileDialog { - folder: StandardPaths.writableLocation(StandardPaths.PicturesLocation) + currentFolder: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] parentWindow: root.Window.window } } diff --git a/src/qml/OpenFileDialog.qml b/src/qml/OpenFileDialog.qml index 971dcf6f3..e03c9d7a1 100644 --- a/src/qml/OpenFileDialog.qml +++ b/src/qml/OpenFileDialog.qml @@ -1,15 +1,14 @@ -// SPDX-FileCopyrightText: 2019 Black Hat -// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later import QtQuick -import Qt.labs.platform +import QtQuick.Dialogs FileDialog { - signal chosen(string path) - id: root - title: i18n("Please choose a file") + signal chosen(string path) - onAccepted: chosen(file) + title: i18n("Select a File") + onAccepted: root.chosen(selectedFile) } diff --git a/src/qml/ReplyPane.qml b/src/qml/ReplyPane.qml index 63fcbd4cc..3f39e3f0f 100644 --- a/src/qml/ReplyPane.qml +++ b/src/qml/ReplyPane.qml @@ -11,82 +11,88 @@ import org.kde.kirigamiaddons.labs.components as KirigamiComponents import org.kde.neochat -GridLayout { +RowLayout { id: root + property string userName - property color userColor: Kirigami.Theme.highlightColor + property color userColor property url userAvatar: "" property var text - rows: 3 - columns: 3 - rowSpacing: Kirigami.Units.smallSpacing - columnSpacing: Kirigami.Units.largeSpacing + signal cancel - QQC2.Label { - id: replyLabel - Layout.fillWidth: true - Layout.alignment: Qt.AlignLeft - Layout.columnSpan: 3 - topPadding: Kirigami.Units.smallSpacing - - text: i18n("Replying to:") - } Rectangle { id: verticalBorder Layout.fillHeight: true - Layout.rowSpan: 2 implicitWidth: Kirigami.Units.smallSpacing color: userColor } - KirigamiComponents.Avatar { - id: replyAvatar + ColumnLayout { + RowLayout { + KirigamiComponents.Avatar { + id: replyAvatar - implicitWidth: Kirigami.Units.iconSizes.small - implicitHeight: Kirigami.Units.iconSizes.small + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small - source: userAvatar - name: userName - color: userColor - } - QQC2.Label { - Layout.fillWidth: true - Layout.alignment: Qt.AlignLeft + source: userAvatar + name: userName + color: userColor + } + QQC2.Label { + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft - color: userColor - text: userName - elide: Text.ElideRight - } - QQC2.TextArea { - id: textArea - - Layout.fillWidth: true - Layout.columnSpan: 2 - - leftPadding: 0 - rightPadding: 0 - topPadding: 0 - bottomPadding: 0 - text: "" + replyTextMetrics.elidedText - selectByMouse: true - selectByKeyboard: true - readOnly: true - wrapMode: QQC2.Label.Wrap - textFormat: TextEdit.RichText - background: Item {} - HoverHandler { - cursorShape: textArea.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor + color: userColor + text: userName + elide: Text.ElideRight + } } + QQC2.TextArea { + id: textArea - TextMetrics { - id: replyTextMetrics + Layout.fillWidth: true - text: root.text - font: textArea.font - elide: Qt.ElideRight - elideWidth: textArea.width * 2 - Kirigami.Units.smallSpacing * 2 + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + text: "" + replyTextMetrics.elidedText + selectByMouse: true + selectByKeyboard: true + readOnly: true + wrapMode: QQC2.Label.Wrap + textFormat: TextEdit.RichText + background: Item {} + HoverHandler { + cursorShape: textArea.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor + } + + TextMetrics { + id: replyTextMetrics + + text: root.text + font: textArea.font + elide: Qt.ElideRight + elideWidth: textArea.width * 2 - Kirigami.Units.smallSpacing * 2 + } } } + QQC2.ToolButton { + id: cancelButton + + Layout.alignment: Qt.AlignVCenter + + display: QQC2.AbstractButton.IconOnly + text: i18nc("@action:button", "Cancel reply") + icon.name: "dialog-close" + onClicked: { + root.cancel() + } + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } } diff --git a/src/qml/RoomPage.qml b/src/qml/RoomPage.qml index 61e070b4f..217f34428 100644 --- a/src/qml/RoomPage.qml +++ b/src/qml/RoomPage.qml @@ -88,8 +88,8 @@ Kirigami.Page { onCurrentRoomChanged: { banner.visible = false; - if (!Kirigami.Settings.isMobile && chatBoxLoader.item) { - chatBoxLoader.item.forceActiveFocus(); + if (!Kirigami.Settings.isMobile && chatBarLoader.item) { + chatBarLoader.item.forceActiveFocus(); } } @@ -123,9 +123,9 @@ Kirigami.Page { messageEventModel: root.messageEventModel messageFilterModel: root.messageFilterModel actionsHandler: root.actionsHandler - onFocusChatBox: { - if (chatBoxLoader.item) { - chatBoxLoader.item.forceActiveFocus() + onFocusChatBar: { + if (chatBarLoader.item) { + chatBarLoader.item.forceActiveFocus() } } connection: root.connection @@ -157,10 +157,10 @@ Kirigami.Page { } footer: Loader { - id: chatBoxLoader - active: timelineViewLoader.active - sourceComponent: ChatBox { - id: chatBox + id: chatBarLoader + active: timelineViewLoader.active && root.currentRoom.canSendEvent("m.room.message") // TODO make this update in real time + sourceComponent: ChatBar { + id: chatBar width: parent.width currentRoom: root.currentRoom connection: root.connection @@ -215,8 +215,8 @@ Kirigami.Page { Keys.onPressed: event => { if (!(event.modifiers & Qt.ControlModifier) && event.key < Qt.Key_Escape) { event.accepted = true; - chatBoxLoader.item.insertText(event.text); - chatBoxLoader.item.forceActiveFocus(); + chatBarLoader.item.insertText(event.text); + chatBarLoader.item.forceActiveFocus(); return; } else if (event.key === Qt.Key_PageUp) { event.accepted = true; @@ -228,7 +228,7 @@ Kirigami.Page { } Connections { - target: currentRoom + target: root.currentRoom function onShowMessage(messageType, message) { banner.text = message; banner.type = messageType === ActionsHandler.Error ? Kirigami.MessageType.Error : messageType === ActionsHandler.Positive ? Kirigami.MessageType.Positive : Kirigami.MessageType.Information; diff --git a/src/qml/TimelineView.qml b/src/qml/TimelineView.qml index 8f646ce3f..2dcb9496f 100644 --- a/src/qml/TimelineView.qml +++ b/src/qml/TimelineView.qml @@ -55,7 +55,7 @@ QQC2.ScrollView { /// Used to determine if scrolling to the bottom should mark the message as unread property bool hasScrolledUpBefore: false; - signal focusChatBox() + signal focusChatBar() ListView { id: messageListView @@ -166,7 +166,7 @@ QQC2.ScrollView { action: Kirigami.Action { onTriggered: { if (!Kirigami.Settings.isMobile) { - root.focusChatBox(); + root.focusChatBar(); } messageListView.goToEvent(root.currentRoom.readMarkerEventId) } @@ -258,7 +258,7 @@ QQC2.ScrollView { HoverActions { id: hoverActions currentRoom: root.currentRoom - onFocusChatBar: root.focusChatBox() + onFocusChatBar: root.focusChatBar() } onContentYChanged: { diff --git a/src/qml/UserInfo.qml b/src/qml/UserInfo.qml index 0a66d822f..29f0a2b4d 100644 --- a/src/qml/UserInfo.qml +++ b/src/qml/UserInfo.qml @@ -25,7 +25,7 @@ RowLayout { Layout.topMargin: Kirigami.Units.smallSpacing Layout.bottomMargin: Kirigami.Units.smallSpacing - Layout.minimumHeight: bottomEdge ? Kirigami.Units.gridUnit * 2 - 2 : -1 // HACK: -2 here is to ensure the ChatBox and the UserInfo have the same height + Layout.minimumHeight: bottomEdge ? Kirigami.Units.gridUnit * 2 : -1 onVisibleChanged: { if (!visible) {