diff --git a/.kde-ci.yml b/.kde-ci.yml index 8e8d06c9f..3b01ca7da 100644 --- a/.kde-ci.yml +++ b/.kde-ci.yml @@ -13,12 +13,12 @@ Dependencies: 'frameworks/kitemmodels': '@stable' 'frameworks/knotifications': '@stable' 'libraries/kquickimageeditor': '@stable' + 'frameworks/sonnet': '@stable' - 'on': ['Windows', 'Linux', 'FreeBSD'] 'require': 'frameworks/qqc2-desktop-style': '@stable' 'frameworks/kio': '@stable' 'frameworks/kwindowsystem': '@stable' - 'frameworks/sonnet': '@stable' 'frameworks/kconfigwidgets': '@stable' - 'on': ['Linux', 'FreeBSD'] 'require': diff --git a/CMakeLists.txt b/CMakeLists.txt index e3480b7bd..416275618 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,7 +48,7 @@ set_package_properties(Qt${QT_MAJOR_VERSION} PROPERTIES TYPE REQUIRED PURPOSE "Basic application components" ) -find_package(KF5 ${KF5_MIN_VERSION} COMPONENTS Kirigami2 I18n Notifications Config CoreAddons) +find_package(KF5 ${KF5_MIN_VERSION} COMPONENTS Kirigami2 I18n Notifications Config CoreAddons Sonnet) set_package_properties(KF5 PROPERTIES TYPE REQUIRED PURPOSE "Basic application components" @@ -76,7 +76,6 @@ else() set_package_properties(KF5QQC2DesktopStyle PROPERTIES TYPE RUNTIME ) - ecm_find_qmlmodule(org.kde.sonnet 1.0) ecm_find_qmlmodule(org.kde.syntaxhighlighting 1.0) endif() diff --git a/imports/NeoChat/Component/ChatBox/AttachmentPane.qml b/imports/NeoChat/Component/ChatBox/AttachmentPane.qml index 9d47b5620..4c9940ba8 100644 --- a/imports/NeoChat/Component/ChatBox/AttachmentPane.qml +++ b/imports/NeoChat/Component/ChatBox/AttachmentPane.qml @@ -12,16 +12,17 @@ import org.kde.neochat 1.0 import NeoChat.Page 1.0 Loader { - id: root + id: attachmentPaneLoader - property var attachmentMimetype: FileType.mimeTypeForUrl(chatBoxHelper.attachmentPath) + readonly property var attachmentMimetype: FileType.mimeTypeForUrl(attachmentPaneLoader.attachmentPath) readonly property bool hasImage: attachmentMimetype.valid && FileType.supportedImageFormats.includes(attachmentMimetype.preferredSuffix) + readonly property string attachmentPath: currentRoom.chatBoxAttachmentPath + readonly property string baseFileName: attachmentPath.substring(attachmentPath.lastIndexOf('/') + 1, attachmentPath.length) active: visible sourceComponent: Component { Pane { id: attachmentPane - property string baseFileName: chatBoxHelper.attachmentPath.toString().substring(chatBoxHelper.attachmentPath.toString().lastIndexOf('/') + 1, chatBoxHelper.attachmentPath.length) Kirigami.Theme.colorSet: Kirigami.Theme.View contentItem: Item { @@ -45,8 +46,8 @@ Loader { 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 ? chatBoxHelper.attachmentPath : "" + smooth: height === preferredHeight && parent.height === parent.implicitHeight // Don't smooth until height animation stops + source: hasImage ? attachmentPaneLoader.attachmentPath : "" visible: hasImage fillMode: Image.PreserveAspectFit @@ -152,7 +153,7 @@ Loader { Item { Layout.fillWidth: true } - Button { + ToolButton { id: editImageButton visible: hasImage icon.name: "document-edit" @@ -162,25 +163,25 @@ Loader { Component { id: imageEditorPage ImageEditorPage { - imagePath: chatBoxHelper.attachmentPath + imagePath: attachmentPaneLoader.attachmentPath } } onClicked: { let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage); imageEditor.newPathChanged.connect(function(newPath) { applicationWindow().pageStack.layers.pop(); - chatBoxHelper.attachmentPath = newPath; + attachmentPaneLoader.attachmentPath = newPath; }); } ToolTip.text: text ToolTip.visible: hovered } - Button { + ToolButton { id: cancelAttachmentButton - icon.name: "dialog-cancel" - text: i18n("Cancel") + icon.name: "dialog-close" + text: i18n("Cancel sending Image") display: AbstractButton.IconOnly - onClicked: chatBoxHelper.clearAttachment(); + onClicked: currentRoom.chatBoxAttachmentPath = ""; ToolTip.text: text ToolTip.visible: hovered } diff --git a/imports/NeoChat/Component/ChatBox/ChatBar.qml b/imports/NeoChat/Component/ChatBox/ChatBar.qml index be53a5ab3..bba5ce74c 100644 --- a/imports/NeoChat/Component/ChatBox/ChatBar.qml +++ b/imports/NeoChat/Component/ChatBox/ChatBar.qml @@ -5,8 +5,6 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 import QtQuick.Controls 2.15 -import QtQuick.Templates 2.15 as T -import Qt.labs.platform 1.1 as Platform import QtQuick.Window 2.15 import org.kde.kirigami 2.18 as Kirigami @@ -14,25 +12,13 @@ import org.kde.neochat 1.0 ToolBar { id: chatBar - property string replyEventId: "" - property string editEventId: "" property alias inputFieldText: inputField.text 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 closeAllTriggered() signal inputFieldForceActiveFocusTriggered() signal messageSent() - signal pasteImageTriggered() - signal editLastUserMessage() - signal replyPreviousUserMessage() - - property alias isCompleting: completionMenu.visible onInputFieldForceActiveFocusTriggered: { inputField.forceActiveFocus(); @@ -92,12 +78,7 @@ ToolBar { topPadding: 0 bottomPadding: 0 - property real progress: 0 - property bool autoAppeared: false - //property int lineHeight: contentHeight / lineCount - - text: inputFieldText - placeholderText: readOnly ? i18n("This room is encrypted. Sending encrypted messages is not yet supported.") : editEventId.length > 0 ? i18n("Edit Message") : currentRoom.usesEncryption ? i18n("Send an encrypted message…") : i18n("Send a message…") + placeholderText: readOnly ? i18n("This room is encrypted. Sending encrypted messages is not yet supported.") : currentRoom.chatBoxEditId.length > 0 ? i18n("Edit Message") : currentRoom.usesEncryption ? i18n("Send an encrypted message…") : i18n("Send a message…") verticalAlignment: TextEdit.AlignVCenter horizontalAlignment: TextEdit.AlignLeft wrapMode: Text.Wrap @@ -105,7 +86,6 @@ ToolBar { Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.inherit: false - Kirigami.SpellChecking.enabled: true color: Kirigami.Theme.textColor selectionColor: Kirigami.Theme.highlightColor @@ -114,117 +94,54 @@ ToolBar { selectByMouse: !Kirigami.Settings.tabletMode - ChatDocumentHandler { - id: documentHandler - document: inputField.textDocument - cursorPosition: inputField.cursorPosition - selectionStart: inputField.selectionStart - selectionEnd: inputField.selectionEnd - room: currentRoom ?? null - } - - Timer { - id: repeatTimer - interval: 5000 - } - - function sendMessage(event) { - if (isCompleting && completionMenu.count > 0) { - chatBar.complete(); + Keys.onEnterPressed: { + if (completionMenu.visible) { + completionMenu.complete() } else if (event.modifiers & Qt.ShiftModifier) { inputField.insert(cursorPosition, "\n") } else { - currentRoom.sendTypingNotification(false) - chatBar.postMessage() + chatBar.postMessage(); + } + } + Keys.onReturnPressed: { + if (completionMenu.visible) { + completionMenu.complete() + } else if (event.modifiers & Qt.ShiftModifier) { + inputField.insert(cursorPosition, "\n") + } else { + chatBar.postMessage(); } - isCompleting = false; } - Keys.onReturnPressed: { sendMessage(event) } - Keys.onEnterPressed: { sendMessage(event) } - - Keys.onEscapePressed: { - closeAllTriggered() + Keys.onTabPressed: { + if (completionMenu.visible) { + completionMenu.complete() + } } Keys.onPressed: { if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { chatBar.pasteImage(); } else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) { - replyPreviousUserMessage(); + let replyEvent = messageEventModel.getLatestMessageFromIndex(0) + if (replyEvent && replyEvent["event_id"]) { + currentRoom.chatBoxReplyId = replyEvent["event_id"] + } } else if (event.key === Qt.Key_Up && inputField.text.length === 0) { - editLastUserMessage(); - } - } - - Keys.onBacktabPressed: { - if (event.modifiers & Qt.ControlModifier) { - switchRoomUp(); - return; - } - if (!isCompleting) { - nextItemInFocusChain(false).forceActiveFocus(Qt.TabFocusReason) - return - } - if (!autoAppeared) { - 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 + let editEvent = messageEventModel.getLastLocalUserMessageEventId() + if (editEvent) { + currentRoom.chatBoxEditId = editEvent["event_id"] } - completionMenu.currentIndex = decrementedIndex - } else { - autoAppeared = false; + } else if (event.key === Qt.Key_Up && completionMenu.visible) { + completionMenu.decrementIndex() + } else if (event.key === Qt.Key_Down && completionMenu.visible) { + completionMenu.incrementIndex() } - - chatBar.complete(); } - // yes, decrement goes up and increment goes down visually. - Keys.onUpPressed: (event) => { - if (chatBar.isCompleting) { - event.accepted = true - completionMenu.listView.decrementCurrentIndex() - autoAppeared = true; - } - event.accepted = false - } - - Keys.onDownPressed: (event) => { - if (chatBar.isCompleting) { - event.accepted = true - completionMenu.listView.incrementCurrentIndex() - autoAppeared = true; - } - event.accepted = false - } - - Keys.onTabPressed: { - if (event.modifiers & Qt.ControlModifier) { - switchRoomDown(); - return; - } - if (!isCompleting) { - nextItemInFocusChain().forceActiveFocus(Qt.TabFocusReason); - return; - } - - // TODO detect moved cursor - - // ignore first time tab was clicked so that user can select - // first emoji/user - if (!autoAppeared) { - 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(); + Timer { + id: repeatTimer + interval: 5000 } onTextChanged: { @@ -233,58 +150,20 @@ ToolBar { } repeatTimer.start() - currentRoom.cachedInput = text - autoAppeared = false; - - const completionInfo = documentHandler.getAutocompletionInfo(isCompleting); - - if (completionInfo.type === ChatDocumentHandler.Ignore) { - if (completionInfo.keyword) { - // custom emojis - const idx = completionMenu.currentIndex; - completionMenu.model = Array.from(chatBar.customEmojiModel.filterModel(completionInfo.keyword)).concat(EmojiModel.filterModel(completionInfo.keyword)) - completionMenu.currentIndex = idx; - } - return; - } - - if (completionInfo.type === ChatDocumentHandler.None) { - isCompleting = false; - return; - } - - completionMenu.completionType = completionInfo.type - if (completionInfo.type === ChatDocumentHandler.User) { - completionMenu.model = currentRoom.getUsers(completionInfo.keyword, 10); - } else if (completionInfo.type === ChatDocumentHandler.Command) { - completionMenu.model = CommandModel.filterModel(completionInfo.keyword); - } else { - completionMenu.model = Array.from(chatBar.customEmojiModel.filterModel(completionInfo.keyword)).concat(EmojiModel.filterModel(completionInfo.keyword)) - } - - if (completionMenu.model.length === 0) { - isCompleting = false; - return; - } - - if (!isCompleting) { - isCompleting = true - autoAppeared = true; - completionMenu.endPosition = cursorPosition - } + currentRoom.chatBoxText = text } } } Item { - visible: !chatBoxHelper.isReplying && (!chatBoxHelper.hasAttachment || uploadingBusySpinner.running) + visible: currentRoom.chatBoxReplyId.length === 0 && (currentRoom.chatBoxAttachmentPath.length === 0 || uploadingBusySpinner.running) implicitWidth: uploadButton.implicitWidth implicitHeight: uploadButton.implicitHeight ToolButton { id: uploadButton anchors.fill: parent // Matrix does not allow sending attachments in replies - visible: !chatBoxHelper.isReplying && !chatBoxHelper.hasAttachment && !uploadingBusySpinner.running + visible: currentRoom.chatBoxReplyId.length === 0 && currentRoom.chatBoxAttachmentPath.length === 0 && !uploadingBusySpinner.running icon.name: "mail-attachment" text: i18n("Attach an image or file") display: AbstractButton.IconOnly @@ -295,8 +174,10 @@ ToolBar { } else { var fileDialog = openFileDialog.createObject(ApplicationWindow.overlay) fileDialog.chosen.connect((path) => { - if (!path) { return } - chatBoxHelper.attachmentPath = path; + if (!path) { + return; + } + currentRoom.chatBoxAttachmentPath = path; }) fileDialog.open() } @@ -339,24 +220,12 @@ ToolBar { } } - 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 + y: -height - 5 z: 1 + chatDocumentHandler: documentHandler Behavior on height { NumberAnimation { property: "height" @@ -364,59 +233,42 @@ ToolBar { easing.type: Easing.OutCubic } } - onCompleteTriggered: { - complete() - isCompleting = false; + } + + Connections { + target: currentRoom + function onChatBoxEditIdChanged() { + chatBar.inputFieldText = currentRoom.chatBoxEditMessage } } - property CustomEmojiModel customEmojiModel: CustomEmojiModel { - connection: Controller.activeConnection + ChatDocumentHandler { + id: documentHandler + document: inputField.textDocument + cursorPosition: inputField.cursorPosition + selectionStart: inputField.selectionStart + selectionEnd: inputField.selectionEnd + Component.onCompleted: { + RoomManager.chatDocumentHandler = documentHandler; + } } + function pasteImage() { - let localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png"; - if (!Clipboard.saveImage(localPath)) { + let localPath = Clipboard.saveImage(); + if (localPath.length === 0) { return; } - chatBoxHelper.attachmentPath = localPath; + currentRoom.chatBoxAttachmentPath = localPath } function postMessage() { - checkForFancyEffectsReason(); + actionsHandler.handleMessage(); - if (chatBoxHelper.hasAttachment) { - // send attachment but don't reset the text - actionsHandler.postMessage("", chatBoxHelper.attachmentPath, - chatBoxHelper.replyEventId, chatBoxHelper.editEventId, {}, this.customEmojiModel); - currentRoom.markAllMessagesAsRead(); - messageSent(); - return; - } - - const re = /^s\/([^\/]*)\/([^\/]*)/; - if (Config.allowQuickEdit && re.test(inputField.text)) { - // send edited messages - actionsHandler.postEdit(inputField.text); - } else { - // send normal message - actionsHandler.postMessage(inputField.text.trim(), chatBoxHelper.attachmentPath, - chatBoxHelper.replyEventId, chatBoxHelper.editEventId, userAutocompleted, this.customEmojiModel); - } currentRoom.markAllMessagesAsRead(); inputField.clear(); - inputField.text = Qt.binding(function() { - return currentRoom ? currentRoom.cachedInput : ""; - }); + currentRoom.chatBoxReplyId = ""; + currentRoom.chatBoxEditId = ""; messageSent() } - - function complete() { - documentHandler.replaceAutoComplete(completionMenu.currentDisplayText); - if (completionMenu.completionType === ChatDocumentHandler.User - && completionMenu.currentDisplayText.length > 0 - && completionMenu.currentItem.userId.length > 0) { - userAutocompleted[completionMenu.currentDisplayText] = completionMenu.currentItem.userId; - } - } } diff --git a/imports/NeoChat/Component/ChatBox/ChatBox.qml b/imports/NeoChat/Component/ChatBox/ChatBox.qml index 9156c5d91..876903cab 100644 --- a/imports/NeoChat/Component/ChatBox/ChatBox.qml +++ b/imports/NeoChat/Component/ChatBox/ChatBox.qml @@ -4,58 +4,26 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 as QQC2 -import Qt.labs.platform 1.1 as Platform +import QtQuick.Layouts 1.15 import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 import NeoChat.Component.ChatBox 1.0 import NeoChat.Component.Emoji 1.0 -Item { - id: root +ColumnLayout { + id: chatBox + property alias inputFieldText: chatBar.inputFieldText - signal fancyEffectsReasonFound(string fancyEffect) signal messageSent() - signal editLastUserMessage() - signal replyPreviousUserMessage() - 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 - } - } + spacing: 0 Kirigami.Separator { id: connectionPaneSeparator visible: connectionPane.visible - width: parent.width - height: visible ? implicitHeight : 0 - anchors.bottom: connectionPane.top - z: 1 + Layout.fillWidth: true } QQC2.Pane { @@ -71,30 +39,25 @@ Item { color: Kirigami.Theme.backgroundColor } visible: !Controller.isOnline - width: parent.width + Layout.fillWidth: true QQC2.Label { id: networkLabel text: i18n("NeoChat is offline. Please check your network connection.") } - anchors.bottom: emojiPickerLoaderSeparator.top } Kirigami.Separator { id: emojiPickerLoaderSeparator visible: emojiPickerLoader.visible - width: parent.width + Layout.fillWidth: true 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 + Layout.fillWidth: true sourceComponent: QQC2.Pane { topPadding: 0 bottomPadding: 0 @@ -106,178 +69,79 @@ Item { onChosen: addText(emoji) } } - 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 + Layout.fillWidth: true } - ReplyPane { id: replyPane - visible: chatBoxHelper.isReplying || chatBoxHelper.isEditing - user: chatBoxHelper.replyUser - 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 - } - } + visible: currentRoom.chatBoxReplyId.length > 0 || currentRoom.chatBoxEditId.length > 0 + Layout.fillWidth: true + onReplyCancelled: { - root.focusInputField() + chatBox.focusInputField() } } Kirigami.Separator { id: attachmentSeparator visible: attachmentPane.visible - width: parent.width - height: visible ? implicitHeight : 0 - anchors.bottom: attachmentPane.top + Layout.fillWidth: true } AttachmentPane { id: attachmentPane - visible: chatBoxHelper.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 - } - } + visible: currentRoom.chatBoxAttachmentPath.length > 0 + Layout.fillWidth: true } Kirigami.Separator { id: chatBarSeparator visible: chatBar.visible - width: parent.width - height: visible ? implicitHeight : 0 - anchors.bottom: chatBar.top + + Layout.fillWidth: true } 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 - } - } + Layout.fillWidth: true onCloseAllTriggered: closeAll() onMessageSent: { closeAll() - checkForFancyEffectsReason() - root.messageSent(); - } - onEditLastUserMessage: { - root.editLastUserMessage(); - } - onReplyPreviousUserMessage: { - root.replyPreviousUserMessage(); - } - } - - function checkForFancyEffectsReason() { - if (!Config.showFancyEffects) { - return + chatBox.messageSent(); } - 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") + Behavior on implicitHeight { + NumberAnimation { + property: "implicitHeight" + duration: Kirigami.Units.shortDuration + easing.type: Easing.OutCubic + } } } function addText(text) { - root.inputFieldText = inputFieldText + text + chatBox.inputFieldText = inputFieldText + text } function insertText(str) { - root.inputFieldText = inputFieldText.substr(0, inputField.cursorPosition) + str + inputFieldText.substr(inputField.cursorPosition) + chatBox.inputFieldText = inputFieldText.substr(0, inputField.cursorPosition) + str + inputFieldText.substr(inputField.cursorPosition) } function focusInputField() { chatBar.inputFieldForceActiveFocusTriggered() } - Connections { - target: RoomManager - - function onCurrentRoomChanged() { - chatBar.userAutocompleted = {}; - } - } - - Connections { - target: chatBoxHelper - - function onShouldClearText() { - root.inputFieldText = ""; - } - - function onEditing(editContent, editFormatedContent) { - // Set the input field in edit mode - root.inputFieldText = 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]; - } - chatBox.forceActiveFocus(); - } - } - function closeAll() { - chatBoxHelper.clear(); + // TODO clear(); chatBar.emojiPaneOpened = false; } } diff --git a/imports/NeoChat/Component/ChatBox/CompletionMenu.qml b/imports/NeoChat/Component/ChatBox/CompletionMenu.qml index d5507b964..3667848ef 100644 --- a/imports/NeoChat/Component/ChatBox/CompletionMenu.qml +++ b/imports/NeoChat/Component/ChatBox/CompletionMenu.qml @@ -10,154 +10,65 @@ import Qt.labs.qmlmodels 1.0 import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 -import NeoChat.Component 1.0 Popup { - id: control + id: completionMenu + width: parent.width - // Expose internal ListView properties. - property alias model: completionListView.model - property alias listView: completionListView - property alias currentIndex: completionListView.currentIndex - property alias currentItem: completionListView.currentItem - property alias count: completionListView.count - property alias delegate: completionListView.delegate + visible: completions.count > 0 - // Autocomplee text - property string currentDisplayText: currentItem && (currentItem.displayName ?? "") + RoomListModel { + id: roomListModel + connection: Controller.activeConnection + } - property int completionType: ChatDocumentHandler.Emoji - property int beginPosition: 0 - property int endPosition: 0 + required property var chatDocumentHandler + Component.onCompleted: { + chatDocumentHandler.completionModel.roomListModel = roomListModel; + } - signal completeTriggered() + function incrementIndex() { + completions.incrementCurrentIndex() + } - Kirigami.Theme.colorSet: Kirigami.Theme.View + function decrementIndex() { + completions.decrementCurrentIndex() + } + + function complete() { + completionMenu.chatDocumentHandler.complete(completions.currentIndex) + } - bottomPadding: 0 leftPadding: 0 rightPadding: 0 topPadding: 0 - clip: true + bottomPadding: 0 + implicitHeight: Math.min(completions.contentHeight, Kirigami.Units.gridUnit * 10) - onVisibleChanged: if (!visible) { - completionListView.currentIndex = 0; - } + contentItem: ListView { + id: completions - implicitHeight: Math.min(completionListView.contentHeight, Kirigami.Units.gridUnit * 10) - - contentItem: ScrollView { - // HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890) - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - ListView { - id: completionListView - implicitWidth: contentWidth - delegate: { - if (completionType === ChatDocumentHandler.Emoji) { - emojiDelegate - } else if (completionType === ChatDocumentHandler.Command) { - commandDelegate - } else if (completionType === ChatDocumentHandler.User) { - usernameDelegate + anchors.fill: parent + model: completionMenu.chatDocumentHandler.completionModel + currentIndex: 0 + keyNavigationWraps: true + highlightMoveDuration: 100 + delegate: Kirigami.BasicListItem { + text: model.text + subtitle: model.subtitle ?? "" + leading: RowLayout { + Kirigami.Avatar { + visible: model.icon !== "invalid" + Layout.preferredWidth: height + Layout.fillHeight: true + source: model.icon === "invalid" ? "" : ("image://mxc/" + model.icon) + name: model.text } } - - keyNavigationWraps: true - - //interactive: Window.window ? contentHeight + control.topPadding + control.bottomPadding > Window.window.height : false - clip: true - currentIndex: control.currentIndex || 0 + onClicked: completionMenu.chatDocumentHandler.complete(model.index) } } - background: Rectangle { color: Kirigami.Theme.backgroundColor } - - Component { - id: usernameDelegate - Kirigami.BasicListItem { - id: usernameItem - width: ListView.view.width ?? implicitWidth - property string displayName: modelData.displayName - property string userId: modelData.id - 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 - } - labelItem.textFormat: Text.PlainText - text: modelData.displayName - onClicked: completeTriggered(); - } - } - - Component { - id: emojiDelegate - Kirigami.BasicListItem { - id: emojiItem - width: ListView.view.width ?? implicitWidth - property string displayName: modelData.isCustom ? modelData.shortname : modelData.unicode - text: modelData.shortname - height: Kirigami.Units.gridUnit * 2 - - leading: Image { - source: modelData.isCustom ? modelData.unicode : "" - - width: height - sourceSize.width: width - sourceSize.height: height - - Rectangle { - anchors.fill: parent - visible: parent.status === Image.Loading - radius: height/2 - gradient: ShimmerGradient { } - } - - Label { - id: unicodeLabel - - visible: !modelData.isCustom - - font.family: 'emoji' - font.pixelSize: height - 2 - - text: modelData.unicode - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - - anchors.fill: parent - } - } - - onClicked: completeTriggered(); - } - } - - Component { - id: commandDelegate - Kirigami.BasicListItem { - id: commandItem - width: ListView.view.width ?? implicitWidth - text: "" + modelData.parameter.replace("<", "<").replace(">", ">") + " " + modelData.help - property string displayName: modelData.command - - leading: Label { - id: commandLabel - Layout.preferredHeight: Kirigami.Units.gridUnit - Layout.preferredWidth: textMetrics.tightBoundingRect.width - text: modelData.command - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - TextMetrics { - id: textMetrics - text: modelData.command - font: commandLabel.font - } - onClicked: completeTriggered(); - } - } } diff --git a/imports/NeoChat/Component/ChatBox/ReplyPane.qml b/imports/NeoChat/Component/ChatBox/ReplyPane.qml index 311e1e159..8c6cab929 100644 --- a/imports/NeoChat/Component/ChatBox/ReplyPane.qml +++ b/imports/NeoChat/Component/ChatBox/ReplyPane.qml @@ -11,10 +11,8 @@ import org.kde.kirigami 2.14 as Kirigami import org.kde.neochat 1.0 Loader { - id: root - readonly property bool isEdit: chatBoxHelper.isEditing - property var user: null - property string avatarMediaUrl: user ? "image://mxc/" + user.avatarMediaId : "" + id: replyPane + property NeoChatUser user: currentRoom.chatBoxReplyUser ?? currentRoom.chatBoxEditUser signal replyCancelled() @@ -40,7 +38,7 @@ Loader { 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 + source: user ? "image://mxc/" + currentRoom.getUser(user.id).avatarMediaId : "" name: user ? user.displayName : "" color: user ? user.color : "transparent" visible: Boolean(user) @@ -58,7 +56,7 @@ Loader { text: { let heading = "%1" let userName = user ? "" + currentRoom.htmlSafeMemberName(user.id) + "" : "" - if (isEdit) { + if (currentRoom.chatBoxEditId.length > 0) { heading = heading.arg(i18n("Editing message:")) + "
" } else { heading = heading.arg(i18n("Replying to %1:", userName)) @@ -67,6 +65,7 @@ Loader { return heading } } + //TODO edit user mentions ScrollView { Layout.alignment: Qt.AlignLeft | Qt.AlignTop Layout.fillWidth: true @@ -81,11 +80,7 @@ Loader { rightPadding: 0 topPadding: 0 bottomPadding: 0 - text: { - const stylesheet = ""; - const content = chatBoxHelper.isReplying ? chatBoxHelper.replyEventContent : chatBoxHelper.editContent; - return stylesheet + content; - } + text: "" + (currentRoom.chatBoxEditId.length > 0 ? currentRoom.chatBoxEditMessage : currentRoom.chatBoxReplyMessage) selectByMouse: true selectByKeyboard: true readOnly: true @@ -99,15 +94,16 @@ Loader { } } - Button { - id: cancelReplyButton - Layout.alignment: avatar.Layout.alignment - icon.name: "dialog-cancel" - text: i18n("Cancel") + ToolButton { display: AbstractButton.IconOnly - onClicked: { - chatBoxHelper.clear(); - root.replyCancelled(); + action: Kirigami.Action { + text: i18nc("@action:button", "Cancel reply") + icon.name: "dialog-close" + onTriggered: { + currentRoom.chatBoxReplyId = ""; + currentRoom.chatBoxEditId = ""; + } + shortcut: "Escape" } ToolTip.text: text ToolTip.visible: hovered diff --git a/imports/NeoChat/Component/Emoji/EmojiPicker.qml b/imports/NeoChat/Component/Emoji/EmojiPicker.qml index aac88f895..276c3b8fd 100644 --- a/imports/NeoChat/Component/Emoji/EmojiPicker.qml +++ b/imports/NeoChat/Component/Emoji/EmojiPicker.qml @@ -6,7 +6,7 @@ import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import org.kde.kirigami 2.15 as Kirigami -import org.kde.neochat 1.0 as NeoChat +import org.kde.neochat 1.0 import NeoChat.Component 1.0 ColumnLayout { @@ -14,11 +14,7 @@ ColumnLayout { property string emojiCategory: "history" property var textArea - readonly property var emojiModel: NeoChat.EmojiModel - - property NeoChat.CustomEmojiModel customModel: NeoChat.CustomEmojiModel { - connection: NeoChat.Controller.activeConnection - } + readonly property var emojiModel: EmojiModel signal chosen(string emoji) @@ -102,7 +98,7 @@ ColumnLayout { model: { switch (emojiCategory) { case "custom": - return _picker.customModel + return CustomEmojiModel case "history": return emojiModel.history case "people": diff --git a/imports/NeoChat/Menu/Timeline/FileDelegateContextMenu.qml b/imports/NeoChat/Menu/Timeline/FileDelegateContextMenu.qml index 27d50c786..362d3fc48 100644 --- a/imports/NeoChat/Menu/Timeline/FileDelegateContextMenu.qml +++ b/imports/NeoChat/Menu/Timeline/FileDelegateContextMenu.qml @@ -52,7 +52,8 @@ MessageDelegateContextMenu { text: i18n("Reply") icon.name: "mail-replied-symbolic" onTriggered: { - chatBoxHelper.replyToMessage(eventId, message, author); + currentRoom.chatBoxReplyId = eventId + currentRoom.chatBoxEditId = "" root.closeFullscreen() } }, diff --git a/imports/NeoChat/Menu/Timeline/MessageDelegateContextMenu.qml b/imports/NeoChat/Menu/Timeline/MessageDelegateContextMenu.qml index 0fbfd60f7..10a646e55 100644 --- a/imports/NeoChat/Menu/Timeline/MessageDelegateContextMenu.qml +++ b/imports/NeoChat/Menu/Timeline/MessageDelegateContextMenu.qml @@ -28,13 +28,19 @@ Loader { Kirigami.Action { text: i18n("Edit") icon.name: "document-edit" - onTriggered: chatBoxHelper.edit(message, formattedBody, eventId); + onTriggered: { + currentRoom.chatBoxEditId = eventId; + currentRoom.chatBoxReplyId = ""; + } visible: eventType.length > 0 && author.id === Controller.activeConnection.localUserId && (eventType === "emote" || eventType === "message") }, Kirigami.Action { text: i18n("Reply") icon.name: "mail-replied-symbolic" - onTriggered: chatBoxHelper.replyToMessage(eventId, message, author); + onTriggered: { + currentRoom.chatBoxReplyId = eventId; + currentRoom.chatBoxEditId = ""; + } }, Kirigami.Action { visible: author.id === currentRoom.localUser.id || currentRoom.canSendState("redact") diff --git a/imports/NeoChat/Page/RoomPage.qml b/imports/NeoChat/Page/RoomPage.qml index 5b4f60dee..c074f4709 100644 --- a/imports/NeoChat/Page/RoomPage.qml +++ b/imports/NeoChat/Page/RoomPage.qml @@ -53,7 +53,6 @@ Kirigami.ScrollablePage { onCurrentRoomChanged: { hasScrolledUpBefore = false; - chatBoxHelper.clearEditReply() } Connections { @@ -78,11 +77,6 @@ Kirigami.ScrollablePage { ActionsHandler { id: actionsHandler room: page.currentRoom - connection: Controller.activeConnection - } - - ChatBoxHelper { - id: chatBoxHelper } Shortcut { @@ -101,10 +95,10 @@ Kirigami.ScrollablePage { } Connections { - target: actionsHandler + target: currentRoom function onShowMessage(messageType, message) { page.header.contentItem.text = message; - page.header.contentItem.type = messageType === ActionsHandler.Error ? Kirigami.MessageType.Error : Kirigami.MessageType.Information; + page.header.contentItem.type = messageType === ActionsHandler.Error ? Kirigami.MessageType.Error : messageType === ActionsHandler.Positive ? Kirigami.MessageType.Positive : Kirigami.MessageType.Information; page.header.visible = true; } } @@ -177,15 +171,6 @@ Kirigami.ScrollablePage { } } - Connections { - target: currentRoom - function onPositiveMessage(message) { - page.header.contentItem.text = message; - page.header.contentItem.type = Kirigami.MessageType.Positive; - page.header.visible = true; - } - } - // hover actions on a delegate, activated in TimelineContainer.qml Connections { target: page.flickable @@ -268,9 +253,10 @@ Kirigami.ScrollablePage { var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay) fileDialog.chosen.connect(function(path) { - if (!path) return - - chatBoxHelper.attachmentPath = path; + if (!path) { + return; + } + currentRoom.chatBoxAttachmentPath = path; }) fileDialog.open() @@ -292,7 +278,7 @@ Kirigami.ScrollablePage { if (!Clipboard.saveImage(localPath)) { return; } - chatBoxHelper.attachmentPath = localPath; + currentRoom.chatBoxAttachmentPath = localPath; attachDialog.close(); } } @@ -367,7 +353,7 @@ Kirigami.ScrollablePage { DropArea { id: dropAreaFile anchors.fill: parent - onDropped: chatBoxHelper.attachmentPath = drop.urls[0] + onDropped: currentRoom.chatBoxAttachmentPath = drop.urls[0]; } QQC2.Pane { @@ -510,9 +496,8 @@ Kirigami.ScrollablePage { visible: hoverActions.showEdit icon.name: "document-edit" onClicked: { - if (hoverActions.showEdit) { - chatBoxHelper.edit(hoverActions.event.message, hoverActions.event.formattedBody, hoverActions.event.eventId) - } + currentRoom.chatBoxEditId = hoverActions.event.eventId; + currentRoom.chatBoxReplyId = ""; chatBox.focusInputField(); } } @@ -522,7 +507,8 @@ Kirigami.ScrollablePage { QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay icon.name: "mail-replied-symbolic" onClicked: { - chatBoxHelper.replyToMessage(hoverActions.event.eventId, hoverActions.event.message, hoverActions.event.author); + currentRoom.chatBoxReplyId = hoverActions.event.eventId; + currentRoom.chatBoxEditId = ""; chatBox.focusInputField(); } } @@ -534,24 +520,12 @@ Kirigami.ScrollablePage { footer: ChatBox { id: chatBox visible: !invitation.visible && !(messageListView.count === 0 && !currentRoom.allHistoryLoaded) + width: parent.width onMessageSent: { if (!messageListView.atYEnd) { goToLastMessage(); } } - onEditLastUserMessage: { - const targetMessage = messageEventModel.getLastLocalUserMessageEventId(); - if (targetMessage) { - chatBoxHelper.edit(targetMessage["message"], targetMessage["formattedBody"], targetMessage["event_id"]); - chatBox.focusInputField(); - } - } - onReplyPreviousUserMessage: { - const replyResponse = messageEventModel.getLatestMessageFromIndex(0); - if (replyResponse && replyResponse["event_id"]) { - chatBoxHelper.replyToMessage(replyResponse["event_id"], replyResponse["message"], replyResponse["sender_id"]); - } - } } background: FancyEffectsContainer { @@ -582,8 +556,8 @@ Kirigami.ScrollablePage { Connections { enabled: Config.showFancyEffects - target: chatBox - function onFancyEffectsReasonFound(fancyEffect) { + target: actionsHandler + function onShowEffect(fancyEffect) { fancyEffectsContainer.processFancyEffectsReason(fancyEffect) } } diff --git a/imports/NeoChat/Settings/Emoticons.qml b/imports/NeoChat/Settings/Emoticons.qml index 3974326db..9db37a174 100644 --- a/imports/NeoChat/Settings/Emoticons.qml +++ b/imports/NeoChat/Settings/Emoticons.qml @@ -21,10 +21,7 @@ Kirigami.ScrollablePage { ListView { anchors.fill: parent - model: CustomEmojiModel { - id: emojiModel - connection: Controller.activeConnection - } + model: CustomEmojiModel Kirigami.PlaceholderMessage { anchors.centerIn: parent @@ -99,7 +96,7 @@ Kirigami.ScrollablePage { this.fileDialog = openFileDialog.createObject(QQC2.Overlay.overlay) this.fileDialog.chosen.connect((url) => { - emojiModel.addEmoji(emojiCreator.name, url) + CustomEmojiModel.addEmoji(emojiCreator.name, url) this.fileDialog = null }) this.fileDialog.onRejected.connect(() => { diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 19e8a3b13..13f968e5a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -30,8 +30,6 @@ add_library(neochat STATIC filetypesingleton.cpp login.cpp stickerevent.cpp - chatboxhelper.cpp - commandmodel.cpp webshortcutmodel.cpp blurhash.cpp blurhashimageprovider.cpp @@ -40,6 +38,9 @@ add_library(neochat STATIC urlhelper.cpp windowcontroller.cpp linkpreviewer.cpp + completionmodel.cpp + completionproxymodel.cpp + actionsmodel.cpp ) add_executable(neochat-app @@ -84,7 +85,7 @@ else() endif() target_include_directories(neochat PRIVATE ${CMAKE_BINARY_DIR}) -target_link_libraries(neochat PUBLIC Qt::Core Qt::Quick Qt::Qml Qt::Gui Qt::Multimedia Qt::Network Qt::QuickControls2 KF5::I18n KF5::Kirigami2 KF5::Notifications KF5::ConfigCore KF5::ConfigGui KF5::CoreAddons Quotient cmark::cmark ${QTKEYCHAIN_LIBRARIES}) +target_link_libraries(neochat PUBLIC Qt::Core Qt::Quick Qt::Qml Qt::Gui Qt::Multimedia Qt::Network Qt::QuickControls2 KF5::I18n KF5::Kirigami2 KF5::Notifications KF5::ConfigCore KF5::ConfigGui KF5::CoreAddons KF5::SonnetCore Quotient cmark::cmark ${QTKEYCHAIN_LIBRARIES}) if(TARGET QCoro5::Coro) target_link_libraries(neochat PUBLIC QCoro5::Coro) else() diff --git a/src/actionshandler.cpp b/src/actionshandler.cpp index 464231f22..955a37cc2 100644 --- a/src/actionshandler.cpp +++ b/src/actionshandler.cpp @@ -3,21 +3,49 @@ #include "actionshandler.h" +#include "controller.h" + +#include +#include + +#include + #include #include +#include "actionsmodel.h" #include "controller.h" #include "customemojimodel.h" +#include "neochatconfig.h" #include "neochatroom.h" #include "roommanager.h" +#include "neochatuser.h" + +using namespace Quotient; + +QString markdownToHTML(const QString &markdown) +{ + const auto str = markdown.toUtf8(); + char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS); + + const std::string html(tmp_buf); + + free(tmp_buf); + + auto result = QString::fromStdString(html).trimmed(); + + result.replace("", ""); + result.replace("

", ""); + result.replace("

", ""); + + return result; +} ActionsHandler::ActionsHandler(QObject *parent) : QObject(parent) { } -ActionsHandler::~ActionsHandler(){}; - NeoChatRoom *ActionsHandler::room() const { return m_room; @@ -33,298 +61,106 @@ void ActionsHandler::setRoom(NeoChatRoom *room) Q_EMIT roomChanged(); } -Connection *ActionsHandler::connection() const +void ActionsHandler::handleMessage() { - return m_connection; -} - -void ActionsHandler::setConnection(Connection *connection) -{ - if (m_connection == connection) { + checkEffects(); + if (!m_room->chatBoxAttachmentPath().isEmpty()) { + auto path = m_room->chatBoxAttachmentPath(); + path = path.mid(path.lastIndexOf('/') + 1); + m_room->uploadFile(m_room->chatBoxAttachmentPath(), m_room->chatBoxText().isEmpty() ? path : m_room->chatBoxText()); + m_room->setChatBoxAttachmentPath({}); + m_room->setChatBoxText({}); return; } - if (m_connection != nullptr) { - disconnect(m_connection, &Connection::directChatAvailable, nullptr, nullptr); - } - m_connection = connection; - if (m_connection != nullptr) { - connect(m_connection, &Connection::directChatAvailable, this, [this](Quotient::Room *room) { - room->setDisplayed(true); - RoomManager::instance().enterRoom(qobject_cast(room)); - }); - } - Q_EMIT connectionChanged(); -} + QString handledText = m_room->chatBoxText(); -void ActionsHandler::postEdit(const QString &text) -{ - const auto localId = Controller::instance().activeConnection()->userId(); - for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); ++it) { - const auto &evt = **it; - if (const auto event = eventCast(&evt)) { - if (event->senderId() == localId && event->hasTextContent()) { - static QRegularExpression re("^s/([^/]*)/([^/]*)(/g)?"); - auto match = re.match(text); - if (!match.hasMatch()) { - // should not happen but still make sure to send the message normally - // just in case. - postMessage(text, QString(), QString(), QString(), QVariantMap(), nullptr); + std::sort(m_room->mentions()->begin(), m_room->mentions()->end(), [](const auto &a, const auto &b) -> bool { + return a.cursor.anchor() > b.cursor.anchor(); + }); + + for (const auto &mention : *m_room->mentions()) { + handledText = handledText.replace(mention.cursor.anchor(), + mention.cursor.position() - mention.cursor.anchor(), + QStringLiteral("[%1](https://matrix.to/#/%2)").arg(mention.text, mention.id)); + } + + if (NeoChatConfig::allowQuickEdit()) { + QRegularExpression sed("^s/([^/]*)/([^/]*)(/g)?$"); + auto match = sed.match(m_room->chatBoxText()); + if (match.hasMatch()) { + const QString regex = match.captured(1); + const QString replacement = match.captured(2); + const QString flags = match.captured(3); + + for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); it++) { + if (const auto event = eventCast(&**it)) { + if (event->senderId() == m_room->localUser()->id() && event->hasTextContent()) { + QString originalString; + if (event->content()) { + originalString = static_cast(event->content())->body; + } else { + originalString = event->plainBody(); + } + if (flags == "/g") { + m_room->postHtmlMessage(handledText, originalString.replace(regex, replacement), event->msgtype(), "", event->id()); + } else { + m_room->postHtmlMessage(handledText, + originalString.replace(originalString.indexOf(regex), regex.size(), replacement), + event->msgtype(), + "", + event->id()); + } + return; + } } - const QString regex = match.captured(1); - const QString replacement = match.captured(2); - const QString flags = match.captured(3); - QString originalString; - if (event->content()) { - originalString = static_cast(event->content())->body; - } else { - originalString = event->plainBody(); - } - if (flags == "/g") { - m_room->postHtmlMessage(text, originalString.replace(regex, replacement), event->msgtype(), "", event->id()); - } else { - m_room->postHtmlMessage(text, - originalString.replace(originalString.indexOf(regex), regex.size(), replacement), - event->msgtype(), - "", - event->id()); - } - return; } } } -} + auto messageType = RoomMessageEvent::MsgType::Text; -void ActionsHandler::postMessage(const QString &text, - const QString &attachmentPath, - const QString &replyEventId, - const QString &editEventId, - const QVariantMap &usernames, - CustomEmojiModel *cem) -{ - QString rawText = text; - QString cleanedText = text; - - auto preprocess = [cem](const QString &it) -> QString { - if (cem == nullptr) { - return it; - } - return cem->preprocessText(it); - }; - - for (auto it = usernames.constBegin(); it != usernames.constEnd(); it++) { - cleanedText = cleanedText.replace(it.key(), "[" + it.key() + "](https://matrix.to/#/" + it.value().toString() + ")"); - } - - if (attachmentPath.length() > 0) { - m_room->uploadFile(attachmentPath, cleanedText); - } - - if (cleanedText.length() == 0) { - return; - } - - auto messageEventType = RoomMessageEvent::MsgType::Text; - - // Message commands - static const QString shrugPrefix = QStringLiteral("/shrug"); - static const QString lennyPrefix = QStringLiteral("/lenny"); - static const QString tableflipPrefix = QStringLiteral("/tableflip"); - static const QString unflipPrefix = QStringLiteral("/unflip"); - // static const QString plainPrefix = QStringLiteral("/plain "); // TODO - // static const QString htmlPrefix = QStringLiteral("/html "); // TODO - static const QString rainbowPrefix = QStringLiteral("/rainbow "); - static const QString rainbowmePrefix = QStringLiteral("/rainbowme "); - static const QString spoilerPrefix = QStringLiteral("/spoiler "); - static const QString mePrefix = QStringLiteral("/me "); - static const QString noticePrefix = QStringLiteral("/notice "); - - // Actions commands - // static const QString ddgPrefix = QStringLiteral("/ddg "); // TODO - // static const QString nickPrefix = QStringLiteral("/nick "); // TODO - // static const QString meroomnickPrefix = QStringLiteral("/myroomnick "); // TODO - // static const QString roomavatarPrefix = QStringLiteral("/roomavatar "); // TODO - // static const QString myroomavatarPrefix = QStringLiteral("/myroomavatar "); // TODO - // static const QString myavatarPrefix = QStringLiteral("/myavatar "); // TODO - static const QString invitePrefix = QStringLiteral("/invite "); - static const QString joinPrefix = QStringLiteral("/join "); - static const QString joinShortPrefix = QStringLiteral("/j "); - static const QString partPrefix = QStringLiteral("/part"); - static const QString leavePrefix = QStringLiteral("/leave"); - static const QString ignorePrefix = QStringLiteral("/ignore "); - static const QString unignorePrefix = QStringLiteral("/unignore "); - // static const QString queryPrefix = QStringLiteral("/query "); // TODO - // static const QString msgPrefix = QStringLiteral("/msg "); // TODO - static const QString reactPrefix = QStringLiteral("/react "); - - // Admin commands - - static QStringList rainbowColors{"#ff2b00", "#ff5500", "#ff8000", "#ffaa00", "#ffd500", "#ffff00", "#d4ff00", "#aaff00", "#80ff00", - "#55ff00", "#2bff00", "#00ff00", "#00ff2b", "#00ff55", "#00ff80", "#00ffaa", "#00ffd5", "#00ffff", - "#00d4ff", "#00aaff", "#007fff", "#0055ff", "#002bff", "#0000ff", "#2a00ff", "#5500ff", "#7f00ff", - "#aa00ff", "#d400ff", "#ff00ff", "#ff00d4", "#ff00aa", "#ff0080", "#ff0055", "#ff002b", "#ff0000"}; - - if (cleanedText.indexOf(shrugPrefix) == 0) { - cleanedText = QStringLiteral("¯\\_(ツ)_/¯") % cleanedText.remove(0, shrugPrefix.length()); - m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId); - return; - } - - if (cleanedText.indexOf(lennyPrefix) == 0) { - cleanedText = QStringLiteral("( ͡° ͜ʖ ͡°)") % cleanedText.remove(0, lennyPrefix.length()); - m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId); - return; - } - - if (cleanedText.indexOf(tableflipPrefix) == 0) { - cleanedText = QStringLiteral("(╯°□°)╯︵ ┻━┻") % cleanedText.remove(0, tableflipPrefix.length()); - m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId); - return; - } - - if (cleanedText.indexOf(unflipPrefix) == 0) { - cleanedText = QStringLiteral("┬──┬ ノ( ゜-゜ノ)") % cleanedText.remove(0, unflipPrefix.length()); - m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId); - return; - } - - if (cleanedText.indexOf(rainbowPrefix) == 0) { - cleanedText = cleanedText.remove(0, rainbowPrefix.length()); - QString rainbowText; - for (int i = 0; i < cleanedText.length(); i++) { - rainbowText = rainbowText % QStringLiteral("" % cleanedText.at(i) % ""; - } - m_room->postHtmlMessage(cleanedText, preprocess(rainbowText), RoomMessageEvent::MsgType::Notice, replyEventId, editEventId); - return; - } - - if (cleanedText.indexOf(spoilerPrefix) == 0) { - cleanedText = cleanedText.remove(0, spoilerPrefix.length()); - const QStringList splittedText = rawText.split(" "); - QString spoilerHtml = QStringLiteral("") % preprocess(cleanedText) % QStringLiteral(""); - m_room->postHtmlMessage(cleanedText, spoilerHtml, RoomMessageEvent::MsgType::Notice, replyEventId, editEventId); - return; - } - - if (cleanedText.indexOf(rainbowmePrefix) == 0) { - cleanedText = cleanedText.remove(0, rainbowmePrefix.length()); - QString rainbowText; - for (int i = 0; i < cleanedText.length(); i++) { - rainbowText = rainbowText % QStringLiteral("" % cleanedText.at(i) % ""; - } - m_room->postHtmlMessage(cleanedText, preprocess(rainbowText), messageEventType, replyEventId, editEventId); - return; - } - - if (rawText.indexOf(joinPrefix) == 0 || rawText.indexOf(joinShortPrefix) == 0) { - if (rawText.indexOf(joinPrefix) == 0) { - rawText = rawText.remove(0, joinPrefix.length()); - } else { - rawText = rawText.remove(0, joinShortPrefix.length()); - } - const QStringList splittedText = rawText.split(" "); - if (text.count() == 0) { - Q_EMIT showMessage(MessageType::Error, i18n("Invalid command")); - return; - } - if (splittedText.count() > 1) { - Controller::instance().joinRoom(splittedText[0] + ":" + splittedText[1]); - return; - } else if (splittedText[0].indexOf(":") != -1) { - Controller::instance().joinRoom(splittedText[0]); - return; - } else { - Controller::instance().joinRoom(splittedText[0] + ":matrix.org"); - } - return; - } - - if (rawText.indexOf(invitePrefix) == 0) { - rawText = rawText.remove(0, invitePrefix.length()); - const QStringList splittedText = rawText.split(" "); - if (splittedText.count() == 0) { - Q_EMIT showMessage(MessageType::Error, i18n("Invalid command")); - return; - } - m_room->inviteToRoom(splittedText[0]); - return; - } - - if (rawText.indexOf(partPrefix) == 0 || rawText.indexOf(leavePrefix) == 0) { - if (rawText.indexOf(partPrefix) == 0) { - rawText = rawText.remove(0, partPrefix.length()); - } else { - rawText = rawText.remove(0, leavePrefix.length()); - } - const QStringList splittedText = rawText.split(" "); - if (splittedText.count() == 0 || splittedText[0].isEmpty()) { - // leave current room - m_connection->leaveRoom(m_room); - return; - } - m_connection->leaveRoom(m_connection->room(splittedText[0])); - return; - } - - if (rawText.indexOf(ignorePrefix) == 0) { - rawText = rawText.remove(0, ignorePrefix.length()); - const QStringList splittedText = rawText.split(" "); - if (splittedText.count() == 0) { - Q_EMIT showMessage(MessageType::Error, i18n("Invalid command")); - return; - } - - if (m_connection->users().contains(splittedText[0])) { - Q_EMIT showMessage(MessageType::Error, i18n("Invalid command")); - return; - } - - const auto *user = m_connection->users()[splittedText[0]]; - m_connection->addToIgnoredUsers(user); - return; - } - - if (rawText.indexOf(unignorePrefix) == 0) { - rawText = rawText.remove(0, unignorePrefix.length()); - const QStringList splittedText = rawText.split(" "); - if (splittedText.count() == 0) { - Q_EMIT showMessage(MessageType::Error, i18n("Invalid command")); - return; - } - - if (m_connection->users().contains(splittedText[0])) { - Q_EMIT showMessage(MessageType::Error, i18n("Invalid command")); - return; - } - - const auto *user = m_connection->users()[splittedText[0]]; - m_connection->removeFromIgnoredUsers(user); - return; - } - - if (rawText.indexOf(reactPrefix) == 0) { - rawText = rawText.remove(0, reactPrefix.length()); - if (replyEventId.isEmpty()) { - for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); ++it) { - const auto &evt = **it; - if (const auto event = eventCast(&evt)) { - m_room->toggleReaction(event->id(), rawText); + if (handledText.startsWith(QLatin1Char('/'))) { + for (const auto &action : ActionsModel::instance().allActions()) { + if (handledText.indexOf(action.prefix) == 1 + && (handledText.indexOf(" ") == action.prefix.length() + 1 || handledText.length() == action.prefix.length() + 1)) { + handledText = action.handle(handledText.mid(action.prefix.length() + 1).trimmed(), m_room); + if (action.messageType.has_value()) { + messageType = *action.messageType; + } + if (action.messageAction) { + break; + } else { return; } } - Q_EMIT showMessage(MessageType::Error, i18n("Couldn't find a message to react to")); - return; } - m_room->toggleReaction(replyEventId, rawText); + } + + handledText = markdownToHTML(handledText); + handledText = CustomEmojiModel::instance().preprocessText(handledText); + + if (handledText.length() == 0) { return; } - if (cleanedText.indexOf(mePrefix) == 0) { - cleanedText = cleanedText.remove(0, mePrefix.length()); - messageEventType = RoomMessageEvent::MsgType::Emote; - rawText = rawText.remove(0, mePrefix.length()); - } else if (cleanedText.indexOf(noticePrefix) == 0) { - cleanedText = cleanedText.remove(0, noticePrefix.length()); - messageEventType = RoomMessageEvent::MsgType::Notice; - } - m_room->postMessage(rawText, preprocess(m_room->preprocessText(cleanedText)), messageEventType, replyEventId, editEventId); + m_room->postMessage(m_room->chatBoxText(), handledText, messageType, m_room->chatBoxReplyId(), m_room->chatBoxEditId()); +} + +void ActionsHandler::checkEffects() +{ + std::optional effect = std::nullopt; + const auto &text = m_room->chatBoxText(); + if (text.contains("\u2744")) { + effect = QLatin1String("snowflake"); + } else if (text.contains("\u1F386")) { + effect = QLatin1String("fireworks"); + } else if (text.contains("\u2F387")) { + effect = QLatin1String("fireworks"); + } else if (text.contains("\u1F389")) { + effect = QLatin1String("confetti"); + } else if (text.contains("\u1F38A")) { + effect = QLatin1String("confetti"); + } + if (effect.has_value()) { + Q_EMIT showEffect(*effect); + } } diff --git a/src/actionshandler.h b/src/actionshandler.h index e6e45efa8..6daf7a655 100644 --- a/src/actionshandler.h +++ b/src/actionshandler.h @@ -5,13 +5,11 @@ #include -namespace Quotient -{ -class Connection; -} +#include class NeoChatRoom; class CustomEmojiModel; +class NeoChatRoom; /// \brief Handles user interactions with NeoChat (joining room, creating room, /// sending message). Account management is handled by Controller. @@ -19,56 +17,29 @@ class ActionsHandler : public QObject { Q_OBJECT - /// \brief The connection that will handle sending the message. - Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged) - - /// \brief The connection that will handle sending the message. + /// \brief The room that messages will be sent to. Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) public: - enum MessageType { - Info, - Error, - }; - Q_ENUM(MessageType); - explicit ActionsHandler(QObject *parent = nullptr); - ~ActionsHandler(); - - [[nodiscard]] Quotient::Connection *connection() const; - void setConnection(Quotient::Connection *connection); [[nodiscard]] NeoChatRoom *room() const; void setRoom(NeoChatRoom *room); Q_SIGNALS: - /// \brief Show error or information message. - /// - /// These messages will be displayed in the room view header. - void showMessage(ActionsHandler::MessageType messageType, QString message); - void roomChanged(); - void connectionChanged(); + void showEffect(QString effect); public Q_SLOTS: /// \brief Post a message. /// /// This also interprets commands if any. - void postMessage(const QString &text, - const QString &attachementPath, - const QString &replyEventId, - const QString &editEventId, - const QVariantMap &usernames, - CustomEmojiModel *cem); - - /// \brief Send edit instructions (.e.g s/hallo/hello/) - /// - /// This will automatically edit the last message posted and send the sed - /// instruction to IRC. - void postEdit(const QString &text); + void handleMessage(); private: - Quotient::Connection *m_connection = nullptr; NeoChatRoom *m_room = nullptr; + void checkEffects(); }; + +QString markdownToHTML(const QString &markdown); diff --git a/src/actionsmodel.cpp b/src/actionsmodel.cpp new file mode 100644 index 000000000..3070a2454 --- /dev/null +++ b/src/actionsmodel.cpp @@ -0,0 +1,401 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "actionsmodel.h" + +#include "controller.h" +#include "neochatroom.h" +#include "neochatuser.h" +#include + +#include + +using Action = ActionsModel::Action; +using namespace Quotient; + +QStringList rainbowColors{"#ff2b00", "#ff5500", "#ff8000", "#ffaa00", "#ffd500", "#ffff00", "#d4ff00", "#aaff00", "#80ff00", "#55ff00", "#2bff00", "#00ff00", + "#00ff2b", "#00ff55", "#00ff80", "#00ffaa", "#00ffd5", "#00ffff", "#00d4ff", "#00aaff", "#007fff", "#0055ff", "#002bff", "#0000ff", + "#2a00ff", "#5500ff", "#7f00ff", "#aa00ff", "#d400ff", "#ff00ff", "#ff00d4", "#ff00aa", "#ff0080", "#ff0055", "#ff002b", "#ff0000"}; + +QVector actions{ + Action{ + QStringLiteral("shrug"), + [](const QString &message, NeoChatRoom *) { + return QStringLiteral("¯\\\\_(ツ)_/¯ %1").arg(message); + }, + true, + std::nullopt, + kli18n(""), + kli18n("Prepends ¯\\_(ツ)_/¯ to a plain-text message"), + }, + Action{ + QStringLiteral("lenny"), + [](const QString &message, NeoChatRoom *) { + return QStringLiteral("( ͡° ͜ʖ ͡°) %1").arg(message); + }, + true, + std::nullopt, + kli18n(""), + kli18n("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"), + }, + Action{ + QStringLiteral("tableflip"), + [](const QString &message, NeoChatRoom *) { + return QStringLiteral("(╯°□°)╯︵ ┻━┻ %1").arg(message); + }, + true, + std::nullopt, + kli18n(""), + kli18n("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"), + }, + Action{ + QStringLiteral("unflip"), + [](const QString &message, NeoChatRoom *) { + return QStringLiteral("┬──┬ ノ( ゜-゜ノ) %1").arg(message); + }, + true, + std::nullopt, + kli18n(""), + kli18n("Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message"), + }, + Action{ + QStringLiteral("rainbow"), + [](const QString &text, NeoChatRoom *room) { + QString rainbowText; + for (int i = 0; i < text.length(); i++) { + rainbowText += QStringLiteral("%3").arg(rainbowColors[i % rainbowColors.length()], text.at(i)); + } + // Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML. + room->postMessage(QStringLiteral("/rainbow %1").arg(text), + rainbowText, + RoomMessageEvent::MsgType::Text, + room->chatBoxReplyId(), + room->chatBoxEditId()); + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("Sends the given message colored as a rainbow"), + }, + Action{ + QStringLiteral("rainbowme"), + [](const QString &text, NeoChatRoom *room) { + QString rainbowText; + for (int i = 0; i < text.length(); i++) { + rainbowText += QStringLiteral("%3").arg(rainbowColors[i % rainbowColors.length()], text.at(i)); + } + // Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML. + room->postMessage(QStringLiteral("/rainbow %1").arg(text), + rainbowText, + RoomMessageEvent::MsgType::Emote, + room->chatBoxReplyId(), + room->chatBoxEditId()); + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("Sends the given emote colored as a rainbow"), + }, + Action{ + QStringLiteral("spoiler"), + [](const QString &text, NeoChatRoom *room) { + // Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML. + room->postMessage(QStringLiteral("/rainbow %1").arg(text), + QStringLiteral("%1").arg(text), + RoomMessageEvent::MsgType::Text, + room->chatBoxReplyId(), + room->chatBoxEditId()); + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("Sends the given message as a spoiler"), + }, + Action{ + QStringLiteral("me"), + [](const QString &text, NeoChatRoom *) { + return text; + }, + true, + RoomMessageEvent::MsgType::Emote, + kli18n(""), + kli18n("Sends the given emote"), + }, + Action{ + QStringLiteral("notice"), + [](const QString &text, NeoChatRoom *) { + return text; + }, + true, + RoomMessageEvent::MsgType::Notice, + kli18n(""), + kli18n("Sends the given message as a notice"), + }, + Action{ + QStringLiteral("invite"), + [](const QString &text, NeoChatRoom *room) { + static const QRegularExpression mxidRegex( + QStringLiteral(R"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))")); + auto regexMatch = mxidRegex.match(text); + if (!regexMatch.hasMatch()) { + Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", text)); + return QString(); + } +#ifdef QUOTIENT_07 + if (room->currentState().get(text)->membership() == Membership::Invite) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc(" is already invited to this room.", "%1 is already invited to this room.", text)); + return QString(); + } + if (room->currentState().get(text)->membership() == Membership::Ban) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc(" is banned from this room.", "%1 is banned from this room.", text)); + return QString(); + } +#endif + if (room->localUser()->id() == text) { + Q_EMIT room->showMessage(NeoChatRoom::Positive, i18n("You are already in this room.")); + return QString(); + } + if (room->users().contains(room->user(text))) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc(" is already in this room.", "%1 is already in this room.", text)); + return QString(); + } + room->inviteToRoom(text); + Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc(" was invited into this room", "%1 was invited into this room", text)); + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("Invites the user to this room"), + }, + Action{ + QStringLiteral("join"), + [](const QString &text, NeoChatRoom *room) { + QRegularExpression roomRegex(QStringLiteral(R"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)")); + auto regexMatch = roomRegex.match(text); + if (!regexMatch.hasMatch()) { + Q_EMIT room->showMessage(NeoChatRoom::Error, + i18nc("'' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text)); + return QString(); + } + if (Controller::instance().activeConnection()->room(text) || Controller::instance().activeConnection()->roomByAlias(text)) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("You are already in room .", "You are already in room %1.", text)); + return QString(); + } + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Joining room .", "Joining room %1.", text)); + Controller::instance().joinRoom(text); + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("Joins the given room"), + }, + Action{ + QStringLiteral("j"), + [](const QString &text, NeoChatRoom *room) { + QRegularExpression roomRegex(QStringLiteral(R"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)")); + auto regexMatch = roomRegex.match(text); + if (!regexMatch.hasMatch()) { + Q_EMIT room->showMessage(NeoChatRoom::Error, + i18nc("'' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text)); + return QString(); + } + if (Controller::instance().activeConnection()->room(text) || Controller::instance().activeConnection()->roomByAlias(text)) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("You are already in room .", "You are already in room %1.", text)); + return QString(); + } + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Joining room .", "Joining room %1.", text)); + Controller::instance().joinRoom(text); + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("Joins the given room"), + }, + Action{ + QStringLiteral("part"), + [](const QString &text, NeoChatRoom *room) { + if (text.isEmpty()) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18n("Leaving this room.")); + room->connection()->leaveRoom(room); + } else { + QRegularExpression roomRegex(QStringLiteral(R"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)")); + auto regexMatch = roomRegex.match(text); + if (!regexMatch.hasMatch()) { + Q_EMIT room->showMessage(NeoChatRoom::Error, + i18nc("'' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text)); + return QString(); + } + auto leaving = room->connection()->room(text); + if (!leaving) { + leaving = room->connection()->roomByAlias(text); + } + if (leaving) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Leaving room .", "Leaving room %1.", text)); + room->connection()->leaveRoom(leaving); + } else { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Room not found", "Room %1 not found.", text)); + } + } + return QString(); + }, + false, + std::nullopt, + kli18n("[]"), + kli18n("Leaves the given room or this room, if there is none given"), + }, + Action{ + QStringLiteral("leave"), + [](const QString &text, NeoChatRoom *room) { + if (text.isEmpty()) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18n("Leaving this room.")); + room->connection()->leaveRoom(room); + } else { + QRegularExpression roomRegex(QStringLiteral(R"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)")); + auto regexMatch = roomRegex.match(text); + if (!regexMatch.hasMatch()) { + Q_EMIT room->showMessage(NeoChatRoom::Error, + i18nc("'' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text)); + return QString(); + } + auto leaving = room->connection()->room(text); + if (!leaving) { + leaving = room->connection()->roomByAlias(text); + } + if (leaving) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Leaving room .", "Leaving room %1.", text)); + room->connection()->leaveRoom(leaving); + } else { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Room not found", "Room %1 not found.", text)); + } + } + return QString(); + }, + false, + std::nullopt, + kli18n("[]"), + kli18n("Leaves the given room or this room, if there is none given"), + }, + Action{ + QStringLiteral("ignore"), + [](const QString &text, NeoChatRoom *room) { + static const QRegularExpression mxidRegex( + QStringLiteral(R"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))")); + auto regexMatch = mxidRegex.match(text); + if (!regexMatch.hasMatch()) { + Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", text)); + return QString(); + } + auto user = room->connection()->users()[text]; + if (room->connection()->ignoredUsers().contains(user->id())) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc(" is already ignored.", "%1 is already ignored.", text)); + return QString(); + } + if (user) { + room->connection()->addToIgnoredUsers(user); + Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc(" is now ignored", "%1 is now ignored.", text)); + } else { + Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc(" is not a known user", "%1 is not a known user.", text)); + } + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("Ignores the given user"), + }, + Action{ + QStringLiteral("unignore"), + [](const QString &text, NeoChatRoom *room) { + static const QRegularExpression mxidRegex( + QStringLiteral(R"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))")); + auto regexMatch = mxidRegex.match(text); + if (!regexMatch.hasMatch()) { + Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", text)); + return QString(); + } + auto user = room->connection()->users()[text]; + if (user) { + if (!room->connection()->ignoredUsers().contains(user->id())) { + Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc(" is not ignored.", "%1 is not ignored.", text)); + return QString(); + } + room->connection()->removeFromIgnoredUsers(user); + Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc(" is no longer ignored.", "%1 is no longer ignored.", text)); + } else { + Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc(" is not a known user", "%1 is not a known user.", text)); + } + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("Unignores the given user"), + }, + Action{ + QStringLiteral("react"), + [](const QString &text, NeoChatRoom *room) { + QString replyEventId = room->chatBoxReplyId(); + if (replyEventId.isEmpty()) { + for (auto it = room->messageEvents().crbegin(); it != room->messageEvents().crend(); it++) { + const auto &evt = **it; + if (const auto event = eventCast(&evt)) { + room->toggleReaction(event->id(), text); + return QString(); + } + } + } + room->toggleReaction(replyEventId, text); + return QString(); + }, + false, + std::nullopt, + kli18n(""), + kli18n("React to the message with the given text"), + }, +}; + +int ActionsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return actions.size(); +} + +QVariant ActionsModel::data(const QModelIndex &index, int role) const +{ + if (index.row() < 0 || index.row() >= actions.size()) { + return {}; + } + if (role == Prefix) { + return actions[index.row()].prefix; + } + if (role == Description) { + return actions[index.row()].description.toString(); + } + if (role == CompletionType) { + return QStringLiteral("action"); + } + if (role == Parameters) { + return actions[index.row()].parameters.toString(); + } + return {}; +} + +QHash ActionsModel::roleNames() const +{ + return { + {Prefix, "prefix"}, + {Description, "description"}, + {CompletionType, "completionType"}, + }; +} + +QVector &ActionsModel::allActions() const +{ + return actions; +} diff --git a/src/actionsmodel.h b/src/actionsmodel.h new file mode 100644 index 000000000..df8aace2b --- /dev/null +++ b/src/actionsmodel.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include +#include +#include + +class NeoChatRoom; + +class ActionsModel : public QAbstractListModel +{ +public: + struct Action { + // The prefix, without '/' and space after the word + QString prefix; + std::function handle; + // If this is true, this action transforms a message to a different message and it will be sent. + // If this is false, this message does some action on the client and should not be sent as a message. + bool messageAction; + // If this action changes the message type, this is the new message type. Otherwise it's nullopt + std::optional messageType = std::nullopt; + KLazyLocalizedString parameters; + KLazyLocalizedString description; + }; + static ActionsModel &instance() + { + static ActionsModel _instance; + return _instance; + } + + enum Roles { + Prefix = Qt::DisplayRole, + Description, + CompletionType, + Parameters, + }; + Q_ENUM(Roles); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + QVector &allActions() const; + +private: + ActionsModel() = default; +}; diff --git a/src/chatboxhelper.h b/src/chatboxhelper.h deleted file mode 100644 index 6d3ddaeb6..000000000 --- a/src/chatboxhelper.h +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Carl Schwan -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include - -/// Helper singleton for keeping the chatbar state in sync in the application. -class ChatBoxHelper : public QObject -{ - Q_OBJECT - /// True, iff the user is currently editing one of their previous message. - Q_PROPERTY(bool isEditing READ isEditing NOTIFY isEditingChanged) - Q_PROPERTY(QString editEventId READ editEventId WRITE setEditEventId NOTIFY editEventIdChanged) - Q_PROPERTY(QString editContent READ editContent WRITE setEditContent NOTIFY editContentChanged) - - Q_PROPERTY(bool isReplying READ isReplying NOTIFY isReplyingChanged) - Q_PROPERTY(QString replyEventId READ replyEventId WRITE setReplyEventId NOTIFY replyEventIdChanged) - Q_PROPERTY(QString replyEventContent READ replyEventContent WRITE setReplyEventContent NOTIFY replyEventContentChanged) - Q_PROPERTY(QVariant replyUser READ replyUser WRITE setReplyUser NOTIFY replyUserChanged) - - Q_PROPERTY(QString attachmentPath READ attachmentPath WRITE setAttachmentPath NOTIFY attachmentPathChanged) - Q_PROPERTY(bool hasAttachment READ hasAttachment NOTIFY hasAttachmentChanged) - -public: - ChatBoxHelper(QObject *parent = nullptr); - ~ChatBoxHelper() = default; - - bool isEditing() const; - QString editEventId() const; - QString editContent() const; - - QString replyEventId() const; - QString replyEventContent() const; - QVariant replyUser() const; - bool isReplying() const; - - QString attachmentPath() const; - bool hasAttachment() const; - - void setEditEventId(const QString &editEventId); - void setEditContent(const QString &editContent); - void setReplyEventId(const QString &replyEventId); - void setReplyEventContent(const QString &replyEventContent); - void setAttachmentPath(const QString &attachmentPath); - void setReplyUser(const QVariant &replyUser); - - Q_INVOKABLE void replyToMessage(const QString &replyEventid, const QString &replyEvent, const QVariant &replyUser); - Q_INVOKABLE void edit(const QString &message, const QString &formattedBody, const QString &eventId); - Q_INVOKABLE void clear(); - Q_INVOKABLE void clearEditReply(); - Q_INVOKABLE void clearAttachment(); - -Q_SIGNALS: - void isEditingChanged(bool isEditing); - void editEventIdChanged(const QString &editEventId); - void editContentChanged(); - void replyEventIdChanged(const QString &replyEventId); - void replyEventContentChanged(const QString &replyEventContent); - void replyUserChanged(); - void isReplyingChanged(bool isReplying); - void attachmentPathChanged(const QString &attachmentPath); - void hasAttachmentChanged(bool hasAttachment); - void editing(const QString &message, const QString &formattedBody); - void shouldClearText(); - -private: - QString m_editEventId; - QString m_editContent; - QString m_replyEventId; - QString m_replyEventContent; - QVariant m_replyUser; - QString m_attachmentPath; -}; diff --git a/src/chatdocumenthandler.cpp b/src/chatdocumenthandler.cpp index 4a88b4d9f..4b8a07809 100644 --- a/src/chatdocumenthandler.cpp +++ b/src/chatdocumenthandler.cpp @@ -7,18 +7,138 @@ #include #include #include +#include #include #include +#include +#include + +#include "actionsmodel.h" +#include "completionmodel.h" #include "neochatroom.h" +#include "roomlistmodel.h" + +class SyntaxHighlighter : public QSyntaxHighlighter +{ +public: + QTextCharFormat mentionFormat; + QTextCharFormat errorFormat; + Sonnet::BackgroundChecker *checker = new Sonnet::BackgroundChecker; + Sonnet::Settings settings; + QList> errors; + QString previousText; + SyntaxHighlighter(QObject *parent) + : QSyntaxHighlighter(parent) + { + mentionFormat.setFontWeight(QFont::Bold); + mentionFormat.setForeground(Qt::blue); + + errorFormat.setForeground(Qt::red); + errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline); + + connect(checker, &Sonnet::BackgroundChecker::misspelling, this, [this](const QString &word, int start) { + errors += {start, word}; + rehighlight(); + checker->continueChecking(); + }); + } + void highlightBlock(const QString &text) override + { + if (settings.checkerEnabledByDefault()) { + if (text != previousText) { + previousText = text; + checker->stop(); + errors.clear(); + checker->setText(text); + } + for (const auto &error : errors) { + setFormat(error.first, error.second.size(), errorFormat); + } + } + auto room = dynamic_cast(parent())->room(); + if (!room) { + return; + } + auto mentions = room->mentions(); + mentions->erase(std::remove_if(mentions->begin(), + mentions->end(), + [this](auto &mention) { + if (document()->toPlainText().isEmpty()) { + return false; + } + + if (mention.cursor.position() == 0 && mention.cursor.anchor() == 0) { + return true; + } + + if (mention.cursor.position() - mention.cursor.anchor() != mention.text.size()) { + mention.cursor.setPosition(mention.start); + mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor); + } + + if (mention.cursor.selectedText() != mention.text) { + return true; + } + if (currentBlock() == mention.cursor.block()) { + mention.start = mention.cursor.anchor(); + mention.position = mention.cursor.position(); + setFormat(mention.cursor.selectionStart(), mention.cursor.selectedText().size(), mentionFormat); + } + return false; + }), + mentions->end()); + } +}; ChatDocumentHandler::ChatDocumentHandler(QObject *parent) : QObject(parent) , m_document(nullptr) , m_cursorPosition(-1) - , m_selectionStart(-1) - , m_selectionEnd(-1) + , m_highlighter(new SyntaxHighlighter(this)) + , m_completionModel(new CompletionModel()) { + connect(this, &ChatDocumentHandler::roomChanged, this, [this]() { + m_completionModel->setRoom(m_room); + static NeoChatRoom *previousRoom = nullptr; + if (previousRoom) { + disconnect(nullptr, &NeoChatRoom::chatBoxTextChanged, this, nullptr); + } + previousRoom = m_room; + connect(m_room, &NeoChatRoom::chatBoxTextChanged, this, [this]() { + int start = completionStartIndex(); + m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start)); + }); + }); + connect(this, &ChatDocumentHandler::documentChanged, this, [this]() { + m_highlighter->setDocument(m_document->textDocument()); + }); + connect(this, &ChatDocumentHandler::cursorPositionChanged, this, [this]() { + if (!m_room) { + return; + } + int start = completionStartIndex(); + m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start)); + }); +} + +int ChatDocumentHandler::completionStartIndex() const +{ + if (!m_room) { + return 0; + } + + const auto &cursor = cursorPosition(); + const auto &text = m_room->chatBoxText(); + auto start = std::min(cursor, text.size()) - 1; + while (start > -1) { + if (text.at(start) == QLatin1Char(' ')) { + start++; + break; + } + start--; + } + return start; } QQuickTextDocument *ChatDocumentHandler::document() const @@ -49,67 +169,12 @@ void ChatDocumentHandler::setCursorPosition(int position) if (position == m_cursorPosition) { return; } - - m_cursorPosition = position; + if (m_room) { + m_cursorPosition = position; + } Q_EMIT cursorPositionChanged(); } -int ChatDocumentHandler::selectionStart() const -{ - return m_selectionStart; -} - -void ChatDocumentHandler::setSelectionStart(int position) -{ - if (position == m_selectionStart) { - return; - } - - m_selectionStart = position; - Q_EMIT selectionStartChanged(); -} - -int ChatDocumentHandler::selectionEnd() const -{ - return m_selectionEnd; -} - -void ChatDocumentHandler::setSelectionEnd(int position) -{ - if (position == m_selectionEnd) { - return; - } - - m_selectionEnd = position; - Q_EMIT selectionEndChanged(); -} - -QTextCursor ChatDocumentHandler::textCursor() const -{ - QTextDocument *doc = textDocument(); - if (!doc) { - return QTextCursor(); - } - - QTextCursor cursor = QTextCursor(doc); - if (m_selectionStart != m_selectionEnd) { - cursor.setPosition(m_selectionStart); - cursor.setPosition(m_selectionEnd, QTextCursor::KeepAnchor); - } else { - cursor.setPosition(m_cursorPosition); - } - return cursor; -} - -QTextDocument *ChatDocumentHandler::textDocument() const -{ - if (!m_document) { - return nullptr; - } - - return m_document->textDocument(); -} - NeoChatRoom *ChatDocumentHandler::room() const { return m_room; @@ -125,91 +190,55 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room) Q_EMIT roomChanged(); } -QVariantMap ChatDocumentHandler::getAutocompletionInfo(bool isAutocompleting) +void ChatDocumentHandler::complete(int index) { - QTextCursor cursor = textCursor(); - - if (cursor.block().text() == m_lastState) { - // ignore change, it was caused by autocompletion - return QVariantMap{ - {"type", AutoCompletionType::Ignore}, - }; + if (m_completionModel->autoCompletionType() == ChatDocumentHandler::User) { + auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString(); + auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString(); + auto text = m_room->chatBoxText(); + auto at = text.lastIndexOf(QLatin1Char('@'), cursorPosition() - 1); + QTextCursor cursor(document()->textDocument()); + cursor.setPosition(at); + cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor); + cursor.insertText(name % " "); + cursor.setPosition(at); + cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor); + cursor.setKeepPositionOnInsert(true); + m_room->mentions()->push_back({cursor, name, 0, 0, id}); + m_highlighter->rehighlight(); + } else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Command) { + auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString(); + auto text = m_room->chatBoxText(); + auto at = text.lastIndexOf(QLatin1Char('/')); + QTextCursor cursor(document()->textDocument()); + cursor.setPosition(at); + cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor); + cursor.insertText(QStringLiteral("/%1 ").arg(command)); + } else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Room) { + auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString(); + auto text = m_room->chatBoxText(); + auto at = text.lastIndexOf(QLatin1Char('#'), cursorPosition() - 1); + QTextCursor cursor(document()->textDocument()); + cursor.setPosition(at); + cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor); + cursor.insertText(alias % " "); + cursor.setPosition(at); + cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor); + cursor.setKeepPositionOnInsert(true); + m_room->mentions()->push_back({cursor, alias, 0, 0, alias}); + m_highlighter->rehighlight(); + } else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Emoji) { + auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString(); + auto text = m_room->chatBoxText(); + auto at = text.lastIndexOf(QLatin1Char(':')); + QTextCursor cursor(document()->textDocument()); + cursor.setPosition(at); + cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor); + cursor.insertText(shortcode); } - - QString text = cursor.block().text(); - QString textBeforeCursor = text; - textBeforeCursor.truncate(m_cursorPosition); - - QString autoCompletePrefix = textBeforeCursor.section(" ", -1); - - if (autoCompletePrefix.isEmpty()) { - return QVariantMap{ - {"type", AutoCompletionType::None}, - }; - } - - if (autoCompletePrefix.startsWith("@") || autoCompletePrefix.startsWith(":") || autoCompletePrefix.startsWith("/")) { - m_autoCompleteBeginPosition = textBeforeCursor.lastIndexOf(" ") + 1; // 1 == space - - if (autoCompletePrefix.startsWith("@")) { - autoCompletePrefix.remove(0, 1); - return QVariantMap{ - {"keyword", autoCompletePrefix}, - {"type", AutoCompletionType::User}, - }; - } - - if (autoCompletePrefix.startsWith("/") && text.trimmed().length() <= 1) { - return QVariantMap{ - {"keyword", autoCompletePrefix}, - {"type", AutoCompletionType::Command}, - }; - } - - if (!isAutocompleting) { - return QVariantMap{ - {"keyword", autoCompletePrefix}, - {"type", AutoCompletionType::Emoji}, - }; - } else { - return QVariantMap{ - {"type", AutoCompletionType::Ignore}, - {"keyword", autoCompletePrefix}, - }; - } - } - - return QVariantMap{ - {"type", AutoCompletionType::None}, - }; } -void ChatDocumentHandler::replaceAutoComplete(const QString &word) +CompletionModel *ChatDocumentHandler::completionModel() const { - QTextCursor cursor = textCursor(); - if (cursor.block().text() == m_lastState) { - m_document->textDocument()->undo(); - } - cursor.beginEditBlock(); - cursor.select(QTextCursor::WordUnderCursor); - cursor.removeSelectedText(); - cursor.deletePreviousChar(); - while (!cursor.atBlockStart()) { - cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); - - if (cursor.selectedText() == " ") { - cursor.movePosition(QTextCursor::NextCharacter); - break; - } - } - - cursor.insertHtml(word); - - // Add space after autocomplete if not already there - if (!cursor.block().text().endsWith(QStringLiteral(" "))) { - cursor.insertText(QStringLiteral(" ")); - } - - m_lastState = cursor.block().text(); - cursor.endEditBlock(); + return m_completionModel; } diff --git a/src/chatdocumenthandler.h b/src/chatdocumenthandler.h index 448576242..4147760d9 100644 --- a/src/chatdocumenthandler.h +++ b/src/chatdocumenthandler.h @@ -4,30 +4,33 @@ #pragma once #include + #include +#include "userlistmodel.h" + class QTextDocument; class QQuickTextDocument; class NeoChatRoom; -class Controller; +class SyntaxHighlighter; +class CompletionModel; class ChatDocumentHandler : public QObject { Q_OBJECT Q_PROPERTY(QQuickTextDocument *document READ document WRITE setDocument NOTIFY documentChanged) Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged) - Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged) - Q_PROPERTY(int selectionEnd READ selectionEnd WRITE setSelectionEnd NOTIFY selectionEndChanged) + Q_PROPERTY(CompletionModel *completionModel READ completionModel NOTIFY completionModelChanged) - Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + Q_PROPERTY(NeoChatRoom *room READ room NOTIFY roomChanged) public: enum AutoCompletionType { User, + Room, Emoji, Command, None, - Ignore, }; Q_ENUM(AutoCompletionType) @@ -39,44 +42,34 @@ public: [[nodiscard]] int cursorPosition() const; void setCursorPosition(int position); - [[nodiscard]] int selectionStart() const; - void setSelectionStart(int position); - - [[nodiscard]] int selectionEnd() const; - void setSelectionEnd(int position); - [[nodiscard]] NeoChatRoom *room() const; void setRoom(NeoChatRoom *room); - /// This function will look at the current QTextCursor and determine if there - /// is the possibility to autocomplete it. - Q_INVOKABLE QVariantMap getAutocompletionInfo(bool isAutocompleting); - Q_INVOKABLE void replaceAutoComplete(const QString &word); + Q_INVOKABLE void complete(int index); + void updateCompletions(); + CompletionModel *completionModel() const; Q_SIGNALS: void documentChanged(); void cursorPositionChanged(); - void selectionStartChanged(); - void selectionEndChanged(); void roomChanged(); - void joinRoom(QString roomName); + void completionModelChanged(); private: - [[nodiscard]] QTextCursor textCursor() const; - [[nodiscard]] QTextDocument *textDocument() const; + int completionStartIndex() const; QQuickTextDocument *m_document; - NeoChatRoom *m_room; + NeoChatRoom *m_room = nullptr; + bool completionVisible = false; int m_cursorPosition; - int m_selectionStart; - int m_selectionEnd; - int m_autoCompleteBeginPosition = -1; - int m_autoCompleteEndPosition = -1; + SyntaxHighlighter *m_highlighter = nullptr; - QString m_lastState; + AutoCompletionType m_completionType = None; + + CompletionModel *m_completionModel = nullptr; }; Q_DECLARE_METATYPE(ChatDocumentHandler::AutoCompletionType); diff --git a/src/clipboard.cpp b/src/clipboard.cpp index d8e23a310..1d4756136 100644 --- a/src/clipboard.cpp +++ b/src/clipboard.cpp @@ -4,12 +4,14 @@ #include "clipboard.h" #include +#include #include #include #include #include #include #include +#include #include Clipboard::Clipboard(QObject *parent) @@ -29,27 +31,31 @@ QImage Clipboard::image() const return m_clipboard->image(); } -bool Clipboard::saveImage(const QUrl &localPath) const +QString Clipboard::saveImage(QString localPath) const { - if (!localPath.isLocalFile()) { - return false; + if (localPath.isEmpty()) { + localPath = QStringLiteral("file://%1/screenshots/%2.png") + .arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation), + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-hh-mm-ss"))); + } + QUrl url(localPath); + if (!url.isLocalFile()) { + return {}; + } + auto image = this->image(); + + if (image.isNull()) { + return {}; } - auto i = image(); - - if (i.isNull()) { - return false; - } - - QString path = QFileInfo(localPath.toLocalFile()).absolutePath(); QDir dir; - if (!dir.exists(path)) { - dir.mkpath(path); + if (!dir.exists(localPath)) { + dir.mkpath(localPath); } - i.save(localPath.toLocalFile()); + image.save(url.toLocalFile()); - return true; + return localPath; } void Clipboard::saveText(QString message) diff --git a/src/clipboard.h b/src/clipboard.h index 8b9728465..32d884cf1 100644 --- a/src/clipboard.h +++ b/src/clipboard.h @@ -23,7 +23,7 @@ public: [[nodiscard]] bool hasImage() const; [[nodiscard]] QImage image() const; - Q_INVOKABLE bool saveImage(const QUrl &localPath) const; + Q_INVOKABLE QString saveImage(QString localPath = {}) const; Q_INVOKABLE void saveText(QString message); diff --git a/src/completionmodel.cpp b/src/completionmodel.cpp new file mode 100644 index 000000000..d066a884f --- /dev/null +++ b/src/completionmodel.cpp @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "completionmodel.h" +#include + +#include "actionsmodel.h" +#include "chatdocumenthandler.h" +#include "completionproxymodel.h" +#include "customemojimodel.h" +#include "neochatroom.h" +#include "roomlistmodel.h" +#include "userlistmodel.h" + +CompletionModel::CompletionModel(QObject *parent) + : QAbstractListModel(parent) + , m_filterModel(new CompletionProxyModel()) + , m_userListModel(new UserListModel(this)) +{ + connect(this, &CompletionModel::textChanged, this, &CompletionModel::updateCompletion); + connect(this, &CompletionModel::roomChanged, this, [this]() { + m_userListModel->setRoom(m_room); + }); +} + +QString CompletionModel::text() const +{ + return m_text; +} + +void CompletionModel::setText(const QString &text, const QString &fullText) +{ + m_text = text; + m_fullText = fullText; + Q_EMIT textChanged(); +} + +int CompletionModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + if (m_autoCompletionType == ChatDocumentHandler::None) { + return 0; + } + return m_filterModel->rowCount(); +} + +QVariant CompletionModel::data(const QModelIndex &index, int role) const +{ + if (index.row() < 0 || index.row() >= m_filterModel->rowCount()) { + return {}; + } + auto filterIndex = m_filterModel->index(index.row(), 0); + if (m_autoCompletionType == ChatDocumentHandler::User) { + if (role == Text) { + return m_filterModel->data(filterIndex, UserListModel::NameRole); + } + if (role == Subtitle) { + return m_filterModel->data(filterIndex, UserListModel::UserIDRole); + } + if (role == Icon) { + return m_filterModel->data(filterIndex, UserListModel::AvatarRole); + } + } + + if (m_autoCompletionType == ChatDocumentHandler::Command) { + if (role == Text) { + return m_filterModel->data(filterIndex, ActionsModel::Prefix).toString() + QStringLiteral(" ") + + m_filterModel->data(filterIndex, ActionsModel::Parameters).toString(); + } + if (role == Subtitle) { + return m_filterModel->data(filterIndex, ActionsModel::Description); + } + if (role == Icon) { + return QStringLiteral("invalid"); + } + if (role == ReplacedText) { + return m_filterModel->data(filterIndex, ActionsModel::Prefix); + } + } + if (m_autoCompletionType == ChatDocumentHandler::Room) { + if (role == Text) { + return m_filterModel->data(filterIndex, RoomListModel::DisplayNameRole); + } + if (role == Subtitle) { + return m_filterModel->data(filterIndex, RoomListModel::CanonicalAliasRole); + } + if (role == Icon) { + return m_filterModel->data(filterIndex, RoomListModel::AvatarRole); + } + } + if (m_autoCompletionType == ChatDocumentHandler::Emoji) { + if (role == Text) { + return m_filterModel->data(filterIndex, CustomEmojiModel::Name); + } + if (role == Icon) { + return m_filterModel->data(filterIndex, CustomEmojiModel::MxcUrl); + } + } + + return {}; +} + +QHash CompletionModel::roleNames() const +{ + return { + {Text, "text"}, + {Subtitle, "subtitle"}, + {Icon, "icon"}, + {ReplacedText, "replacedText"}, + }; +} + +void CompletionModel::updateCompletion() +{ + if (text().startsWith(QLatin1Char('@'))) { + m_filterModel->setSourceModel(m_userListModel); + m_filterModel->setFilterRole(UserListModel::UserIDRole); + m_filterModel->setSecondaryFilterRole(UserListModel::NameRole); + m_filterModel->setFullText(m_fullText); + m_filterModel->setFilterText(m_text); + m_autoCompletionType = ChatDocumentHandler::User; + m_filterModel->invalidate(); + } else if (text().startsWith(QLatin1Char('/'))) { + m_filterModel->setSourceModel(&ActionsModel::instance()); + m_filterModel->setFilterRole(ActionsModel::Prefix); + m_filterModel->setSecondaryFilterRole(-1); + m_filterModel->setFullText(m_fullText); + m_filterModel->setFilterText(m_text.mid(1)); + m_autoCompletionType = ChatDocumentHandler::Command; + m_filterModel->invalidate(); + } else if (text().startsWith(QLatin1Char('#'))) { + m_autoCompletionType = ChatDocumentHandler::Room; + m_filterModel->setSourceModel(m_roomListModel); + m_filterModel->setFilterRole(RoomListModel::CanonicalAliasRole); + m_filterModel->setSecondaryFilterRole(RoomListModel::DisplayNameRole); + m_filterModel->setFullText(m_fullText); + m_filterModel->setFilterText(m_text); + m_filterModel->invalidate(); + } else if (text().startsWith(QLatin1Char(':')) + && (m_fullText.indexOf(QLatin1Char(':'), 1) == -1 + || (m_fullText.indexOf(QLatin1Char(' ')) != -1 && m_fullText.indexOf(QLatin1Char(':'), 1) > m_fullText.indexOf(QLatin1Char(' '), 1)))) { + m_autoCompletionType = ChatDocumentHandler::Emoji; + m_filterModel->setSourceModel(&CustomEmojiModel::instance()); + m_filterModel->setFilterRole(CustomEmojiModel::Name); + m_filterModel->setSecondaryFilterRole(-1); + m_filterModel->setFullText(m_fullText); + m_filterModel->setFilterText(m_text); + m_filterModel->invalidate(); + } else { + m_autoCompletionType = ChatDocumentHandler::None; + } + beginResetModel(); + endResetModel(); +} + +NeoChatRoom *CompletionModel::room() const +{ + return m_room; +} + +void CompletionModel::setRoom(NeoChatRoom *room) +{ + m_room = room; + Q_EMIT roomChanged(); +} + +ChatDocumentHandler::AutoCompletionType CompletionModel::autoCompletionType() const +{ + return m_autoCompletionType; +} + +void CompletionModel::setAutoCompletionType(ChatDocumentHandler::AutoCompletionType autoCompletionType) +{ + m_autoCompletionType = autoCompletionType; + Q_EMIT autoCompletionTypeChanged(); +} + +RoomListModel *CompletionModel::roomListModel() const +{ + return m_roomListModel; +} + +void CompletionModel::setRoomListModel(RoomListModel *roomListModel) +{ + m_roomListModel = roomListModel; + Q_EMIT roomListModelChanged(); +} diff --git a/src/completionmodel.h b/src/completionmodel.h new file mode 100644 index 000000000..eb17faf68 --- /dev/null +++ b/src/completionmodel.h @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include + +#include "chatdocumenthandler.h" + +class CompletionProxyModel; +class UserListModel; +class NeoChatRoom; +class RoomListModel; + +class CompletionModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(QString text READ text NOTIFY textChanged) + Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + Q_PROPERTY(ChatDocumentHandler::AutoCompletionType autoCompletionType READ autoCompletionType NOTIFY autoCompletionTypeChanged); + Q_PROPERTY(RoomListModel *roomListModel READ roomListModel WRITE setRoomListModel NOTIFY roomListModelChanged); + +public: + enum Roles { + Text = Qt::DisplayRole, + Subtitle, + Icon, + ReplacedText, + }; + Q_ENUM(Roles); + + CompletionModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + QString text() const; + void setText(const QString &text, const QString &fullText); + void updateCompletion(); + + NeoChatRoom *room() const; + void setRoom(NeoChatRoom *room); + + RoomListModel *roomListModel() const; + void setRoomListModel(RoomListModel *roomListModel); + + ChatDocumentHandler::AutoCompletionType autoCompletionType() const; + +Q_SIGNALS: + void textChanged(); + void roomChanged(); + void autoCompletionTypeChanged(); + void roomListModelChanged(); + +private: + QString m_text; + QString m_fullText; + CompletionProxyModel *m_filterModel; + NeoChatRoom *m_room = nullptr; + ChatDocumentHandler::AutoCompletionType m_autoCompletionType = ChatDocumentHandler::None; + + void setAutoCompletionType(ChatDocumentHandler::AutoCompletionType autoCompletionType); + + UserListModel *m_userListModel; + RoomListModel *m_roomListModel; +}; diff --git a/src/completionproxymodel.cpp b/src/completionproxymodel.cpp new file mode 100644 index 000000000..fe57f06fe --- /dev/null +++ b/src/completionproxymodel.cpp @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "completionproxymodel.h" +#include + +#include "neochatroom.h" + +bool CompletionProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +{ + Q_UNUSED(sourceParent); + if (m_filterText.isEmpty()) { + return false; + } + return (sourceModel()->data(sourceModel()->index(sourceRow, 0), filterRole()).toString().startsWith(m_filterText) + && !m_fullText.startsWith(sourceModel()->data(sourceModel()->index(sourceRow, 0), filterRole()).toString())) + || (m_secondaryFilterRole != -1 + && sourceModel()->data(sourceModel()->index(sourceRow, 0), secondaryFilterRole()).toString().startsWith(m_filterText.midRef(1))); +} + +int CompletionProxyModel::secondaryFilterRole() const +{ + return m_secondaryFilterRole; +} + +void CompletionProxyModel::setSecondaryFilterRole(int role) +{ + m_secondaryFilterRole = role; + Q_EMIT secondaryFilterRoleChanged(); +} + +QString CompletionProxyModel::filterText() const +{ + return m_filterText; +} + +void CompletionProxyModel::setFilterText(const QString &filterText) +{ + m_filterText = filterText; + Q_EMIT filterTextChanged(); +} + +void CompletionProxyModel::setFullText(const QString &fullText) +{ + m_fullText = fullText; +} diff --git a/src/completionproxymodel.h b/src/completionproxymodel.h new file mode 100644 index 000000000..015c0d4ee --- /dev/null +++ b/src/completionproxymodel.h @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include + +class CompletionProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + Q_PROPERTY(int secondaryFilterRole READ secondaryFilterRole WRITE setSecondaryFilterRole NOTIFY secondaryFilterRoleChanged) + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) + +public: + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + + int secondaryFilterRole() const; + void setSecondaryFilterRole(int role); + + QString filterText() const; + void setFilterText(const QString &filterText); + + void setFullText(const QString &fullText); + +Q_SIGNALS: + void secondaryFilterRoleChanged(); + void filterTextChanged(); + +private: + int m_secondaryFilterRole = -1; + QString m_filterText; + QString m_fullText; +}; diff --git a/src/customemojimodel+network.cpp b/src/customemojimodel+network.cpp index c69b295db..13e25f1f2 100644 --- a/src/customemojimodel+network.cpp +++ b/src/customemojimodel+network.cpp @@ -4,7 +4,9 @@ #include #include -#include "customemojimodel_p.h" +#include "controller.h" +#include "customemojimodel.h" +#include #ifdef QUOTIENT_07 #define running isJobPending @@ -12,35 +14,35 @@ #define running isJobRunning #endif -void CustomEmojiModel::fetchEmojies() +void CustomEmojiModel::fetchEmojis() { - if (d->conn == nullptr) { + if (!Controller::instance().activeConnection()) { return; } - const auto &data = d->conn->accountData("im.ponies.user_emotes"); + const auto &data = Controller::instance().activeConnection()->accountData("im.ponies.user_emotes"); if (data == nullptr) { return; } - QJsonObject emojies = data->contentJson()["images"].toObject(); + QJsonObject emojis = data->contentJson()["images"].toObject(); // TODO: Remove with stable migration - const auto legacyEmojies = data->contentJson()["emoticons"].toObject(); - for (const auto &emoji : legacyEmojies.keys()) { - if (!emojies.contains(emoji)) { - emojies[emoji] = legacyEmojies[emoji]; + const auto legacyEmojis = data->contentJson()["emoticons"].toObject(); + for (const auto &emoji : legacyEmojis.keys()) { + if (!emojis.contains(emoji)) { + emojis[emoji] = legacyEmojis[emoji]; } } beginResetModel(); - d->emojies.clear(); + m_emojis.clear(); - for (const auto &emoji : emojies.keys()) { - const auto &data = emojies[emoji]; + for (const auto &emoji : emojis.keys()) { + const auto &data = emojis[emoji]; const auto e = emoji.startsWith(":") ? emoji : (QStringLiteral(":") + emoji + QStringLiteral(":")); - d->emojies << CustomEmoji{e, data.toObject()["url"].toString(), QRegularExpression(QStringLiteral(R"((^|[^\\]))") + e)}; + m_emojis << CustomEmoji{e, data.toObject()["url"].toString(), QRegularExpression(QStringLiteral(R"((^|[^\\]))") + e)}; } endResetModel(); @@ -50,11 +52,11 @@ void CustomEmojiModel::addEmoji(const QString &name, const QUrl &location) { using namespace Quotient; - auto job = d->conn->uploadFile(location.toLocalFile()); + auto job = Controller::instance().activeConnection()->uploadFile(location.toLocalFile()); if (running(job)) { connect(job, &BaseJob::success, this, [this, name, job] { - const auto &data = d->conn->accountData("im.ponies.user_emotes"); + const auto &data = Controller::instance().activeConnection()->accountData("im.ponies.user_emotes"); auto json = data != nullptr ? data->contentJson() : QJsonObject(); auto emojiData = json["images"].toObject(); emojiData[QStringLiteral("%1").arg(name)] = QJsonObject({ @@ -65,7 +67,7 @@ void CustomEmojiModel::addEmoji(const QString &name, const QUrl &location) #endif }); json["images"] = emojiData; - d->conn->setAccountData("im.ponies.user_emotes", json); + Controller::instance().activeConnection()->setAccountData("im.ponies.user_emotes", json); }); } } @@ -74,8 +76,8 @@ void CustomEmojiModel::removeEmoji(const QString &name) { using namespace Quotient; - const auto &data = d->conn->accountData("im.ponies.user_emotes"); - Q_ASSERT(data != nullptr); // something's screwed if we get here with a nullptr + const auto &data = Controller::instance().activeConnection()->accountData("im.ponies.user_emotes"); + Q_ASSERT(data); auto json = data->contentJson(); const QString _name = name.mid(1).chopped(1); auto emojiData = json["images"].toObject(); @@ -97,5 +99,5 @@ void CustomEmojiModel::removeEmoji(const QString &name) emojiData.remove(_name); json["emoticons"] = emojiData; } - d->conn->setAccountData("im.ponies.user_emotes", json); + Controller::instance().activeConnection()->setAccountData("im.ponies.user_emotes", json); } diff --git a/src/customemojimodel.cpp b/src/customemojimodel.cpp index 99b751ffb..90cc70214 100644 --- a/src/customemojimodel.cpp +++ b/src/customemojimodel.cpp @@ -1,48 +1,39 @@ // SPDX-FileCopyrightText: 2021 Carson Black // SPDX-License-Identifier: GPL-2.0-or-later -#include "customemojimodel_p.h" +#include "customemojimodel.h" +#include "controller.h" #include "emojimodel.h" #include using namespace Quotient; -enum Roles { - Name, - ImageURL, - ModelData, // for emulating the regular emoji model's usage, otherwise the UI code would get too complicated -}; - CustomEmojiModel::CustomEmojiModel(QObject *parent) : QAbstractListModel(parent) - , d(new Private) { - connect(this, &CustomEmojiModel::connectionChanged, this, &CustomEmojiModel::fetchEmojies); - connect(this, &CustomEmojiModel::connectionChanged, this, [this]() { - if (!d->conn) + connect(&Controller::instance(), &Controller::activeConnectionChanged, this, [this]() { + if (!Controller::instance().activeConnection()) { return; - - connect(d->conn, &Connection::accountDataChanged, this, [this](const QString &id) { + } + CustomEmojiModel::fetchEmojis(); + disconnect(nullptr, &Connection::accountDataChanged, this, nullptr); + connect(Controller::instance().activeConnection(), &Connection::accountDataChanged, this, [this](const QString &id) { if (id != QStringLiteral("im.ponies.user_emotes")) { return; } - fetchEmojies(); + fetchEmojis(); }); }); } -CustomEmojiModel::~CustomEmojiModel() -{ -} - QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const { const auto row = idx.row(); - if (row >= d->emojies.length()) { + if (row >= m_emojis.length()) { return QVariant(); } - const auto &data = d->emojies[row]; + const auto &data = m_emojis[row]; switch (Roles(role)) { case Roles::ModelData: @@ -51,6 +42,8 @@ QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const return data.name; case Roles::ImageURL: return QUrl(QStringLiteral("image://mxc/") + data.url.mid(6)); + case Roles::MxcUrl: + return data.url.mid(6); } return QVariant(); @@ -60,7 +53,7 @@ int CustomEmojiModel::rowCount(const QModelIndex &parent) const { Q_UNUSED(parent) - return d->emojies.length(); + return m_emojis.length(); } QHash CustomEmojiModel::roleNames() const @@ -69,41 +62,25 @@ QHash CustomEmojiModel::roleNames() const {Name, "name"}, {ImageURL, "imageURL"}, {ModelData, "modelData"}, + {MxcUrl, "mxcUrl"}, }; } -Connection *CustomEmojiModel::connection() const +QString CustomEmojiModel::preprocessText(const QString &text) { - return d->conn; -} - -void CustomEmojiModel::setConnection(Connection *it) -{ - if (d->conn == it) { - return; - } - if (d->conn != nullptr) { - disconnect(d->conn, nullptr, this, nullptr); - } - d->conn = it; - Q_EMIT connectionChanged(); -} - -QString CustomEmojiModel::preprocessText(const QString &it) -{ - auto cp = it; - for (const auto &emoji : std::as_const(d->emojies)) { - cp.replace( + auto handledText = text; + for (const auto &emoji : std::as_const(m_emojis)) { + handledText.replace( emoji.regexp, QStringLiteral(R"(%2)").arg(emoji.url, emoji.name)); } - return cp; + return handledText; } QVariantList CustomEmojiModel::filterModel(const QString &filter) { QVariantList results; - for (const auto &emoji : std::as_const(d->emojies)) { + for (const auto &emoji : std::as_const(m_emojis)) { if (results.length() >= 10) break; if (!emoji.name.contains(filter, Qt::CaseInsensitive)) diff --git a/src/customemojimodel.h b/src/customemojimodel.h index 5fb6026a2..95c91607c 100644 --- a/src/customemojimodel.h +++ b/src/customemojimodel.h @@ -5,47 +5,46 @@ #include #include +#include -namespace Quotient -{ -class Connection; -} +struct CustomEmoji { + QString name; // with :semicolons: + QString url; // mxc:// + QRegularExpression regexp; +}; class CustomEmojiModel : public QAbstractListModel { Q_OBJECT - Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged) - public: - // constructors + enum Roles { + Name, + ImageURL, + ModelData, // for emulating the regular emoji model's usage, otherwise the UI code would get too complicated + MxcUrl, + }; + Q_ENUM(Roles); - explicit CustomEmojiModel(QObject *parent = nullptr); - ~CustomEmojiModel(); - - // model + static CustomEmojiModel &instance() + { + static CustomEmojiModel _instance; + return _instance; + } QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; QHash roleNames() const override; - // property setters - - Quotient::Connection *connection() const; - void setConnection(Quotient::Connection *it); - Q_SIGNAL void connectionChanged(); - - // QML functions - Q_INVOKABLE QString preprocessText(const QString &it); Q_INVOKABLE QVariantList filterModel(const QString &filter); Q_INVOKABLE void addEmoji(const QString &name, const QUrl &location); Q_INVOKABLE void removeEmoji(const QString &name); private: - struct Private; - std::unique_ptr d; + explicit CustomEmojiModel(QObject *parent = nullptr); + QList m_emojis; - void fetchEmojies(); + void fetchEmojis(); }; diff --git a/src/main.cpp b/src/main.cpp index e6392c756..b9781301f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -39,11 +39,9 @@ #include "actionshandler.h" #include "blurhashimageprovider.h" -#include "chatboxhelper.h" #include "chatdocumenthandler.h" #include "clipboard.h" #include "collapsestateproxymodel.h" -#include "commandmodel.h" #include "controller.h" #include "customemojimodel.h" #include "devicesmodel.h" @@ -76,6 +74,8 @@ #ifdef HAVE_COLORSCHEME #include "colorschemer.h" #endif +#include "completionmodel.h" +#include "neochatuser.h" #ifdef HAVE_RUNNER #include "runner.h" @@ -167,7 +167,6 @@ int main(int argc, char *argv[]) FileTypeSingleton fileTypeSingleton; Login *login = new Login(); - ChatBoxHelper chatBoxHelper; UrlHelper urlHelper; #ifdef HAVE_COLORSCHEME @@ -187,20 +186,18 @@ int main(int argc, char *argv[]) qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "LoginHelper", login); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "UrlHelper", &urlHelper); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "EmojiModel", new EmojiModel(&app)); - qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "CommandModel", new CommandModel(&app)); #ifdef QUOTIENT_07 qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "AccountRegistry", &Quotient::Accounts); #else qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "AccountRegistry", &Quotient::AccountRegistry::instance()); #endif qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "SpaceHierarchyCache", &SpaceHierarchyCache::instance()); + qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "CustomEmojiModel", &CustomEmojiModel::instance()); qmlRegisterType("org.kde.neochat", 1, 0, "ActionsHandler"); - qmlRegisterType("org.kde.neochat", 1, 0, "ChatBoxHelper"); qmlRegisterType("org.kde.neochat", 1, 0, "ChatDocumentHandler"); qmlRegisterType("org.kde.neochat", 1, 0, "RoomListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "WebShortcutModel"); qmlRegisterType("org.kde.neochat", 1, 0, "UserListModel"); - qmlRegisterType("org.kde.neochat", 1, 0, "CustomEmojiModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageEventModel"); qmlRegisterType("org.kde.neochat", 1, 0, "CollapseStateProxyModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageFilterModel"); @@ -210,10 +207,12 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "SortFilterSpaceListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "DevicesModel"); qmlRegisterType("org.kde.neochat", 1, 0, "LinkPreviewer"); + qmlRegisterType("org.kde.neochat", 1, 0, "CompletionModel"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "RoomMessageEvent", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "NeoChatRoomType", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "UserType", "ENUM"); + qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "NeoChatUser", {}); qRegisterMetaType("User*"); qRegisterMetaType("const User*"); diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 08c86a4db..962ac5fb9 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -3,8 +3,6 @@ #include "neochatroom.h" -#include - #include #include #include @@ -642,24 +640,6 @@ void NeoChatRoom::removeLocalAlias(const QString &alias) setLocalAliases(a); } -QString NeoChatRoom::markdownToHTML(const QString &markdown) -{ - const auto str = markdown.toUtf8(); - char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS); - - const std::string html(tmp_buf); - - free(tmp_buf); - - auto result = QString::fromStdString(html).trimmed(); - - result.replace("", ""); - result.replace("

", ""); - result.replace("

", ""); - - return result; -} - QString msgTypeToString(MessageEventType msgType) { switch (msgType) { @@ -684,11 +664,6 @@ QString msgTypeToString(MessageEventType msgType) } } -QString NeoChatRoom::preprocessText(const QString &text) -{ - return markdownToHTML(text); -} - void NeoChatRoom::postMessage(const QString &rawText, const QString &text, MessageEventType type, const QString &replyEventId, const QString &relateToEventId) { postHtmlMessage(rawText, text, type, replyEventId, relateToEventId); @@ -1094,7 +1069,99 @@ void NeoChatRoom::reportEvent(const QString &eventId, const QString &reason) auto job = connection()->callApi(id(), eventId, -50, reason); connect(job, &BaseJob::finished, this, [this, job]() { if (job->error() == BaseJob::Success) { - Q_EMIT positiveMessage(i18n("Report sent successfully.")); + Q_EMIT showMessage(Positive, i18n("Report sent successfully.")); + Q_EMIT showMessage(MessageType::Positive, i18n("Report sent successfully.")); } }); } + +QString NeoChatRoom::chatBoxText() const +{ + return m_chatBoxText; +} + +void NeoChatRoom::setChatBoxText(const QString &text) +{ + m_chatBoxText = text; + Q_EMIT chatBoxTextChanged(); +} + +QString NeoChatRoom::chatBoxReplyId() const +{ + return m_chatBoxReplyId; +} + +void NeoChatRoom::setChatBoxReplyId(const QString &replyId) +{ + m_chatBoxReplyId = replyId; + Q_EMIT chatBoxReplyIdChanged(); +} + +QString NeoChatRoom::chatBoxEditId() const +{ + return m_chatBoxEditId; +} + +void NeoChatRoom::setChatBoxEditId(const QString &editId) +{ + m_chatBoxEditId = editId; + Q_EMIT chatBoxEditIdChanged(); +} + +NeoChatUser *NeoChatRoom::chatBoxReplyUser() const +{ + if (m_chatBoxReplyId.isEmpty()) { + return nullptr; + } + return static_cast(user((*findInTimeline(m_chatBoxReplyId))->senderId())); +} + +QString NeoChatRoom::chatBoxReplyMessage() const +{ + if (m_chatBoxReplyId.isEmpty()) { + return {}; + } + return eventToString(*static_cast(&**findInTimeline(m_chatBoxReplyId))); +} + +NeoChatUser *NeoChatRoom::chatBoxEditUser() const +{ + if (m_chatBoxEditId.isEmpty()) { + return nullptr; + } + return static_cast(user((*findInTimeline(m_chatBoxEditId))->senderId())); +} + +QString NeoChatRoom::chatBoxEditMessage() const +{ + if (m_chatBoxEditId.isEmpty()) { + return {}; + } + return eventToString(*static_cast(&**findInTimeline(m_chatBoxEditId))); +} + +QString NeoChatRoom::chatBoxAttachmentPath() const +{ + return m_chatBoxAttachmentPath; +} + +void NeoChatRoom::setChatBoxAttachmentPath(const QString &attachmentPath) +{ + m_chatBoxAttachmentPath = attachmentPath; + Q_EMIT chatBoxAttachmentPathChanged(); +} + +QVector *NeoChatRoom::mentions() +{ + return &m_mentions; +} + +QString NeoChatRoom::savedText() const +{ + return m_savedText; +} + +void NeoChatRoom::setSavedText(const QString &savedText) +{ + m_savedText = savedText; +} diff --git a/src/neochatroom.h b/src/neochatroom.h index d3dd243da..07aaa7ab2 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -6,9 +6,12 @@ #include #include -#include +#include + #include +class NeoChatUser; + class PushNotificationState : public QObject { Q_OBJECT @@ -24,11 +27,20 @@ public: Q_ENUM(State); }; +struct Mention { + QTextCursor cursor; + QString text; + int start = 0; + int position = 0; + QString id; +}; + class NeoChatRoom : public Quotient::Room { + + Q_OBJECT Q_PROPERTY(QVariantList usersTyping READ getUsersTyping NOTIFY typingChanged) - Q_PROPERTY(QString cachedInput MEMBER m_cachedInput NOTIFY cachedInputChanged) Q_PROPERTY(bool hasFileUploading READ hasFileUploading WRITE setHasFileUploading NOTIFY hasFileUploadingChanged) Q_PROPERTY(int fileUploadingProgress READ fileUploadingProgress NOTIFY fileUploadingProgressChanged) Q_PROPERTY(QString avatarMediaId READ avatarMediaId NOTIFY avatarChanged STORED false) @@ -40,7 +52,24 @@ class NeoChatRoom : public Quotient::Room Q_PROPERTY(PushNotificationState::State pushNotificationState MEMBER m_currentPushNotificationState WRITE setPushNotificationState NOTIFY pushNotificationStateChanged) + // Due to problems with QTextDocument, unlike the other properties here, chatBoxText is *not* used to store the text when switching rooms + Q_PROPERTY(QString chatBoxText READ chatBoxText WRITE setChatBoxText NOTIFY chatBoxTextChanged) + Q_PROPERTY(QString chatBoxReplyId READ chatBoxReplyId WRITE setChatBoxReplyId NOTIFY chatBoxReplyIdChanged) + Q_PROPERTY(QString chatBoxEditId READ chatBoxEditId WRITE setChatBoxEditId NOTIFY chatBoxEditIdChanged) + Q_PROPERTY(NeoChatUser *chatBoxReplyUser READ chatBoxReplyUser NOTIFY chatBoxReplyIdChanged) + Q_PROPERTY(QString chatBoxReplyMessage READ chatBoxReplyMessage NOTIFY chatBoxReplyIdChanged) + Q_PROPERTY(NeoChatUser *chatBoxEditUser READ chatBoxEditUser NOTIFY chatBoxEditIdChanged) + Q_PROPERTY(QString chatBoxEditMessage READ chatBoxEditMessage NOTIFY chatBoxEditIdChanged) + Q_PROPERTY(QString chatBoxAttachmentPath READ chatBoxAttachmentPath WRITE setChatBoxAttachmentPath NOTIFY chatBoxAttachmentPathChanged) + public: + enum MessageType { + Positive, + Info, + Error, + }; + Q_ENUM(MessageType); + explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {}); [[nodiscard]] QVariantList getUsersTyping() const; @@ -137,6 +166,29 @@ public: Q_INVOKABLE void setPushNotificationState(PushNotificationState::State state); + QString chatBoxText() const; + void setChatBoxText(const QString &text); + + QString chatBoxReplyId() const; + void setChatBoxReplyId(const QString &replyId); + + NeoChatUser *chatBoxReplyUser() const; + QString chatBoxReplyMessage() const; + + QString chatBoxEditId() const; + void setChatBoxEditId(const QString &editId); + + NeoChatUser *chatBoxEditUser() const; + QString chatBoxEditMessage() const; + + QString chatBoxAttachmentPath() const; + void setChatBoxAttachmentPath(const QString &attachmentPath); + + QVector *mentions(); + + QString savedText() const; + void setSavedText(const QString &savedText); + #ifndef QUOTIENT_07 Q_INVOKABLE QString htmlSafeMemberName(const QString &userId) const { @@ -145,7 +197,6 @@ public: #endif private: - QString m_cachedInput; QSet highlights; bool m_hasFileUploading = false; @@ -160,10 +211,16 @@ private: void onAddHistoricalTimelineEvents(rev_iter_t from) override; void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override; - static QString markdownToHTML(const QString &markdown); QCoro::Task doDeleteMessagesByUser(const QString &user); QCoro::Task doUploadFile(QUrl url, QString body = QString()); + QString m_chatBoxText; + QString m_chatBoxReplyId; + QString m_chatBoxEditId; + QString m_chatBoxAttachmentPath; + QVector m_mentions; + QString m_savedText; + private Q_SLOTS: void countChanged(); void updatePushNotificationState(QString type); @@ -179,14 +236,17 @@ Q_SIGNALS: void isInviteChanged(); void displayNameChanged(); void pushNotificationStateChanged(PushNotificationState::State state); - void positiveMessage(const QString &message); + void showMessage(MessageType messageType, const QString &message); + void chatBoxTextChanged(); + void chatBoxReplyIdChanged(); + void chatBoxEditIdChanged(); + void chatBoxAttachmentPathChanged(); public Q_SLOTS: void uploadFile(const QUrl &url, const QString &body = QString()); void acceptInvitation(); void forget(); void sendTypingNotification(bool isTyping); - QString preprocessText(const QString &text); /// @param rawText The text as it was typed. /// @param cleanedText The text with link to the users. diff --git a/src/notificationsmanager.cpp b/src/notificationsmanager.cpp index de7f383e6..d14df3473 100644 --- a/src/notificationsmanager.cpp +++ b/src/notificationsmanager.cpp @@ -16,6 +16,7 @@ #include #include +#include "actionshandler.h" #include "controller.h" #include "neochatconfig.h" #include "neochatroom.h" @@ -73,7 +74,7 @@ void NotificationsManager::postNotification(NeoChatRoom *room, std::unique_ptr replyAction(new KNotificationReplyAction(i18n("Reply"))); replyAction->setPlaceholderText(i18n("Reply...")); connect(replyAction.get(), &KNotificationReplyAction::replied, this, [room, replyEventId](const QString &text) { - room->postMessage(text, room->preprocessText(text), RoomMessageEvent::MsgType::Text, replyEventId, QString()); + room->postMessage(text, markdownToHTML(text), RoomMessageEvent::MsgType::Text, replyEventId, QString()); }); notification->setReplyAction(std::move(replyAction)); } diff --git a/src/roomlistmodel.cpp b/src/roomlistmodel.cpp index 606f56aa8..dd57c9607 100644 --- a/src/roomlistmodel.cpp +++ b/src/roomlistmodel.cpp @@ -337,6 +337,9 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const if (role == AvatarRole) { return room->avatarMediaId(); } + if (role == CanonicalAliasRole) { + return room->canonicalAlias(); + } if (role == TopicRole) { return room->topic(); } @@ -429,6 +432,7 @@ QHash RoomListModel::roleNames() const roles[NameRole] = "name"; roles[DisplayNameRole] = "displayName"; roles[AvatarRole] = "avatar"; + roles[CanonicalAliasRole] = "canonicalAlias"; roles[TopicRole] = "topic"; roles[CategoryRole] = "category"; roles[UnreadCountRole] = "unreadCount"; diff --git a/src/roomlistmodel.h b/src/roomlistmodel.h index 169eb22f9..b4059d756 100644 --- a/src/roomlistmodel.h +++ b/src/roomlistmodel.h @@ -42,6 +42,7 @@ public: NameRole = Qt::UserRole + 1, DisplayNameRole, AvatarRole, + CanonicalAliasRole, TopicRole, CategoryRole, UnreadCountRole, diff --git a/src/roommanager.cpp b/src/roommanager.cpp index 0b7482eef..334092eba 100644 --- a/src/roommanager.cpp +++ b/src/roommanager.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #ifndef Q_OS_ANDROID @@ -123,6 +124,12 @@ void RoomManager::openRoomForActiveConnection() void RoomManager::enterRoom(NeoChatRoom *room) { + if (m_chatDocumentHandler) { + // We're doing these things here because it is critical that they are switched at the same time + m_currentRoom->setSavedText(m_chatDocumentHandler->document()->textDocument()->toPlainText()); + m_chatDocumentHandler->setRoom(room); + m_chatDocumentHandler->document()->textDocument()->setPlainText(room->savedText()); + } m_lastCurrentRoom = std::exchange(m_currentRoom, room); Q_EMIT currentRoomChanged(); @@ -232,3 +239,15 @@ void RoomManager::leaveRoom(NeoChatRoom *room) room->forget(); } + +ChatDocumentHandler *RoomManager::chatDocumentHandler() const +{ + return m_chatDocumentHandler; +} + +void RoomManager::setChatDocumentHandler(ChatDocumentHandler *handler) +{ + m_chatDocumentHandler = handler; + m_chatDocumentHandler->setRoom(m_currentRoom); + Q_EMIT chatDocumentHandlerChanged(); +} diff --git a/src/roommanager.h b/src/roommanager.h index 3a1a3f168..f80e51c3b 100644 --- a/src/roommanager.h +++ b/src/roommanager.h @@ -8,6 +8,8 @@ #include #include +#include "chatdocumenthandler.h" + class NeoChatRoom; namespace Quotient @@ -29,6 +31,7 @@ class RoomManager : public QObject, public UriResolverBase /// This property holds whether a room is currently open in NeoChat. /// \sa room Q_PROPERTY(bool hasOpenRoom READ hasOpenRoom NOTIFY currentRoomChanged) + Q_PROPERTY(ChatDocumentHandler *chatDocumentHandler READ chatDocumentHandler WRITE setChatDocumentHandler NOTIFY chatDocumentHandlerChanged) public: explicit RoomManager(QObject *parent = nullptr); @@ -64,6 +67,9 @@ public: /// Call this when the current used connection is dropped. Q_INVOKABLE void reset(); + ChatDocumentHandler *chatDocumentHandler() const; + void setChatDocumentHandler(ChatDocumentHandler *handler); + void setUrlArgument(const QString &arg); Q_SIGNALS: @@ -98,6 +104,8 @@ Q_SIGNALS: /// Displays warning to the user. void warning(const QString &title, const QString &message); + void chatDocumentHandlerChanged(); + private: void openRoomForActiveConnection(); @@ -106,4 +114,5 @@ private: QString m_arg; KConfig m_config; KConfigGroup m_lastRoomConfig; + ChatDocumentHandler *m_chatDocumentHandler; }; diff --git a/src/userlistmodel.h b/src/userlistmodel.h index 770b00567..e6a6c6f7b 100644 --- a/src/userlistmodel.h +++ b/src/userlistmodel.h @@ -34,7 +34,7 @@ class UserListModel : public QAbstractListModel Q_PROPERTY(Quotient::Room *room READ room WRITE setRoom NOTIFY roomChanged) public: enum EventRoles { - NameRole = Qt::UserRole + 1, + NameRole = Qt::DisplayRole, UserIDRole, AvatarRole, ObjectRole,