Refactor input stuff

This is the start of a significant refactoring of everything related to sending messages, which is roughly:
- the chatbox
- action handling
- message sending on the c++ side
- autocompletion of users/rooms/emojis/commands/things i forgot

Notable changes so far include:
- ChatBox is now a ColumnLayout. As part of this, i removed the height animations for now. <del>as far as i can tell, they were broken anyway.</del> I'll readd them later
- Actions were refactored to live outside of the message sending function and are now each an object; it's mostly a wrapper around a function that is executed when the action is invoked
- Everything that used to live in ChatBoxHelper is now in NeoChatRoom; that means that the exact input status (text, message being replied to, message being edited, attachment) is now saved between room switching).
- To edit/reply an event, set `NeoChatRoom::chatBox{edit,reply}Id` to the desired event id, `NeoChatRoom::chatBox{reply,edit}{User,Message}` will then be updated automatically
- Attachments behave equivalently with `NeoChatRoom::chatBoxAttachmentPath`
- Error message reporting from ActionsHandler has been fixed (same fix as in !517) and moved to NeoChatRoom


Broken at the moment:
- [x] Any kind of autocompletion
- [x] Mentions
- [x] Fancy effects
- [x] sed-style edits
- [x] last-user-message edits and replies
- [x] Some of the actions, probably
- [x] Replies from notifications
- [x] Lots of keyboard shortcuts
- [x] Custom emojis
- [x] ChatBox height animations

TODO:
- [x] User / room mentions based on QTextCursors instead of the hack we currently use
- [x] Refactor autocompletion stuff
- [x] ???
- [x] Profit
This commit is contained in:
Tobias Fella
2022-10-10 23:10:00 +00:00
parent b2fa269515
commit 4bfd857093
38 changed files with 1579 additions and 1300 deletions

View File

@@ -13,12 +13,12 @@ Dependencies:
'frameworks/kitemmodels': '@stable' 'frameworks/kitemmodels': '@stable'
'frameworks/knotifications': '@stable' 'frameworks/knotifications': '@stable'
'libraries/kquickimageeditor': '@stable' 'libraries/kquickimageeditor': '@stable'
'frameworks/sonnet': '@stable'
- 'on': ['Windows', 'Linux', 'FreeBSD'] - 'on': ['Windows', 'Linux', 'FreeBSD']
'require': 'require':
'frameworks/qqc2-desktop-style': '@stable' 'frameworks/qqc2-desktop-style': '@stable'
'frameworks/kio': '@stable' 'frameworks/kio': '@stable'
'frameworks/kwindowsystem': '@stable' 'frameworks/kwindowsystem': '@stable'
'frameworks/sonnet': '@stable'
'frameworks/kconfigwidgets': '@stable' 'frameworks/kconfigwidgets': '@stable'
- 'on': ['Linux', 'FreeBSD'] - 'on': ['Linux', 'FreeBSD']
'require': 'require':

View File

@@ -48,7 +48,7 @@ set_package_properties(Qt${QT_MAJOR_VERSION} PROPERTIES
TYPE REQUIRED TYPE REQUIRED
PURPOSE "Basic application components" 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 set_package_properties(KF5 PROPERTIES
TYPE REQUIRED TYPE REQUIRED
PURPOSE "Basic application components" PURPOSE "Basic application components"
@@ -76,7 +76,6 @@ else()
set_package_properties(KF5QQC2DesktopStyle PROPERTIES set_package_properties(KF5QQC2DesktopStyle PROPERTIES
TYPE RUNTIME TYPE RUNTIME
) )
ecm_find_qmlmodule(org.kde.sonnet 1.0)
ecm_find_qmlmodule(org.kde.syntaxhighlighting 1.0) ecm_find_qmlmodule(org.kde.syntaxhighlighting 1.0)
endif() endif()

View File

@@ -12,16 +12,17 @@ import org.kde.neochat 1.0
import NeoChat.Page 1.0 import NeoChat.Page 1.0
Loader { 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 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 active: visible
sourceComponent: Component { sourceComponent: Component {
Pane { Pane {
id: attachmentPane id: attachmentPane
property string baseFileName: chatBoxHelper.attachmentPath.toString().substring(chatBoxHelper.attachmentPath.toString().lastIndexOf('/') + 1, chatBoxHelper.attachmentPath.length)
Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.colorSet: Kirigami.Theme.View
contentItem: Item { contentItem: Item {
@@ -45,8 +46,8 @@ Loader {
width: Math.min(implicitWidth, attachmentPane.availableWidth) width: Math.min(implicitWidth, attachmentPane.availableWidth)
asynchronous: true asynchronous: true
cache: false // Cache is not needed. Images will rarely be shown repeatedly. 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 smooth: height === preferredHeight && parent.height === parent.implicitHeight // Don't smooth until height animation stops
source: hasImage ? chatBoxHelper.attachmentPath : "" source: hasImage ? attachmentPaneLoader.attachmentPath : ""
visible: hasImage visible: hasImage
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
@@ -152,7 +153,7 @@ Loader {
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
} }
Button { ToolButton {
id: editImageButton id: editImageButton
visible: hasImage visible: hasImage
icon.name: "document-edit" icon.name: "document-edit"
@@ -162,25 +163,25 @@ Loader {
Component { Component {
id: imageEditorPage id: imageEditorPage
ImageEditorPage { ImageEditorPage {
imagePath: chatBoxHelper.attachmentPath imagePath: attachmentPaneLoader.attachmentPath
} }
} }
onClicked: { onClicked: {
let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage); let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage);
imageEditor.newPathChanged.connect(function(newPath) { imageEditor.newPathChanged.connect(function(newPath) {
applicationWindow().pageStack.layers.pop(); applicationWindow().pageStack.layers.pop();
chatBoxHelper.attachmentPath = newPath; attachmentPaneLoader.attachmentPath = newPath;
}); });
} }
ToolTip.text: text ToolTip.text: text
ToolTip.visible: hovered ToolTip.visible: hovered
} }
Button { ToolButton {
id: cancelAttachmentButton id: cancelAttachmentButton
icon.name: "dialog-cancel" icon.name: "dialog-close"
text: i18n("Cancel") text: i18n("Cancel sending Image")
display: AbstractButton.IconOnly display: AbstractButton.IconOnly
onClicked: chatBoxHelper.clearAttachment(); onClicked: currentRoom.chatBoxAttachmentPath = "";
ToolTip.text: text ToolTip.text: text
ToolTip.visible: hovered ToolTip.visible: hovered
} }

View File

@@ -5,8 +5,6 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import QtQuick.Controls 2.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 QtQuick.Window 2.15
import org.kde.kirigami 2.18 as Kirigami import org.kde.kirigami 2.18 as Kirigami
@@ -14,25 +12,13 @@ import org.kde.neochat 1.0
ToolBar { ToolBar {
id: chatBar id: chatBar
property string replyEventId: ""
property string editEventId: ""
property alias inputFieldText: inputField.text property alias inputFieldText: inputField.text
property alias textField: inputField property alias textField: inputField
property alias emojiPaneOpened: emojiButton.checked 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 closeAllTriggered()
signal inputFieldForceActiveFocusTriggered() signal inputFieldForceActiveFocusTriggered()
signal messageSent() signal messageSent()
signal pasteImageTriggered()
signal editLastUserMessage()
signal replyPreviousUserMessage()
property alias isCompleting: completionMenu.visible
onInputFieldForceActiveFocusTriggered: { onInputFieldForceActiveFocusTriggered: {
inputField.forceActiveFocus(); inputField.forceActiveFocus();
@@ -92,12 +78,7 @@ ToolBar {
topPadding: 0 topPadding: 0
bottomPadding: 0 bottomPadding: 0
property real progress: 0 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…")
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…")
verticalAlignment: TextEdit.AlignVCenter verticalAlignment: TextEdit.AlignVCenter
horizontalAlignment: TextEdit.AlignLeft horizontalAlignment: TextEdit.AlignLeft
wrapMode: Text.Wrap wrapMode: Text.Wrap
@@ -105,7 +86,6 @@ ToolBar {
Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false Kirigami.Theme.inherit: false
Kirigami.SpellChecking.enabled: true
color: Kirigami.Theme.textColor color: Kirigami.Theme.textColor
selectionColor: Kirigami.Theme.highlightColor selectionColor: Kirigami.Theme.highlightColor
@@ -114,117 +94,54 @@ ToolBar {
selectByMouse: !Kirigami.Settings.tabletMode selectByMouse: !Kirigami.Settings.tabletMode
ChatDocumentHandler { Keys.onEnterPressed: {
id: documentHandler if (completionMenu.visible) {
document: inputField.textDocument completionMenu.complete()
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();
} else if (event.modifiers & Qt.ShiftModifier) { } else if (event.modifiers & Qt.ShiftModifier) {
inputField.insert(cursorPosition, "\n") inputField.insert(cursorPosition, "\n")
} else { } 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.onTabPressed: {
Keys.onEnterPressed: { sendMessage(event) } if (completionMenu.visible) {
completionMenu.complete()
Keys.onEscapePressed: { }
closeAllTriggered()
} }
Keys.onPressed: { Keys.onPressed: {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
chatBar.pasteImage(); chatBar.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) { } 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) { } else if (event.key === Qt.Key_Up && inputField.text.length === 0) {
editLastUserMessage(); let editEvent = messageEventModel.getLastLocalUserMessageEventId()
} if (editEvent) {
} currentRoom.chatBoxEditId = editEvent["event_id"]
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
} }
completionMenu.currentIndex = decrementedIndex } else if (event.key === Qt.Key_Up && completionMenu.visible) {
} else { completionMenu.decrementIndex()
autoAppeared = false; } else if (event.key === Qt.Key_Down && completionMenu.visible) {
completionMenu.incrementIndex()
} }
chatBar.complete();
} }
// yes, decrement goes up and increment goes down visually. Timer {
Keys.onUpPressed: (event) => { id: repeatTimer
if (chatBar.isCompleting) { interval: 5000
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();
} }
onTextChanged: { onTextChanged: {
@@ -233,58 +150,20 @@ ToolBar {
} }
repeatTimer.start() repeatTimer.start()
currentRoom.cachedInput = text currentRoom.chatBoxText = 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
}
} }
} }
} }
Item { Item {
visible: !chatBoxHelper.isReplying && (!chatBoxHelper.hasAttachment || uploadingBusySpinner.running) visible: currentRoom.chatBoxReplyId.length === 0 && (currentRoom.chatBoxAttachmentPath.length === 0 || uploadingBusySpinner.running)
implicitWidth: uploadButton.implicitWidth implicitWidth: uploadButton.implicitWidth
implicitHeight: uploadButton.implicitHeight implicitHeight: uploadButton.implicitHeight
ToolButton { ToolButton {
id: uploadButton id: uploadButton
anchors.fill: parent anchors.fill: parent
// Matrix does not allow sending attachments in replies // 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" icon.name: "mail-attachment"
text: i18n("Attach an image or file") text: i18n("Attach an image or file")
display: AbstractButton.IconOnly display: AbstractButton.IconOnly
@@ -295,8 +174,10 @@ ToolBar {
} else { } else {
var fileDialog = openFileDialog.createObject(ApplicationWindow.overlay) var fileDialog = openFileDialog.createObject(ApplicationWindow.overlay)
fileDialog.chosen.connect((path) => { fileDialog.chosen.connect((path) => {
if (!path) { return } if (!path) {
chatBoxHelper.attachmentPath = path; return;
}
currentRoom.chatBoxAttachmentPath = path;
}) })
fileDialog.open() fileDialog.open()
} }
@@ -339,24 +220,12 @@ ToolBar {
} }
} }
Action {
id: pasteAction
shortcut: StandardKey.Paste
onTriggered: {
if (Clipboard.hasImage) {
pasteImageTriggered();
}
activeFocusItem.paste();
}
}
CompletionMenu { CompletionMenu {
id: completionMenu id: completionMenu
width: parent.width
//height: 80 //Math.min(implicitHeight, delegate.implicitHeight * 6)
height: implicitHeight height: implicitHeight
y: -height - 1 y: -height - 5
z: 1 z: 1
chatDocumentHandler: documentHandler
Behavior on height { Behavior on height {
NumberAnimation { NumberAnimation {
property: "height" property: "height"
@@ -364,59 +233,42 @@ ToolBar {
easing.type: Easing.OutCubic easing.type: Easing.OutCubic
} }
} }
onCompleteTriggered: { }
complete()
isCompleting = false; Connections {
target: currentRoom
function onChatBoxEditIdChanged() {
chatBar.inputFieldText = currentRoom.chatBoxEditMessage
} }
} }
property CustomEmojiModel customEmojiModel: CustomEmojiModel { ChatDocumentHandler {
connection: Controller.activeConnection id: documentHandler
document: inputField.textDocument
cursorPosition: inputField.cursorPosition
selectionStart: inputField.selectionStart
selectionEnd: inputField.selectionEnd
Component.onCompleted: {
RoomManager.chatDocumentHandler = documentHandler;
}
} }
function pasteImage() { function pasteImage() {
let localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png"; let localPath = Clipboard.saveImage();
if (!Clipboard.saveImage(localPath)) { if (localPath.length === 0) {
return; return;
} }
chatBoxHelper.attachmentPath = localPath; currentRoom.chatBoxAttachmentPath = localPath
} }
function postMessage() { 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(); currentRoom.markAllMessagesAsRead();
inputField.clear(); inputField.clear();
inputField.text = Qt.binding(function() { currentRoom.chatBoxReplyId = "";
return currentRoom ? currentRoom.cachedInput : ""; currentRoom.chatBoxEditId = "";
});
messageSent() 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;
}
}
} }

View File

@@ -4,58 +4,26 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2 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.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
import NeoChat.Component.ChatBox 1.0 import NeoChat.Component.ChatBox 1.0
import NeoChat.Component.Emoji 1.0 import NeoChat.Component.Emoji 1.0
Item { ColumnLayout {
id: root id: chatBox
property alias inputFieldText: chatBar.inputFieldText property alias inputFieldText: chatBar.inputFieldText
signal fancyEffectsReasonFound(string fancyEffect)
signal messageSent() signal messageSent()
signal editLastUserMessage()
signal replyPreviousUserMessage()
Kirigami.Theme.colorSet: Kirigami.Theme.View spacing: 0
implicitWidth: {
let w = 0
for(let i = 0; i < visibleChildren.length; ++i) {
w = Math.max(w, Math.ceil(visibleChildren[i].implicitWidth))
}
return w
}
implicitHeight: {
let h = 0
for(let i = 0; i < visibleChildren.length; ++i) {
h += Math.ceil(visibleChildren[i].implicitHeight)
}
return h
}
// For some reason, this is needed to make the height animation work even though
// it used to work and height should be directly affected by implicitHeight
height: implicitHeight
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
Kirigami.Separator { Kirigami.Separator {
id: connectionPaneSeparator id: connectionPaneSeparator
visible: connectionPane.visible visible: connectionPane.visible
width: parent.width Layout.fillWidth: true
height: visible ? implicitHeight : 0
anchors.bottom: connectionPane.top
z: 1
} }
QQC2.Pane { QQC2.Pane {
@@ -71,30 +39,25 @@ Item {
color: Kirigami.Theme.backgroundColor color: Kirigami.Theme.backgroundColor
} }
visible: !Controller.isOnline visible: !Controller.isOnline
width: parent.width Layout.fillWidth: true
QQC2.Label { QQC2.Label {
id: networkLabel id: networkLabel
text: i18n("NeoChat is offline. Please check your network connection.") text: i18n("NeoChat is offline. Please check your network connection.")
} }
anchors.bottom: emojiPickerLoaderSeparator.top
} }
Kirigami.Separator { Kirigami.Separator {
id: emojiPickerLoaderSeparator id: emojiPickerLoaderSeparator
visible: emojiPickerLoader.visible visible: emojiPickerLoader.visible
width: parent.width Layout.fillWidth: true
height: visible ? implicitHeight : 0 height: visible ? implicitHeight : 0
anchors.bottom: emojiPickerLoader.top
z: 1
} }
Loader { Loader {
id: emojiPickerLoader id: emojiPickerLoader
active: visible active: visible
visible: chatBar.emojiPaneOpened visible: chatBar.emojiPaneOpened
width: parent.width Layout.fillWidth: true
height: visible ? implicitHeight : 0
anchors.bottom: replySeparator.top
sourceComponent: QQC2.Pane { sourceComponent: QQC2.Pane {
topPadding: 0 topPadding: 0
bottomPadding: 0 bottomPadding: 0
@@ -106,178 +69,79 @@ Item {
onChosen: addText(emoji) onChosen: addText(emoji)
} }
} }
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
} }
Kirigami.Separator { Kirigami.Separator {
id: replySeparator id: replySeparator
visible: replyPane.visible visible: replyPane.visible
width: parent.width Layout.fillWidth: true
height: visible ? implicitHeight : 0
anchors.bottom: replyPane.top
} }
ReplyPane { ReplyPane {
id: replyPane id: replyPane
visible: chatBoxHelper.isReplying || chatBoxHelper.isEditing visible: currentRoom.chatBoxReplyId.length > 0 || currentRoom.chatBoxEditId.length > 0
user: chatBoxHelper.replyUser Layout.fillWidth: true
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
}
}
onReplyCancelled: { onReplyCancelled: {
root.focusInputField() chatBox.focusInputField()
} }
} }
Kirigami.Separator { Kirigami.Separator {
id: attachmentSeparator id: attachmentSeparator
visible: attachmentPane.visible visible: attachmentPane.visible
width: parent.width Layout.fillWidth: true
height: visible ? implicitHeight : 0
anchors.bottom: attachmentPane.top
} }
AttachmentPane { AttachmentPane {
id: attachmentPane id: attachmentPane
visible: chatBoxHelper.hasAttachment visible: currentRoom.chatBoxAttachmentPath.length > 0
width: parent.width Layout.fillWidth: true
height: visible ? implicitHeight : 0
anchors.bottom: chatBarSeparator.top
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
} }
Kirigami.Separator { Kirigami.Separator {
id: chatBarSeparator id: chatBarSeparator
visible: chatBar.visible visible: chatBar.visible
width: parent.width
height: visible ? implicitHeight : 0 Layout.fillWidth: true
anchors.bottom: chatBar.top
} }
ChatBar { ChatBar {
id: chatBar id: chatBar
visible: currentRoom.canSendEvent("m.room.message") visible: currentRoom.canSendEvent("m.room.message")
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: parent.bottom
Behavior on height { Layout.fillWidth: true
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
onCloseAllTriggered: closeAll() onCloseAllTriggered: closeAll()
onMessageSent: { onMessageSent: {
closeAll() closeAll()
checkForFancyEffectsReason() chatBox.messageSent();
root.messageSent();
}
onEditLastUserMessage: {
root.editLastUserMessage();
}
onReplyPreviousUserMessage: {
root.replyPreviousUserMessage();
}
}
function checkForFancyEffectsReason() {
if (!Config.showFancyEffects) {
return
} }
let text = root.inputFieldText.trim() Behavior on implicitHeight {
if (text.includes('\u{2744}')) { NumberAnimation {
root.fancyEffectsReasonFound("snowflake") property: "implicitHeight"
} duration: Kirigami.Units.shortDuration
if (text.includes('\u{1F386}')) { easing.type: Easing.OutCubic
root.fancyEffectsReasonFound("fireworks") }
}
if (text.includes('\u{1F387}')) {
root.fancyEffectsReasonFound("fireworks")
}
if (text.includes('\u{1F389}')) {
root.fancyEffectsReasonFound("confetti")
}
if (text.includes('\u{1F38A}')) {
root.fancyEffectsReasonFound("confetti")
} }
} }
function addText(text) { function addText(text) {
root.inputFieldText = inputFieldText + text chatBox.inputFieldText = inputFieldText + text
} }
function insertText(str) { 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() { function focusInputField() {
chatBar.inputFieldForceActiveFocusTriggered() 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 href=\"https:\/\/matrix.to\/#\/(@[a-zA-Z09]*:[a-zA-Z09.]*)\">([^<]*)<\/a>/g;
let match;
while ((match = regex.exec(editFormatedContent.toString())) !== null) {
chatBar.userAutocompleted[match[2]] = match[1];
}
chatBox.forceActiveFocus();
}
}
function closeAll() { function closeAll() {
chatBoxHelper.clear(); // TODO clear();
chatBar.emojiPaneOpened = false; chatBar.emojiPaneOpened = false;
} }
} }

View File

@@ -10,154 +10,65 @@ import Qt.labs.qmlmodels 1.0
import org.kde.kirigami 2.15 as Kirigami import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
import NeoChat.Component 1.0
Popup { Popup {
id: control id: completionMenu
width: parent.width
// Expose internal ListView properties. visible: completions.count > 0
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
// Autocomplee text RoomListModel {
property string currentDisplayText: currentItem && (currentItem.displayName ?? "") id: roomListModel
connection: Controller.activeConnection
}
property int completionType: ChatDocumentHandler.Emoji required property var chatDocumentHandler
property int beginPosition: 0 Component.onCompleted: {
property int endPosition: 0 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 leftPadding: 0
rightPadding: 0 rightPadding: 0
topPadding: 0 topPadding: 0
clip: true bottomPadding: 0
implicitHeight: Math.min(completions.contentHeight, Kirigami.Units.gridUnit * 10)
onVisibleChanged: if (!visible) { contentItem: ListView {
completionListView.currentIndex = 0; id: completions
}
implicitHeight: Math.min(completionListView.contentHeight, Kirigami.Units.gridUnit * 10) anchors.fill: parent
model: completionMenu.chatDocumentHandler.completionModel
contentItem: ScrollView { currentIndex: 0
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890) keyNavigationWraps: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff highlightMoveDuration: 100
ListView { delegate: Kirigami.BasicListItem {
id: completionListView text: model.text
implicitWidth: contentWidth subtitle: model.subtitle ?? ""
delegate: { leading: RowLayout {
if (completionType === ChatDocumentHandler.Emoji) { Kirigami.Avatar {
emojiDelegate visible: model.icon !== "invalid"
} else if (completionType === ChatDocumentHandler.Command) { Layout.preferredWidth: height
commandDelegate Layout.fillHeight: true
} else if (completionType === ChatDocumentHandler.User) { source: model.icon === "invalid" ? "" : ("image://mxc/" + model.icon)
usernameDelegate name: model.text
} }
} }
onClicked: completionMenu.chatDocumentHandler.complete(model.index)
keyNavigationWraps: true
//interactive: Window.window ? contentHeight + control.topPadding + control.bottomPadding > Window.window.height : false
clip: true
currentIndex: control.currentIndex || 0
} }
} }
background: Rectangle { background: Rectangle {
color: Kirigami.Theme.backgroundColor 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: "<i>" + modelData.parameter.replace("<", "&lt;").replace(">", "&gt;") + "</i> " + 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();
}
}
} }

View File

@@ -11,10 +11,8 @@ import org.kde.kirigami 2.14 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
Loader { Loader {
id: root id: replyPane
readonly property bool isEdit: chatBoxHelper.isEditing property NeoChatUser user: currentRoom.chatBoxReplyUser ?? currentRoom.chatBoxEditUser
property var user: null
property string avatarMediaUrl: user ? "image://mxc/" + user.avatarMediaId : ""
signal replyCancelled() signal replyCancelled()
@@ -40,7 +38,7 @@ Loader {
Layout.alignment: textContentLayout.height > avatar.height ? Qt.AlignHCenter | Qt.AlignTop : Qt.AlignCenter Layout.alignment: textContentLayout.height > avatar.height ? Qt.AlignHCenter | Qt.AlignTop : Qt.AlignCenter
Layout.preferredWidth: Layout.preferredHeight Layout.preferredWidth: Layout.preferredHeight
Layout.preferredHeight: fontMetrics.lineSpacing * 2 - fontMetrics.leading Layout.preferredHeight: fontMetrics.lineSpacing * 2 - fontMetrics.leading
source: root.avatarMediaUrl source: user ? "image://mxc/" + currentRoom.getUser(user.id).avatarMediaId : ""
name: user ? user.displayName : "" name: user ? user.displayName : ""
color: user ? user.color : "transparent" color: user ? user.color : "transparent"
visible: Boolean(user) visible: Boolean(user)
@@ -58,7 +56,7 @@ Loader {
text: { text: {
let heading = "<b>%1</b>" let heading = "<b>%1</b>"
let userName = user ? "<font color=\""+ user.color +"\">" + currentRoom.htmlSafeMemberName(user.id) + "</font>" : "" let userName = user ? "<font color=\""+ user.color +"\">" + currentRoom.htmlSafeMemberName(user.id) + "</font>" : ""
if (isEdit) { if (currentRoom.chatBoxEditId.length > 0) {
heading = heading.arg(i18n("Editing message:")) + "<br/>" heading = heading.arg(i18n("Editing message:")) + "<br/>"
} else { } else {
heading = heading.arg(i18n("Replying to %1:", userName)) heading = heading.arg(i18n("Replying to %1:", userName))
@@ -67,6 +65,7 @@ Loader {
return heading return heading
} }
} }
//TODO edit user mentions
ScrollView { ScrollView {
Layout.alignment: Qt.AlignLeft | Qt.AlignTop Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true Layout.fillWidth: true
@@ -81,11 +80,7 @@ Loader {
rightPadding: 0 rightPadding: 0
topPadding: 0 topPadding: 0
bottomPadding: 0 bottomPadding: 0
text: { text: "<style> a{color:" + Kirigami.Theme.linkColor + ";}.user-pill{}</style>" + (currentRoom.chatBoxEditId.length > 0 ? currentRoom.chatBoxEditMessage : currentRoom.chatBoxReplyMessage)
const stylesheet = "<style> a{color:"+Kirigami.Theme.linkColor+";}.user-pill{}</style>";
const content = chatBoxHelper.isReplying ? chatBoxHelper.replyEventContent : chatBoxHelper.editContent;
return stylesheet + content;
}
selectByMouse: true selectByMouse: true
selectByKeyboard: true selectByKeyboard: true
readOnly: true readOnly: true
@@ -99,15 +94,16 @@ Loader {
} }
} }
Button { ToolButton {
id: cancelReplyButton
Layout.alignment: avatar.Layout.alignment
icon.name: "dialog-cancel"
text: i18n("Cancel")
display: AbstractButton.IconOnly display: AbstractButton.IconOnly
onClicked: { action: Kirigami.Action {
chatBoxHelper.clear(); text: i18nc("@action:button", "Cancel reply")
root.replyCancelled(); icon.name: "dialog-close"
onTriggered: {
currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxEditId = "";
}
shortcut: "Escape"
} }
ToolTip.text: text ToolTip.text: text
ToolTip.visible: hovered ToolTip.visible: hovered

View File

@@ -6,7 +6,7 @@ import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami 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 import NeoChat.Component 1.0
ColumnLayout { ColumnLayout {
@@ -14,11 +14,7 @@ ColumnLayout {
property string emojiCategory: "history" property string emojiCategory: "history"
property var textArea property var textArea
readonly property var emojiModel: NeoChat.EmojiModel readonly property var emojiModel: EmojiModel
property NeoChat.CustomEmojiModel customModel: NeoChat.CustomEmojiModel {
connection: NeoChat.Controller.activeConnection
}
signal chosen(string emoji) signal chosen(string emoji)
@@ -102,7 +98,7 @@ ColumnLayout {
model: { model: {
switch (emojiCategory) { switch (emojiCategory) {
case "custom": case "custom":
return _picker.customModel return CustomEmojiModel
case "history": case "history":
return emojiModel.history return emojiModel.history
case "people": case "people":

View File

@@ -52,7 +52,8 @@ MessageDelegateContextMenu {
text: i18n("Reply") text: i18n("Reply")
icon.name: "mail-replied-symbolic" icon.name: "mail-replied-symbolic"
onTriggered: { onTriggered: {
chatBoxHelper.replyToMessage(eventId, message, author); currentRoom.chatBoxReplyId = eventId
currentRoom.chatBoxEditId = ""
root.closeFullscreen() root.closeFullscreen()
} }
}, },

View File

@@ -28,13 +28,19 @@ Loader {
Kirigami.Action { Kirigami.Action {
text: i18n("Edit") text: i18n("Edit")
icon.name: "document-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") visible: eventType.length > 0 && author.id === Controller.activeConnection.localUserId && (eventType === "emote" || eventType === "message")
}, },
Kirigami.Action { Kirigami.Action {
text: i18n("Reply") text: i18n("Reply")
icon.name: "mail-replied-symbolic" icon.name: "mail-replied-symbolic"
onTriggered: chatBoxHelper.replyToMessage(eventId, message, author); onTriggered: {
currentRoom.chatBoxReplyId = eventId;
currentRoom.chatBoxEditId = "";
}
}, },
Kirigami.Action { Kirigami.Action {
visible: author.id === currentRoom.localUser.id || currentRoom.canSendState("redact") visible: author.id === currentRoom.localUser.id || currentRoom.canSendState("redact")

View File

@@ -53,7 +53,6 @@ Kirigami.ScrollablePage {
onCurrentRoomChanged: { onCurrentRoomChanged: {
hasScrolledUpBefore = false; hasScrolledUpBefore = false;
chatBoxHelper.clearEditReply()
} }
Connections { Connections {
@@ -78,11 +77,6 @@ Kirigami.ScrollablePage {
ActionsHandler { ActionsHandler {
id: actionsHandler id: actionsHandler
room: page.currentRoom room: page.currentRoom
connection: Controller.activeConnection
}
ChatBoxHelper {
id: chatBoxHelper
} }
Shortcut { Shortcut {
@@ -101,10 +95,10 @@ Kirigami.ScrollablePage {
} }
Connections { Connections {
target: actionsHandler target: currentRoom
function onShowMessage(messageType, message) { function onShowMessage(messageType, message) {
page.header.contentItem.text = 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; 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 // hover actions on a delegate, activated in TimelineContainer.qml
Connections { Connections {
target: page.flickable target: page.flickable
@@ -268,9 +253,10 @@ Kirigami.ScrollablePage {
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay) var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay)
fileDialog.chosen.connect(function(path) { fileDialog.chosen.connect(function(path) {
if (!path) return if (!path) {
return;
chatBoxHelper.attachmentPath = path; }
currentRoom.chatBoxAttachmentPath = path;
}) })
fileDialog.open() fileDialog.open()
@@ -292,7 +278,7 @@ Kirigami.ScrollablePage {
if (!Clipboard.saveImage(localPath)) { if (!Clipboard.saveImage(localPath)) {
return; return;
} }
chatBoxHelper.attachmentPath = localPath; currentRoom.chatBoxAttachmentPath = localPath;
attachDialog.close(); attachDialog.close();
} }
} }
@@ -367,7 +353,7 @@ Kirigami.ScrollablePage {
DropArea { DropArea {
id: dropAreaFile id: dropAreaFile
anchors.fill: parent anchors.fill: parent
onDropped: chatBoxHelper.attachmentPath = drop.urls[0] onDropped: currentRoom.chatBoxAttachmentPath = drop.urls[0];
} }
QQC2.Pane { QQC2.Pane {
@@ -510,9 +496,8 @@ Kirigami.ScrollablePage {
visible: hoverActions.showEdit visible: hoverActions.showEdit
icon.name: "document-edit" icon.name: "document-edit"
onClicked: { onClicked: {
if (hoverActions.showEdit) { currentRoom.chatBoxEditId = hoverActions.event.eventId;
chatBoxHelper.edit(hoverActions.event.message, hoverActions.event.formattedBody, hoverActions.event.eventId) currentRoom.chatBoxReplyId = "";
}
chatBox.focusInputField(); chatBox.focusInputField();
} }
} }
@@ -522,7 +507,8 @@ Kirigami.ScrollablePage {
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
icon.name: "mail-replied-symbolic" icon.name: "mail-replied-symbolic"
onClicked: { onClicked: {
chatBoxHelper.replyToMessage(hoverActions.event.eventId, hoverActions.event.message, hoverActions.event.author); currentRoom.chatBoxReplyId = hoverActions.event.eventId;
currentRoom.chatBoxEditId = "";
chatBox.focusInputField(); chatBox.focusInputField();
} }
} }
@@ -534,24 +520,12 @@ Kirigami.ScrollablePage {
footer: ChatBox { footer: ChatBox {
id: chatBox id: chatBox
visible: !invitation.visible && !(messageListView.count === 0 && !currentRoom.allHistoryLoaded) visible: !invitation.visible && !(messageListView.count === 0 && !currentRoom.allHistoryLoaded)
width: parent.width
onMessageSent: { onMessageSent: {
if (!messageListView.atYEnd) { if (!messageListView.atYEnd) {
goToLastMessage(); 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 { background: FancyEffectsContainer {
@@ -582,8 +556,8 @@ Kirigami.ScrollablePage {
Connections { Connections {
enabled: Config.showFancyEffects enabled: Config.showFancyEffects
target: chatBox target: actionsHandler
function onFancyEffectsReasonFound(fancyEffect) { function onShowEffect(fancyEffect) {
fancyEffectsContainer.processFancyEffectsReason(fancyEffect) fancyEffectsContainer.processFancyEffectsReason(fancyEffect)
} }
} }

View File

@@ -21,10 +21,7 @@ Kirigami.ScrollablePage {
ListView { ListView {
anchors.fill: parent anchors.fill: parent
model: CustomEmojiModel { model: CustomEmojiModel
id: emojiModel
connection: Controller.activeConnection
}
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
anchors.centerIn: parent anchors.centerIn: parent
@@ -99,7 +96,7 @@ Kirigami.ScrollablePage {
this.fileDialog = openFileDialog.createObject(QQC2.Overlay.overlay) this.fileDialog = openFileDialog.createObject(QQC2.Overlay.overlay)
this.fileDialog.chosen.connect((url) => { this.fileDialog.chosen.connect((url) => {
emojiModel.addEmoji(emojiCreator.name, url) CustomEmojiModel.addEmoji(emojiCreator.name, url)
this.fileDialog = null this.fileDialog = null
}) })
this.fileDialog.onRejected.connect(() => { this.fileDialog.onRejected.connect(() => {

View File

@@ -30,8 +30,6 @@ add_library(neochat STATIC
filetypesingleton.cpp filetypesingleton.cpp
login.cpp login.cpp
stickerevent.cpp stickerevent.cpp
chatboxhelper.cpp
commandmodel.cpp
webshortcutmodel.cpp webshortcutmodel.cpp
blurhash.cpp blurhash.cpp
blurhashimageprovider.cpp blurhashimageprovider.cpp
@@ -40,6 +38,9 @@ add_library(neochat STATIC
urlhelper.cpp urlhelper.cpp
windowcontroller.cpp windowcontroller.cpp
linkpreviewer.cpp linkpreviewer.cpp
completionmodel.cpp
completionproxymodel.cpp
actionsmodel.cpp
) )
add_executable(neochat-app add_executable(neochat-app
@@ -84,7 +85,7 @@ else()
endif() endif()
target_include_directories(neochat PRIVATE ${CMAKE_BINARY_DIR}) 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) if(TARGET QCoro5::Coro)
target_link_libraries(neochat PUBLIC QCoro5::Coro) target_link_libraries(neochat PUBLIC QCoro5::Coro)
else() else()

View File

@@ -3,21 +3,49 @@
#include "actionshandler.h" #include "actionshandler.h"
#include "controller.h"
#include <csapi/joining.h>
#include <events/roommemberevent.h>
#include <cmark.h>
#include <KLocalizedString> #include <KLocalizedString>
#include <QStringBuilder> #include <QStringBuilder>
#include "actionsmodel.h"
#include "controller.h" #include "controller.h"
#include "customemojimodel.h" #include "customemojimodel.h"
#include "neochatconfig.h"
#include "neochatroom.h" #include "neochatroom.h"
#include "roommanager.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("<!-- raw HTML omitted -->", "");
result.replace("<p>", "");
result.replace("</p>", "");
return result;
}
ActionsHandler::ActionsHandler(QObject *parent) ActionsHandler::ActionsHandler(QObject *parent)
: QObject(parent) : QObject(parent)
{ {
} }
ActionsHandler::~ActionsHandler(){};
NeoChatRoom *ActionsHandler::room() const NeoChatRoom *ActionsHandler::room() const
{ {
return m_room; return m_room;
@@ -33,298 +61,106 @@ void ActionsHandler::setRoom(NeoChatRoom *room)
Q_EMIT roomChanged(); Q_EMIT roomChanged();
} }
Connection *ActionsHandler::connection() const void ActionsHandler::handleMessage()
{ {
return m_connection; checkEffects();
} if (!m_room->chatBoxAttachmentPath().isEmpty()) {
auto path = m_room->chatBoxAttachmentPath();
void ActionsHandler::setConnection(Connection *connection) path = path.mid(path.lastIndexOf('/') + 1);
{ m_room->uploadFile(m_room->chatBoxAttachmentPath(), m_room->chatBoxText().isEmpty() ? path : m_room->chatBoxText());
if (m_connection == connection) { m_room->setChatBoxAttachmentPath({});
m_room->setChatBoxText({});
return; return;
} }
if (m_connection != nullptr) { QString handledText = m_room->chatBoxText();
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<NeoChatRoom *>(room));
});
}
Q_EMIT connectionChanged();
}
void ActionsHandler::postEdit(const QString &text) std::sort(m_room->mentions()->begin(), m_room->mentions()->end(), [](const auto &a, const auto &b) -> bool {
{ return a.cursor.anchor() > b.cursor.anchor();
const auto localId = Controller::instance().activeConnection()->userId(); });
for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); ++it) {
const auto &evt = **it; for (const auto &mention : *m_room->mentions()) {
if (const auto event = eventCast<const RoomMessageEvent>(&evt)) { handledText = handledText.replace(mention.cursor.anchor(),
if (event->senderId() == localId && event->hasTextContent()) { mention.cursor.position() - mention.cursor.anchor(),
static QRegularExpression re("^s/([^/]*)/([^/]*)(/g)?"); QStringLiteral("[%1](https://matrix.to/#/%2)").arg(mention.text, mention.id));
auto match = re.match(text); }
if (!match.hasMatch()) {
// should not happen but still make sure to send the message normally if (NeoChatConfig::allowQuickEdit()) {
// just in case. QRegularExpression sed("^s/([^/]*)/([^/]*)(/g)?$");
postMessage(text, QString(), QString(), QString(), QVariantMap(), nullptr); 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<const RoomMessageEvent>(&**it)) {
if (event->senderId() == m_room->localUser()->id() && event->hasTextContent()) {
QString originalString;
if (event->content()) {
originalString = static_cast<const Quotient::EventContent::TextContent *>(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<const Quotient::EventContent::TextContent *>(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, if (handledText.startsWith(QLatin1Char('/'))) {
const QString &attachmentPath, for (const auto &action : ActionsModel::instance().allActions()) {
const QString &replyEventId, if (handledText.indexOf(action.prefix) == 1
const QString &editEventId, && (handledText.indexOf(" ") == action.prefix.length() + 1 || handledText.length() == action.prefix.length() + 1)) {
const QVariantMap &usernames, handledText = action.handle(handledText.mid(action.prefix.length() + 1).trimmed(), m_room);
CustomEmojiModel *cem) if (action.messageType.has_value()) {
{ messageType = *action.messageType;
QString rawText = text; }
QString cleanedText = text; if (action.messageAction) {
break;
auto preprocess = [cem](const QString &it) -> QString { } else {
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("<font color='") % rainbowColors.at(i % rainbowColors.length()) % "'>" % cleanedText.at(i) % "</font>";
}
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("<span data-mx-spoiler>") % preprocess(cleanedText) % QStringLiteral("</span>");
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("<font color='") % rainbowColors.at(i % rainbowColors.length()) % "'>" % cleanedText.at(i) % "</font>";
}
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<const RoomMessageEvent>(&evt)) {
m_room->toggleReaction(event->id(), rawText);
return; 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; return;
} }
if (cleanedText.indexOf(mePrefix) == 0) { m_room->postMessage(m_room->chatBoxText(), handledText, messageType, m_room->chatBoxReplyId(), m_room->chatBoxEditId());
cleanedText = cleanedText.remove(0, mePrefix.length()); }
messageEventType = RoomMessageEvent::MsgType::Emote;
rawText = rawText.remove(0, mePrefix.length()); void ActionsHandler::checkEffects()
} else if (cleanedText.indexOf(noticePrefix) == 0) { {
cleanedText = cleanedText.remove(0, noticePrefix.length()); std::optional<QString> effect = std::nullopt;
messageEventType = RoomMessageEvent::MsgType::Notice; const auto &text = m_room->chatBoxText();
} if (text.contains("\u2744")) {
m_room->postMessage(rawText, preprocess(m_room->preprocessText(cleanedText)), messageEventType, replyEventId, editEventId); 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);
}
} }

View File

@@ -5,13 +5,11 @@
#include <QObject> #include <QObject>
namespace Quotient #include <events/roommessageevent.h>
{
class Connection;
}
class NeoChatRoom; class NeoChatRoom;
class CustomEmojiModel; class CustomEmojiModel;
class NeoChatRoom;
/// \brief Handles user interactions with NeoChat (joining room, creating room, /// \brief Handles user interactions with NeoChat (joining room, creating room,
/// sending message). Account management is handled by Controller. /// sending message). Account management is handled by Controller.
@@ -19,56 +17,29 @@ class ActionsHandler : public QObject
{ {
Q_OBJECT Q_OBJECT
/// \brief The connection that will handle sending the message. /// \brief The room that messages will be sent to.
Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
/// \brief The connection that will handle sending the message.
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
public: public:
enum MessageType {
Info,
Error,
};
Q_ENUM(MessageType);
explicit ActionsHandler(QObject *parent = nullptr); explicit ActionsHandler(QObject *parent = nullptr);
~ActionsHandler();
[[nodiscard]] Quotient::Connection *connection() const;
void setConnection(Quotient::Connection *connection);
[[nodiscard]] NeoChatRoom *room() const; [[nodiscard]] NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room); void setRoom(NeoChatRoom *room);
Q_SIGNALS: 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 roomChanged();
void connectionChanged(); void showEffect(QString effect);
public Q_SLOTS: public Q_SLOTS:
/// \brief Post a message. /// \brief Post a message.
/// ///
/// This also interprets commands if any. /// This also interprets commands if any.
void postMessage(const QString &text, void handleMessage();
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);
private: private:
Quotient::Connection *m_connection = nullptr;
NeoChatRoom *m_room = nullptr; NeoChatRoom *m_room = nullptr;
void checkEffects();
}; };
QString markdownToHTML(const QString &markdown);

401
src/actionsmodel.cpp Normal file
View File

@@ -0,0 +1,401 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "actionsmodel.h"
#include "controller.h"
#include "neochatroom.h"
#include "neochatuser.h"
#include <events/roommemberevent.h>
#include <KLocalizedString>
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<ActionsModel::Action> actions{
Action{
QStringLiteral("shrug"),
[](const QString &message, NeoChatRoom *) {
return QStringLiteral("¯\\\\_(ツ)_/¯ %1").arg(message);
},
true,
std::nullopt,
kli18n("<message>"),
kli18n("Prepends ¯\\_(ツ)_/¯ to a plain-text message"),
},
Action{
QStringLiteral("lenny"),
[](const QString &message, NeoChatRoom *) {
return QStringLiteral("( ͡° ͜ʖ ͡°) %1").arg(message);
},
true,
std::nullopt,
kli18n("<message>"),
kli18n("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"),
},
Action{
QStringLiteral("tableflip"),
[](const QString &message, NeoChatRoom *) {
return QStringLiteral("(╯°□°)╯︵ ┻━┻ %1").arg(message);
},
true,
std::nullopt,
kli18n("<message>"),
kli18n("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"),
},
Action{
QStringLiteral("unflip"),
[](const QString &message, NeoChatRoom *) {
return QStringLiteral("┬──┬ ( ゜-゜ノ) %1").arg(message);
},
true,
std::nullopt,
kli18n("<message>"),
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("<font color='%2'>%3</font>").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("<message>"),
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("<font color='%2'>%3</font>").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("<message>"),
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("<span data-mx-spoiler>%1</span>").arg(text),
RoomMessageEvent::MsgType::Text,
room->chatBoxReplyId(),
room->chatBoxEditId());
return QString();
},
false,
std::nullopt,
kli18n("<message>"),
kli18n("Sends the given message as a spoiler"),
},
Action{
QStringLiteral("me"),
[](const QString &text, NeoChatRoom *) {
return text;
},
true,
RoomMessageEvent::MsgType::Emote,
kli18n("<message>"),
kli18n("Sends the given emote"),
},
Action{
QStringLiteral("notice"),
[](const QString &text, NeoChatRoom *) {
return text;
},
true,
RoomMessageEvent::MsgType::Notice,
kli18n("<message>"),
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("'<text>' 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<RoomMemberEvent>(text)->membership() == Membership::Invite) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> is already invited to this room.", "%1 is already invited to this room.", text));
return QString();
}
if (room->currentState().get<RoomMemberEvent>(text)->membership() == Membership::Ban) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> 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("<user> is already in this room.", "%1 is already in this room.", text));
return QString();
}
room->inviteToRoom(text);
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> was invited into this room", "%1 was invited into this room", text));
return QString();
},
false,
std::nullopt,
kli18n("<user id>"),
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("'<text>' 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 <roomname>.", "You are already in room %1.", text));
return QString();
}
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Joining room <roomname>.", "Joining room %1.", text));
Controller::instance().joinRoom(text);
return QString();
},
false,
std::nullopt,
kli18n("<room alias or id>"),
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("'<text>' 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 <roomname>.", "You are already in room %1.", text));
return QString();
}
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Joining room <roomname>.", "Joining room %1.", text));
Controller::instance().joinRoom(text);
return QString();
},
false,
std::nullopt,
kli18n("<room alias or id>"),
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("'<text>' 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 <roomname>.", "Leaving room %1.", text));
room->connection()->leaveRoom(leaving);
} else {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Room <roomname> not found", "Room %1 not found.", text));
}
}
return QString();
},
false,
std::nullopt,
kli18n("[<room alias or id>]"),
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("'<text>' 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 <roomname>.", "Leaving room %1.", text));
room->connection()->leaveRoom(leaving);
} else {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Room <roomname> not found", "Room %1 not found.", text));
}
}
return QString();
},
false,
std::nullopt,
kli18n("[<room alias or id>]"),
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("'<text>' 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("<username> is already ignored.", "%1 is already ignored.", text));
return QString();
}
if (user) {
room->connection()->addToIgnoredUsers(user);
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> is now ignored", "%1 is now ignored.", text));
} else {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("<username> is not a known user", "%1 is not a known user.", text));
}
return QString();
},
false,
std::nullopt,
kli18n("<user id>"),
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("'<text>' 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("<username> is not ignored.", "%1 is not ignored.", text));
return QString();
}
room->connection()->removeFromIgnoredUsers(user);
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> is no longer ignored.", "%1 is no longer ignored.", text));
} else {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("<username> is not a known user", "%1 is not a known user.", text));
}
return QString();
},
false,
std::nullopt,
kli18n("<user id>"),
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<const RoomMessageEvent>(&evt)) {
room->toggleReaction(event->id(), text);
return QString();
}
}
}
room->toggleReaction(replyEventId, text);
return QString();
},
false,
std::nullopt,
kli18n("<reaction text>"),
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<int, QByteArray> ActionsModel::roleNames() const
{
return {
{Prefix, "prefix"},
{Description, "description"},
{CompletionType, "completionType"},
};
}
QVector<Action> &ActionsModel::allActions() const
{
return actions;
}

49
src/actionsmodel.h Normal file
View File

@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <KLazyLocalizedString>
#include <QAbstractListModel>
#include <events/roommessageevent.h>
class NeoChatRoom;
class ActionsModel : public QAbstractListModel
{
public:
struct Action {
// The prefix, without '/' and space after the word
QString prefix;
std::function<QString(const QString &, NeoChatRoom *)> 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<Quotient::RoomMessageEvent::MsgType> 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<int, QByteArray> roleNames() const override;
QVector<Action> &allActions() const;
private:
ActionsModel() = default;
};

View File

@@ -1,75 +0,0 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carlschwan@kde.org>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QVariant>
/// 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;
};

View File

@@ -7,18 +7,138 @@
#include <QQmlFileSelector> #include <QQmlFileSelector>
#include <QQuickTextDocument> #include <QQuickTextDocument>
#include <QStringBuilder> #include <QStringBuilder>
#include <QSyntaxHighlighter>
#include <QTextBlock> #include <QTextBlock>
#include <QTextDocument> #include <QTextDocument>
#include <Sonnet/BackgroundChecker>
#include <Sonnet/Settings>
#include "actionsmodel.h"
#include "completionmodel.h"
#include "neochatroom.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<QPair<int, QString>> 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<ChatDocumentHandler *>(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) ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
: QObject(parent) : QObject(parent)
, m_document(nullptr) , m_document(nullptr)
, m_cursorPosition(-1) , m_cursorPosition(-1)
, m_selectionStart(-1) , m_highlighter(new SyntaxHighlighter(this))
, m_selectionEnd(-1) , 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 QQuickTextDocument *ChatDocumentHandler::document() const
@@ -49,67 +169,12 @@ void ChatDocumentHandler::setCursorPosition(int position)
if (position == m_cursorPosition) { if (position == m_cursorPosition) {
return; return;
} }
if (m_room) {
m_cursorPosition = position; m_cursorPosition = position;
}
Q_EMIT cursorPositionChanged(); 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 NeoChatRoom *ChatDocumentHandler::room() const
{ {
return m_room; return m_room;
@@ -125,91 +190,55 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room)
Q_EMIT roomChanged(); Q_EMIT roomChanged();
} }
QVariantMap ChatDocumentHandler::getAutocompletionInfo(bool isAutocompleting) void ChatDocumentHandler::complete(int index)
{ {
QTextCursor cursor = textCursor(); if (m_completionModel->autoCompletionType() == ChatDocumentHandler::User) {
auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString();
if (cursor.block().text() == m_lastState) { auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
// ignore change, it was caused by autocompletion auto text = m_room->chatBoxText();
return QVariantMap{ auto at = text.lastIndexOf(QLatin1Char('@'), cursorPosition() - 1);
{"type", AutoCompletionType::Ignore}, 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(); return m_completionModel;
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();
} }

View File

@@ -4,30 +4,33 @@
#pragma once #pragma once
#include <QObject> #include <QObject>
#include <QTextCursor> #include <QTextCursor>
#include "userlistmodel.h"
class QTextDocument; class QTextDocument;
class QQuickTextDocument; class QQuickTextDocument;
class NeoChatRoom; class NeoChatRoom;
class Controller; class SyntaxHighlighter;
class CompletionModel;
class ChatDocumentHandler : public QObject class ChatDocumentHandler : public QObject
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(QQuickTextDocument *document READ document WRITE setDocument NOTIFY documentChanged) Q_PROPERTY(QQuickTextDocument *document READ document WRITE setDocument NOTIFY documentChanged)
Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged) Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged)
Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged) Q_PROPERTY(CompletionModel *completionModel READ completionModel NOTIFY completionModelChanged)
Q_PROPERTY(int selectionEnd READ selectionEnd WRITE setSelectionEnd NOTIFY selectionEndChanged)
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) Q_PROPERTY(NeoChatRoom *room READ room NOTIFY roomChanged)
public: public:
enum AutoCompletionType { enum AutoCompletionType {
User, User,
Room,
Emoji, Emoji,
Command, Command,
None, None,
Ignore,
}; };
Q_ENUM(AutoCompletionType) Q_ENUM(AutoCompletionType)
@@ -39,44 +42,34 @@ public:
[[nodiscard]] int cursorPosition() const; [[nodiscard]] int cursorPosition() const;
void setCursorPosition(int position); void setCursorPosition(int position);
[[nodiscard]] int selectionStart() const;
void setSelectionStart(int position);
[[nodiscard]] int selectionEnd() const;
void setSelectionEnd(int position);
[[nodiscard]] NeoChatRoom *room() const; [[nodiscard]] NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room); void setRoom(NeoChatRoom *room);
/// This function will look at the current QTextCursor and determine if there Q_INVOKABLE void complete(int index);
/// is the possibility to autocomplete it.
Q_INVOKABLE QVariantMap getAutocompletionInfo(bool isAutocompleting);
Q_INVOKABLE void replaceAutoComplete(const QString &word);
void updateCompletions();
CompletionModel *completionModel() const;
Q_SIGNALS: Q_SIGNALS:
void documentChanged(); void documentChanged();
void cursorPositionChanged(); void cursorPositionChanged();
void selectionStartChanged();
void selectionEndChanged();
void roomChanged(); void roomChanged();
void joinRoom(QString roomName); void completionModelChanged();
private: private:
[[nodiscard]] QTextCursor textCursor() const; int completionStartIndex() const;
[[nodiscard]] QTextDocument *textDocument() const;
QQuickTextDocument *m_document; QQuickTextDocument *m_document;
NeoChatRoom *m_room; NeoChatRoom *m_room = nullptr;
bool completionVisible = false;
int m_cursorPosition; int m_cursorPosition;
int m_selectionStart;
int m_selectionEnd;
int m_autoCompleteBeginPosition = -1; SyntaxHighlighter *m_highlighter = nullptr;
int m_autoCompleteEndPosition = -1;
QString m_lastState; AutoCompletionType m_completionType = None;
CompletionModel *m_completionModel = nullptr;
}; };
Q_DECLARE_METATYPE(ChatDocumentHandler::AutoCompletionType); Q_DECLARE_METATYPE(ChatDocumentHandler::AutoCompletionType);

View File

@@ -4,12 +4,14 @@
#include "clipboard.h" #include "clipboard.h"
#include <QClipboard> #include <QClipboard>
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QGuiApplication> #include <QGuiApplication>
#include <QImage> #include <QImage>
#include <QMimeData> #include <QMimeData>
#include <QRegularExpression> #include <QRegularExpression>
#include <QStandardPaths>
#include <QUrl> #include <QUrl>
Clipboard::Clipboard(QObject *parent) Clipboard::Clipboard(QObject *parent)
@@ -29,27 +31,31 @@ QImage Clipboard::image() const
return m_clipboard->image(); return m_clipboard->image();
} }
bool Clipboard::saveImage(const QUrl &localPath) const QString Clipboard::saveImage(QString localPath) const
{ {
if (!localPath.isLocalFile()) { if (localPath.isEmpty()) {
return false; 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; QDir dir;
if (!dir.exists(path)) { if (!dir.exists(localPath)) {
dir.mkpath(path); dir.mkpath(localPath);
} }
i.save(localPath.toLocalFile()); image.save(url.toLocalFile());
return true; return localPath;
} }
void Clipboard::saveText(QString message) void Clipboard::saveText(QString message)

View File

@@ -23,7 +23,7 @@ public:
[[nodiscard]] bool hasImage() const; [[nodiscard]] bool hasImage() const;
[[nodiscard]] QImage image() 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); Q_INVOKABLE void saveText(QString message);

187
src/completionmodel.cpp Normal file
View File

@@ -0,0 +1,187 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "completionmodel.h"
#include <QDebug>
#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<int, QByteArray> 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();
}

67
src/completionmodel.h Normal file
View File

@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QSortFilterProxyModel>
#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<int, QByteArray> 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;
};

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "completionproxymodel.h"
#include <QDebug>
#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;
}

View File

@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QSortFilterProxyModel>
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;
};

View File

@@ -4,7 +4,9 @@
#include <csapi/account-data.h> #include <csapi/account-data.h>
#include <csapi/content-repo.h> #include <csapi/content-repo.h>
#include "customemojimodel_p.h" #include "controller.h"
#include "customemojimodel.h"
#include <connection.h>
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
#define running isJobPending #define running isJobPending
@@ -12,35 +14,35 @@
#define running isJobRunning #define running isJobRunning
#endif #endif
void CustomEmojiModel::fetchEmojies() void CustomEmojiModel::fetchEmojis()
{ {
if (d->conn == nullptr) { if (!Controller::instance().activeConnection()) {
return; 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) { if (data == nullptr) {
return; return;
} }
QJsonObject emojies = data->contentJson()["images"].toObject(); QJsonObject emojis = data->contentJson()["images"].toObject();
// TODO: Remove with stable migration // TODO: Remove with stable migration
const auto legacyEmojies = data->contentJson()["emoticons"].toObject(); const auto legacyEmojis = data->contentJson()["emoticons"].toObject();
for (const auto &emoji : legacyEmojies.keys()) { for (const auto &emoji : legacyEmojis.keys()) {
if (!emojies.contains(emoji)) { if (!emojis.contains(emoji)) {
emojies[emoji] = legacyEmojies[emoji]; emojis[emoji] = legacyEmojis[emoji];
} }
} }
beginResetModel(); beginResetModel();
d->emojies.clear(); m_emojis.clear();
for (const auto &emoji : emojies.keys()) { for (const auto &emoji : emojis.keys()) {
const auto &data = emojies[emoji]; const auto &data = emojis[emoji];
const auto e = emoji.startsWith(":") ? emoji : (QStringLiteral(":") + emoji + QStringLiteral(":")); 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(); endResetModel();
@@ -50,11 +52,11 @@ void CustomEmojiModel::addEmoji(const QString &name, const QUrl &location)
{ {
using namespace Quotient; using namespace Quotient;
auto job = d->conn->uploadFile(location.toLocalFile()); auto job = Controller::instance().activeConnection()->uploadFile(location.toLocalFile());
if (running(job)) { if (running(job)) {
connect(job, &BaseJob::success, this, [this, name, 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 json = data != nullptr ? data->contentJson() : QJsonObject();
auto emojiData = json["images"].toObject(); auto emojiData = json["images"].toObject();
emojiData[QStringLiteral("%1").arg(name)] = QJsonObject({ emojiData[QStringLiteral("%1").arg(name)] = QJsonObject({
@@ -65,7 +67,7 @@ void CustomEmojiModel::addEmoji(const QString &name, const QUrl &location)
#endif #endif
}); });
json["images"] = emojiData; 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; using namespace Quotient;
const auto &data = d->conn->accountData("im.ponies.user_emotes"); const auto &data = Controller::instance().activeConnection()->accountData("im.ponies.user_emotes");
Q_ASSERT(data != nullptr); // something's screwed if we get here with a nullptr Q_ASSERT(data);
auto json = data->contentJson(); auto json = data->contentJson();
const QString _name = name.mid(1).chopped(1); const QString _name = name.mid(1).chopped(1);
auto emojiData = json["images"].toObject(); auto emojiData = json["images"].toObject();
@@ -97,5 +99,5 @@ void CustomEmojiModel::removeEmoji(const QString &name)
emojiData.remove(_name); emojiData.remove(_name);
json["emoticons"] = emojiData; json["emoticons"] = emojiData;
} }
d->conn->setAccountData("im.ponies.user_emotes", json); Controller::instance().activeConnection()->setAccountData("im.ponies.user_emotes", json);
} }

View File

@@ -1,48 +1,39 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com> // SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
#include "customemojimodel_p.h" #include "customemojimodel.h"
#include "controller.h"
#include "emojimodel.h" #include "emojimodel.h"
#include <connection.h> #include <connection.h>
using namespace Quotient; 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) CustomEmojiModel::CustomEmojiModel(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
, d(new Private)
{ {
connect(this, &CustomEmojiModel::connectionChanged, this, &CustomEmojiModel::fetchEmojies); connect(&Controller::instance(), &Controller::activeConnectionChanged, this, [this]() {
connect(this, &CustomEmojiModel::connectionChanged, this, [this]() { if (!Controller::instance().activeConnection()) {
if (!d->conn)
return; 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")) { if (id != QStringLiteral("im.ponies.user_emotes")) {
return; return;
} }
fetchEmojies(); fetchEmojis();
}); });
}); });
} }
CustomEmojiModel::~CustomEmojiModel()
{
}
QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const
{ {
const auto row = idx.row(); const auto row = idx.row();
if (row >= d->emojies.length()) { if (row >= m_emojis.length()) {
return QVariant(); return QVariant();
} }
const auto &data = d->emojies[row]; const auto &data = m_emojis[row];
switch (Roles(role)) { switch (Roles(role)) {
case Roles::ModelData: case Roles::ModelData:
@@ -51,6 +42,8 @@ QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const
return data.name; return data.name;
case Roles::ImageURL: case Roles::ImageURL:
return QUrl(QStringLiteral("image://mxc/") + data.url.mid(6)); return QUrl(QStringLiteral("image://mxc/") + data.url.mid(6));
case Roles::MxcUrl:
return data.url.mid(6);
} }
return QVariant(); return QVariant();
@@ -60,7 +53,7 @@ int CustomEmojiModel::rowCount(const QModelIndex &parent) const
{ {
Q_UNUSED(parent) Q_UNUSED(parent)
return d->emojies.length(); return m_emojis.length();
} }
QHash<int, QByteArray> CustomEmojiModel::roleNames() const QHash<int, QByteArray> CustomEmojiModel::roleNames() const
@@ -69,41 +62,25 @@ QHash<int, QByteArray> CustomEmojiModel::roleNames() const
{Name, "name"}, {Name, "name"},
{ImageURL, "imageURL"}, {ImageURL, "imageURL"},
{ModelData, "modelData"}, {ModelData, "modelData"},
{MxcUrl, "mxcUrl"},
}; };
} }
Connection *CustomEmojiModel::connection() const QString CustomEmojiModel::preprocessText(const QString &text)
{ {
return d->conn; auto handledText = text;
} for (const auto &emoji : std::as_const(m_emojis)) {
handledText.replace(
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(
emoji.regexp, emoji.regexp,
QStringLiteral(R"(<img data-mx-emoticon="" src="%1" alt="%2" title="%2" height="32" vertical-align="middle" />)").arg(emoji.url, emoji.name)); QStringLiteral(R"(<img data-mx-emoticon="" src="%1" alt="%2" title="%2" height="32" vertical-align="middle" />)").arg(emoji.url, emoji.name));
} }
return cp; return handledText;
} }
QVariantList CustomEmojiModel::filterModel(const QString &filter) QVariantList CustomEmojiModel::filterModel(const QString &filter)
{ {
QVariantList results; QVariantList results;
for (const auto &emoji : std::as_const(d->emojies)) { for (const auto &emoji : std::as_const(m_emojis)) {
if (results.length() >= 10) if (results.length() >= 10)
break; break;
if (!emoji.name.contains(filter, Qt::CaseInsensitive)) if (!emoji.name.contains(filter, Qt::CaseInsensitive))

View File

@@ -5,47 +5,46 @@
#include <QAbstractListModel> #include <QAbstractListModel>
#include <memory> #include <memory>
#include <QRegularExpression>
namespace Quotient struct CustomEmoji {
{ QString name; // with :semicolons:
class Connection; QString url; // mxc://
} QRegularExpression regexp;
};
class CustomEmojiModel : public QAbstractListModel class CustomEmojiModel : public QAbstractListModel
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
public: 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); static CustomEmojiModel &instance()
~CustomEmojiModel(); {
static CustomEmojiModel _instance;
// model return _instance;
}
QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override; QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override; int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> 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 QString preprocessText(const QString &it);
Q_INVOKABLE QVariantList filterModel(const QString &filter); Q_INVOKABLE QVariantList filterModel(const QString &filter);
Q_INVOKABLE void addEmoji(const QString &name, const QUrl &location); Q_INVOKABLE void addEmoji(const QString &name, const QUrl &location);
Q_INVOKABLE void removeEmoji(const QString &name); Q_INVOKABLE void removeEmoji(const QString &name);
private: private:
struct Private; explicit CustomEmojiModel(QObject *parent = nullptr);
std::unique_ptr<Private> d; QList<CustomEmoji> m_emojis;
void fetchEmojies(); void fetchEmojis();
}; };

View File

@@ -39,11 +39,9 @@
#include "actionshandler.h" #include "actionshandler.h"
#include "blurhashimageprovider.h" #include "blurhashimageprovider.h"
#include "chatboxhelper.h"
#include "chatdocumenthandler.h" #include "chatdocumenthandler.h"
#include "clipboard.h" #include "clipboard.h"
#include "collapsestateproxymodel.h" #include "collapsestateproxymodel.h"
#include "commandmodel.h"
#include "controller.h" #include "controller.h"
#include "customemojimodel.h" #include "customemojimodel.h"
#include "devicesmodel.h" #include "devicesmodel.h"
@@ -76,6 +74,8 @@
#ifdef HAVE_COLORSCHEME #ifdef HAVE_COLORSCHEME
#include "colorschemer.h" #include "colorschemer.h"
#endif #endif
#include "completionmodel.h"
#include "neochatuser.h"
#ifdef HAVE_RUNNER #ifdef HAVE_RUNNER
#include "runner.h" #include "runner.h"
@@ -167,7 +167,6 @@ int main(int argc, char *argv[])
FileTypeSingleton fileTypeSingleton; FileTypeSingleton fileTypeSingleton;
Login *login = new Login(); Login *login = new Login();
ChatBoxHelper chatBoxHelper;
UrlHelper urlHelper; UrlHelper urlHelper;
#ifdef HAVE_COLORSCHEME #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, "LoginHelper", login);
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "UrlHelper", &urlHelper); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "UrlHelper", &urlHelper);
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "EmojiModel", new EmojiModel(&app)); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "EmojiModel", new EmojiModel(&app));
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "CommandModel", new CommandModel(&app));
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "AccountRegistry", &Quotient::Accounts); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "AccountRegistry", &Quotient::Accounts);
#else #else
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "AccountRegistry", &Quotient::AccountRegistry::instance()); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "AccountRegistry", &Quotient::AccountRegistry::instance());
#endif #endif
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "SpaceHierarchyCache", &SpaceHierarchyCache::instance()); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "SpaceHierarchyCache", &SpaceHierarchyCache::instance());
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "CustomEmojiModel", &CustomEmojiModel::instance());
qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler"); qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler");
qmlRegisterType<ChatBoxHelper>("org.kde.neochat", 1, 0, "ChatBoxHelper");
qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler"); qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler");
qmlRegisterType<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel"); qmlRegisterType<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel");
qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel"); qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel");
qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel"); qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel");
qmlRegisterType<CustomEmojiModel>("org.kde.neochat", 1, 0, "CustomEmojiModel");
qmlRegisterType<MessageEventModel>("org.kde.neochat", 1, 0, "MessageEventModel"); qmlRegisterType<MessageEventModel>("org.kde.neochat", 1, 0, "MessageEventModel");
qmlRegisterType<CollapseStateProxyModel>("org.kde.neochat", 1, 0, "CollapseStateProxyModel"); qmlRegisterType<CollapseStateProxyModel>("org.kde.neochat", 1, 0, "CollapseStateProxyModel");
qmlRegisterType<MessageFilterModel>("org.kde.neochat", 1, 0, "MessageFilterModel"); qmlRegisterType<MessageFilterModel>("org.kde.neochat", 1, 0, "MessageFilterModel");
@@ -210,10 +207,12 @@ int main(int argc, char *argv[])
qmlRegisterType<SortFilterSpaceListModel>("org.kde.neochat", 1, 0, "SortFilterSpaceListModel"); qmlRegisterType<SortFilterSpaceListModel>("org.kde.neochat", 1, 0, "SortFilterSpaceListModel");
qmlRegisterType<DevicesModel>("org.kde.neochat", 1, 0, "DevicesModel"); qmlRegisterType<DevicesModel>("org.kde.neochat", 1, 0, "DevicesModel");
qmlRegisterType<LinkPreviewer>("org.kde.neochat", 1, 0, "LinkPreviewer"); qmlRegisterType<LinkPreviewer>("org.kde.neochat", 1, 0, "LinkPreviewer");
qmlRegisterType<CompletionModel>("org.kde.neochat", 1, 0, "CompletionModel");
qmlRegisterUncreatableType<RoomMessageEvent>("org.kde.neochat", 1, 0, "RoomMessageEvent", "ENUM"); qmlRegisterUncreatableType<RoomMessageEvent>("org.kde.neochat", 1, 0, "RoomMessageEvent", "ENUM");
qmlRegisterUncreatableType<PushNotificationState>("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM"); qmlRegisterUncreatableType<PushNotificationState>("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM");
qmlRegisterUncreatableType<NeoChatRoomType>("org.kde.neochat", 1, 0, "NeoChatRoomType", "ENUM"); qmlRegisterUncreatableType<NeoChatRoomType>("org.kde.neochat", 1, 0, "NeoChatRoomType", "ENUM");
qmlRegisterUncreatableType<UserType>("org.kde.neochat", 1, 0, "UserType", "ENUM"); qmlRegisterUncreatableType<UserType>("org.kde.neochat", 1, 0, "UserType", "ENUM");
qmlRegisterUncreatableType<NeoChatUser>("org.kde.neochat", 1, 0, "NeoChatUser", {});
qRegisterMetaType<User *>("User*"); qRegisterMetaType<User *>("User*");
qRegisterMetaType<User *>("const User*"); qRegisterMetaType<User *>("const User*");

View File

@@ -3,8 +3,6 @@
#include "neochatroom.h" #include "neochatroom.h"
#include <cmark.h>
#include <QFileInfo> #include <QFileInfo>
#include <QMetaObject> #include <QMetaObject>
#include <QMimeDatabase> #include <QMimeDatabase>
@@ -642,24 +640,6 @@ void NeoChatRoom::removeLocalAlias(const QString &alias)
setLocalAliases(a); 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("<!-- raw HTML omitted -->", "");
result.replace("<p>", "");
result.replace("</p>", "");
return result;
}
QString msgTypeToString(MessageEventType msgType) QString msgTypeToString(MessageEventType msgType)
{ {
switch (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) void NeoChatRoom::postMessage(const QString &rawText, const QString &text, MessageEventType type, const QString &replyEventId, const QString &relateToEventId)
{ {
postHtmlMessage(rawText, text, type, replyEventId, relateToEventId); postHtmlMessage(rawText, text, type, replyEventId, relateToEventId);
@@ -1094,7 +1069,99 @@ void NeoChatRoom::reportEvent(const QString &eventId, const QString &reason)
auto job = connection()->callApi<ReportContentJob>(id(), eventId, -50, reason); auto job = connection()->callApi<ReportContentJob>(id(), eventId, -50, reason);
connect(job, &BaseJob::finished, this, [this, job]() { connect(job, &BaseJob::finished, this, [this, job]() {
if (job->error() == BaseJob::Success) { 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<NeoChatUser *>(user((*findInTimeline(m_chatBoxReplyId))->senderId()));
}
QString NeoChatRoom::chatBoxReplyMessage() const
{
if (m_chatBoxReplyId.isEmpty()) {
return {};
}
return eventToString(*static_cast<const RoomMessageEvent *>(&**findInTimeline(m_chatBoxReplyId)));
}
NeoChatUser *NeoChatRoom::chatBoxEditUser() const
{
if (m_chatBoxEditId.isEmpty()) {
return nullptr;
}
return static_cast<NeoChatUser *>(user((*findInTimeline(m_chatBoxEditId))->senderId()));
}
QString NeoChatRoom::chatBoxEditMessage() const
{
if (m_chatBoxEditId.isEmpty()) {
return {};
}
return eventToString(*static_cast<const RoomMessageEvent *>(&**findInTimeline(m_chatBoxEditId)));
}
QString NeoChatRoom::chatBoxAttachmentPath() const
{
return m_chatBoxAttachmentPath;
}
void NeoChatRoom::setChatBoxAttachmentPath(const QString &attachmentPath)
{
m_chatBoxAttachmentPath = attachmentPath;
Q_EMIT chatBoxAttachmentPathChanged();
}
QVector<Mention> *NeoChatRoom::mentions()
{
return &m_mentions;
}
QString NeoChatRoom::savedText() const
{
return m_savedText;
}
void NeoChatRoom::setSavedText(const QString &savedText)
{
m_savedText = savedText;
}

View File

@@ -6,9 +6,12 @@
#include <room.h> #include <room.h>
#include <QObject> #include <QObject>
#include <events/roomevent.h> #include <QTextCursor>
#include <qcoro/task.h> #include <qcoro/task.h>
class NeoChatUser;
class PushNotificationState : public QObject class PushNotificationState : public QObject
{ {
Q_OBJECT Q_OBJECT
@@ -24,11 +27,20 @@ public:
Q_ENUM(State); Q_ENUM(State);
}; };
struct Mention {
QTextCursor cursor;
QString text;
int start = 0;
int position = 0;
QString id;
};
class NeoChatRoom : public Quotient::Room class NeoChatRoom : public Quotient::Room
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(QVariantList usersTyping READ getUsersTyping NOTIFY typingChanged) 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(bool hasFileUploading READ hasFileUploading WRITE setHasFileUploading NOTIFY hasFileUploadingChanged)
Q_PROPERTY(int fileUploadingProgress READ fileUploadingProgress NOTIFY fileUploadingProgressChanged) Q_PROPERTY(int fileUploadingProgress READ fileUploadingProgress NOTIFY fileUploadingProgressChanged)
Q_PROPERTY(QString avatarMediaId READ avatarMediaId NOTIFY avatarChanged STORED false) 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 Q_PROPERTY(PushNotificationState::State pushNotificationState MEMBER m_currentPushNotificationState WRITE setPushNotificationState NOTIFY
pushNotificationStateChanged) 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: public:
enum MessageType {
Positive,
Info,
Error,
};
Q_ENUM(MessageType);
explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {}); explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {});
[[nodiscard]] QVariantList getUsersTyping() const; [[nodiscard]] QVariantList getUsersTyping() const;
@@ -137,6 +166,29 @@ public:
Q_INVOKABLE void setPushNotificationState(PushNotificationState::State state); 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<Mention> *mentions();
QString savedText() const;
void setSavedText(const QString &savedText);
#ifndef QUOTIENT_07 #ifndef QUOTIENT_07
Q_INVOKABLE QString htmlSafeMemberName(const QString &userId) const Q_INVOKABLE QString htmlSafeMemberName(const QString &userId) const
{ {
@@ -145,7 +197,6 @@ public:
#endif #endif
private: private:
QString m_cachedInput;
QSet<const Quotient::RoomEvent *> highlights; QSet<const Quotient::RoomEvent *> highlights;
bool m_hasFileUploading = false; bool m_hasFileUploading = false;
@@ -160,10 +211,16 @@ private:
void onAddHistoricalTimelineEvents(rev_iter_t from) override; void onAddHistoricalTimelineEvents(rev_iter_t from) override;
void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override; void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override;
static QString markdownToHTML(const QString &markdown);
QCoro::Task<void> doDeleteMessagesByUser(const QString &user); QCoro::Task<void> doDeleteMessagesByUser(const QString &user);
QCoro::Task<void> doUploadFile(QUrl url, QString body = QString()); QCoro::Task<void> doUploadFile(QUrl url, QString body = QString());
QString m_chatBoxText;
QString m_chatBoxReplyId;
QString m_chatBoxEditId;
QString m_chatBoxAttachmentPath;
QVector<Mention> m_mentions;
QString m_savedText;
private Q_SLOTS: private Q_SLOTS:
void countChanged(); void countChanged();
void updatePushNotificationState(QString type); void updatePushNotificationState(QString type);
@@ -179,14 +236,17 @@ Q_SIGNALS:
void isInviteChanged(); void isInviteChanged();
void displayNameChanged(); void displayNameChanged();
void pushNotificationStateChanged(PushNotificationState::State state); 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: public Q_SLOTS:
void uploadFile(const QUrl &url, const QString &body = QString()); void uploadFile(const QUrl &url, const QString &body = QString());
void acceptInvitation(); void acceptInvitation();
void forget(); void forget();
void sendTypingNotification(bool isTyping); void sendTypingNotification(bool isTyping);
QString preprocessText(const QString &text);
/// @param rawText The text as it was typed. /// @param rawText The text as it was typed.
/// @param cleanedText The text with link to the users. /// @param cleanedText The text with link to the users.

View File

@@ -16,6 +16,7 @@
#include <jobs/basejob.h> #include <jobs/basejob.h>
#include <user.h> #include <user.h>
#include "actionshandler.h"
#include "controller.h" #include "controller.h"
#include "neochatconfig.h" #include "neochatconfig.h"
#include "neochatroom.h" #include "neochatroom.h"
@@ -73,7 +74,7 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
std::unique_ptr<KNotificationReplyAction> replyAction(new KNotificationReplyAction(i18n("Reply"))); std::unique_ptr<KNotificationReplyAction> replyAction(new KNotificationReplyAction(i18n("Reply")));
replyAction->setPlaceholderText(i18n("Reply...")); replyAction->setPlaceholderText(i18n("Reply..."));
connect(replyAction.get(), &KNotificationReplyAction::replied, this, [room, replyEventId](const QString &text) { 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)); notification->setReplyAction(std::move(replyAction));
} }

View File

@@ -337,6 +337,9 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const
if (role == AvatarRole) { if (role == AvatarRole) {
return room->avatarMediaId(); return room->avatarMediaId();
} }
if (role == CanonicalAliasRole) {
return room->canonicalAlias();
}
if (role == TopicRole) { if (role == TopicRole) {
return room->topic(); return room->topic();
} }
@@ -429,6 +432,7 @@ QHash<int, QByteArray> RoomListModel::roleNames() const
roles[NameRole] = "name"; roles[NameRole] = "name";
roles[DisplayNameRole] = "displayName"; roles[DisplayNameRole] = "displayName";
roles[AvatarRole] = "avatar"; roles[AvatarRole] = "avatar";
roles[CanonicalAliasRole] = "canonicalAlias";
roles[TopicRole] = "topic"; roles[TopicRole] = "topic";
roles[CategoryRole] = "category"; roles[CategoryRole] = "category";
roles[UnreadCountRole] = "unreadCount"; roles[UnreadCountRole] = "unreadCount";

View File

@@ -42,6 +42,7 @@ public:
NameRole = Qt::UserRole + 1, NameRole = Qt::UserRole + 1,
DisplayNameRole, DisplayNameRole,
AvatarRole, AvatarRole,
CanonicalAliasRole,
TopicRole, TopicRole,
CategoryRole, CategoryRole,
UnreadCountRole, UnreadCountRole,

View File

@@ -10,6 +10,7 @@
#include <QDesktopServices> #include <QDesktopServices>
#include <QStandardPaths> #include <QStandardPaths>
#include <qt_connection_util.h> #include <qt_connection_util.h>
#include <QQuickTextDocument>
#include <user.h> #include <user.h>
#ifndef Q_OS_ANDROID #ifndef Q_OS_ANDROID
@@ -123,6 +124,12 @@ void RoomManager::openRoomForActiveConnection()
void RoomManager::enterRoom(NeoChatRoom *room) 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); m_lastCurrentRoom = std::exchange(m_currentRoom, room);
Q_EMIT currentRoomChanged(); Q_EMIT currentRoomChanged();
@@ -232,3 +239,15 @@ void RoomManager::leaveRoom(NeoChatRoom *room)
room->forget(); 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();
}

View File

@@ -8,6 +8,8 @@
#include <QObject> #include <QObject>
#include <uriresolver.h> #include <uriresolver.h>
#include "chatdocumenthandler.h"
class NeoChatRoom; class NeoChatRoom;
namespace Quotient namespace Quotient
@@ -29,6 +31,7 @@ class RoomManager : public QObject, public UriResolverBase
/// This property holds whether a room is currently open in NeoChat. /// This property holds whether a room is currently open in NeoChat.
/// \sa room /// \sa room
Q_PROPERTY(bool hasOpenRoom READ hasOpenRoom NOTIFY currentRoomChanged) Q_PROPERTY(bool hasOpenRoom READ hasOpenRoom NOTIFY currentRoomChanged)
Q_PROPERTY(ChatDocumentHandler *chatDocumentHandler READ chatDocumentHandler WRITE setChatDocumentHandler NOTIFY chatDocumentHandlerChanged)
public: public:
explicit RoomManager(QObject *parent = nullptr); explicit RoomManager(QObject *parent = nullptr);
@@ -64,6 +67,9 @@ public:
/// Call this when the current used connection is dropped. /// Call this when the current used connection is dropped.
Q_INVOKABLE void reset(); Q_INVOKABLE void reset();
ChatDocumentHandler *chatDocumentHandler() const;
void setChatDocumentHandler(ChatDocumentHandler *handler);
void setUrlArgument(const QString &arg); void setUrlArgument(const QString &arg);
Q_SIGNALS: Q_SIGNALS:
@@ -98,6 +104,8 @@ Q_SIGNALS:
/// Displays warning to the user. /// Displays warning to the user.
void warning(const QString &title, const QString &message); void warning(const QString &title, const QString &message);
void chatDocumentHandlerChanged();
private: private:
void openRoomForActiveConnection(); void openRoomForActiveConnection();
@@ -106,4 +114,5 @@ private:
QString m_arg; QString m_arg;
KConfig m_config; KConfig m_config;
KConfigGroup m_lastRoomConfig; KConfigGroup m_lastRoomConfig;
ChatDocumentHandler *m_chatDocumentHandler;
}; };

View File

@@ -34,7 +34,7 @@ class UserListModel : public QAbstractListModel
Q_PROPERTY(Quotient::Room *room READ room WRITE setRoom NOTIFY roomChanged) Q_PROPERTY(Quotient::Room *room READ room WRITE setRoom NOTIFY roomChanged)
public: public:
enum EventRoles { enum EventRoles {
NameRole = Qt::UserRole + 1, NameRole = Qt::DisplayRole,
UserIDRole, UserIDRole,
AvatarRole, AvatarRole,
ObjectRole, ObjectRole,