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/knotifications': '@stable'
'libraries/kquickimageeditor': '@stable'
'frameworks/sonnet': '@stable'
- 'on': ['Windows', 'Linux', 'FreeBSD']
'require':
'frameworks/qqc2-desktop-style': '@stable'
'frameworks/kio': '@stable'
'frameworks/kwindowsystem': '@stable'
'frameworks/sonnet': '@stable'
'frameworks/kconfigwidgets': '@stable'
- 'on': ['Linux', 'FreeBSD']
'require':

View File

@@ -48,7 +48,7 @@ set_package_properties(Qt${QT_MAJOR_VERSION} PROPERTIES
TYPE REQUIRED
PURPOSE "Basic application components"
)
find_package(KF5 ${KF5_MIN_VERSION} COMPONENTS Kirigami2 I18n Notifications Config CoreAddons)
find_package(KF5 ${KF5_MIN_VERSION} COMPONENTS Kirigami2 I18n Notifications Config CoreAddons Sonnet)
set_package_properties(KF5 PROPERTIES
TYPE REQUIRED
PURPOSE "Basic application components"
@@ -76,7 +76,6 @@ else()
set_package_properties(KF5QQC2DesktopStyle PROPERTIES
TYPE RUNTIME
)
ecm_find_qmlmodule(org.kde.sonnet 1.0)
ecm_find_qmlmodule(org.kde.syntaxhighlighting 1.0)
endif()

View File

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

View File

@@ -5,8 +5,6 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import QtQuick.Templates 2.15 as T
import Qt.labs.platform 1.1 as Platform
import QtQuick.Window 2.15
import org.kde.kirigami 2.18 as Kirigami
@@ -14,25 +12,13 @@ import org.kde.neochat 1.0
ToolBar {
id: chatBar
property string replyEventId: ""
property string editEventId: ""
property alias inputFieldText: inputField.text
property alias textField: inputField
property alias emojiPaneOpened: emojiButton.checked
// store each user we autoComplete here, this will be helpful later to generate
// the matrix.to links.
// This use an hack to define: https://doc.qt.io/qt-5/qml-var.html#property-value-initialization-semantics
property var userAutocompleted: ({})
signal closeAllTriggered()
signal inputFieldForceActiveFocusTriggered()
signal messageSent()
signal pasteImageTriggered()
signal editLastUserMessage()
signal replyPreviousUserMessage()
property alias isCompleting: completionMenu.visible
onInputFieldForceActiveFocusTriggered: {
inputField.forceActiveFocus();
@@ -92,12 +78,7 @@ ToolBar {
topPadding: 0
bottomPadding: 0
property real progress: 0
property bool autoAppeared: false
//property int lineHeight: contentHeight / lineCount
text: inputFieldText
placeholderText: readOnly ? i18n("This room is encrypted. Sending encrypted messages is not yet supported.") : editEventId.length > 0 ? i18n("Edit Message") : currentRoom.usesEncryption ? i18n("Send an encrypted message…") : i18n("Send a message…")
placeholderText: readOnly ? i18n("This room is encrypted. Sending encrypted messages is not yet supported.") : currentRoom.chatBoxEditId.length > 0 ? i18n("Edit Message") : currentRoom.usesEncryption ? i18n("Send an encrypted message…") : i18n("Send a message…")
verticalAlignment: TextEdit.AlignVCenter
horizontalAlignment: TextEdit.AlignLeft
wrapMode: Text.Wrap
@@ -105,7 +86,6 @@ ToolBar {
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
Kirigami.SpellChecking.enabled: true
color: Kirigami.Theme.textColor
selectionColor: Kirigami.Theme.highlightColor
@@ -114,117 +94,54 @@ ToolBar {
selectByMouse: !Kirigami.Settings.tabletMode
ChatDocumentHandler {
id: documentHandler
document: inputField.textDocument
cursorPosition: inputField.cursorPosition
selectionStart: inputField.selectionStart
selectionEnd: inputField.selectionEnd
room: currentRoom ?? null
}
Timer {
id: repeatTimer
interval: 5000
}
function sendMessage(event) {
if (isCompleting && completionMenu.count > 0) {
chatBar.complete();
Keys.onEnterPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
inputField.insert(cursorPosition, "\n")
} else {
currentRoom.sendTypingNotification(false)
chatBar.postMessage()
chatBar.postMessage();
}
}
Keys.onReturnPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
inputField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
isCompleting = false;
}
Keys.onReturnPressed: { sendMessage(event) }
Keys.onEnterPressed: { sendMessage(event) }
Keys.onEscapePressed: {
closeAllTriggered()
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete()
}
}
Keys.onPressed: {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
chatBar.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
replyPreviousUserMessage();
let replyEvent = messageEventModel.getLatestMessageFromIndex(0)
if (replyEvent && replyEvent["event_id"]) {
currentRoom.chatBoxReplyId = replyEvent["event_id"]
}
} else if (event.key === Qt.Key_Up && inputField.text.length === 0) {
editLastUserMessage();
}
}
Keys.onBacktabPressed: {
if (event.modifiers & Qt.ControlModifier) {
switchRoomUp();
return;
}
if (!isCompleting) {
nextItemInFocusChain(false).forceActiveFocus(Qt.TabFocusReason)
return
}
if (!autoAppeared) {
let decrementedIndex = completionMenu.currentIndex - 1
// Wrap around to the last item
if (decrementedIndex < 0) {
decrementedIndex = Math.max(completionMenu.count - 1, 0) // 0 if count == 0
let editEvent = messageEventModel.getLastLocalUserMessageEventId()
if (editEvent) {
currentRoom.chatBoxEditId = editEvent["event_id"]
}
completionMenu.currentIndex = decrementedIndex
} else {
autoAppeared = false;
} else if (event.key === Qt.Key_Up && completionMenu.visible) {
completionMenu.decrementIndex()
} else if (event.key === Qt.Key_Down && completionMenu.visible) {
completionMenu.incrementIndex()
}
chatBar.complete();
}
// yes, decrement goes up and increment goes down visually.
Keys.onUpPressed: (event) => {
if (chatBar.isCompleting) {
event.accepted = true
completionMenu.listView.decrementCurrentIndex()
autoAppeared = true;
}
event.accepted = false
}
Keys.onDownPressed: (event) => {
if (chatBar.isCompleting) {
event.accepted = true
completionMenu.listView.incrementCurrentIndex()
autoAppeared = true;
}
event.accepted = false
}
Keys.onTabPressed: {
if (event.modifiers & Qt.ControlModifier) {
switchRoomDown();
return;
}
if (!isCompleting) {
nextItemInFocusChain().forceActiveFocus(Qt.TabFocusReason);
return;
}
// TODO detect moved cursor
// ignore first time tab was clicked so that user can select
// first emoji/user
if (!autoAppeared) {
let incrementedIndex = completionMenu.currentIndex + 1;
// Wrap around to the first item
if (incrementedIndex > completionMenu.count - 1) {
incrementedIndex = 0
}
completionMenu.currentIndex = incrementedIndex;
} else {
autoAppeared = false;
}
chatBar.complete();
Timer {
id: repeatTimer
interval: 5000
}
onTextChanged: {
@@ -233,58 +150,20 @@ ToolBar {
}
repeatTimer.start()
currentRoom.cachedInput = text
autoAppeared = false;
const completionInfo = documentHandler.getAutocompletionInfo(isCompleting);
if (completionInfo.type === ChatDocumentHandler.Ignore) {
if (completionInfo.keyword) {
// custom emojis
const idx = completionMenu.currentIndex;
completionMenu.model = Array.from(chatBar.customEmojiModel.filterModel(completionInfo.keyword)).concat(EmojiModel.filterModel(completionInfo.keyword))
completionMenu.currentIndex = idx;
}
return;
}
if (completionInfo.type === ChatDocumentHandler.None) {
isCompleting = false;
return;
}
completionMenu.completionType = completionInfo.type
if (completionInfo.type === ChatDocumentHandler.User) {
completionMenu.model = currentRoom.getUsers(completionInfo.keyword, 10);
} else if (completionInfo.type === ChatDocumentHandler.Command) {
completionMenu.model = CommandModel.filterModel(completionInfo.keyword);
} else {
completionMenu.model = Array.from(chatBar.customEmojiModel.filterModel(completionInfo.keyword)).concat(EmojiModel.filterModel(completionInfo.keyword))
}
if (completionMenu.model.length === 0) {
isCompleting = false;
return;
}
if (!isCompleting) {
isCompleting = true
autoAppeared = true;
completionMenu.endPosition = cursorPosition
}
currentRoom.chatBoxText = text
}
}
}
Item {
visible: !chatBoxHelper.isReplying && (!chatBoxHelper.hasAttachment || uploadingBusySpinner.running)
visible: currentRoom.chatBoxReplyId.length === 0 && (currentRoom.chatBoxAttachmentPath.length === 0 || uploadingBusySpinner.running)
implicitWidth: uploadButton.implicitWidth
implicitHeight: uploadButton.implicitHeight
ToolButton {
id: uploadButton
anchors.fill: parent
// Matrix does not allow sending attachments in replies
visible: !chatBoxHelper.isReplying && !chatBoxHelper.hasAttachment && !uploadingBusySpinner.running
visible: currentRoom.chatBoxReplyId.length === 0 && currentRoom.chatBoxAttachmentPath.length === 0 && !uploadingBusySpinner.running
icon.name: "mail-attachment"
text: i18n("Attach an image or file")
display: AbstractButton.IconOnly
@@ -295,8 +174,10 @@ ToolBar {
} else {
var fileDialog = openFileDialog.createObject(ApplicationWindow.overlay)
fileDialog.chosen.connect((path) => {
if (!path) { return }
chatBoxHelper.attachmentPath = path;
if (!path) {
return;
}
currentRoom.chatBoxAttachmentPath = path;
})
fileDialog.open()
}
@@ -339,24 +220,12 @@ ToolBar {
}
}
Action {
id: pasteAction
shortcut: StandardKey.Paste
onTriggered: {
if (Clipboard.hasImage) {
pasteImageTriggered();
}
activeFocusItem.paste();
}
}
CompletionMenu {
id: completionMenu
width: parent.width
//height: 80 //Math.min(implicitHeight, delegate.implicitHeight * 6)
height: implicitHeight
y: -height - 1
y: -height - 5
z: 1
chatDocumentHandler: documentHandler
Behavior on height {
NumberAnimation {
property: "height"
@@ -364,59 +233,42 @@ ToolBar {
easing.type: Easing.OutCubic
}
}
onCompleteTriggered: {
complete()
isCompleting = false;
}
Connections {
target: currentRoom
function onChatBoxEditIdChanged() {
chatBar.inputFieldText = currentRoom.chatBoxEditMessage
}
}
property CustomEmojiModel customEmojiModel: CustomEmojiModel {
connection: Controller.activeConnection
ChatDocumentHandler {
id: documentHandler
document: inputField.textDocument
cursorPosition: inputField.cursorPosition
selectionStart: inputField.selectionStart
selectionEnd: inputField.selectionEnd
Component.onCompleted: {
RoomManager.chatDocumentHandler = documentHandler;
}
}
function pasteImage() {
let localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png";
if (!Clipboard.saveImage(localPath)) {
let localPath = Clipboard.saveImage();
if (localPath.length === 0) {
return;
}
chatBoxHelper.attachmentPath = localPath;
currentRoom.chatBoxAttachmentPath = localPath
}
function postMessage() {
checkForFancyEffectsReason();
actionsHandler.handleMessage();
if (chatBoxHelper.hasAttachment) {
// send attachment but don't reset the text
actionsHandler.postMessage("", chatBoxHelper.attachmentPath,
chatBoxHelper.replyEventId, chatBoxHelper.editEventId, {}, this.customEmojiModel);
currentRoom.markAllMessagesAsRead();
messageSent();
return;
}
const re = /^s\/([^\/]*)\/([^\/]*)/;
if (Config.allowQuickEdit && re.test(inputField.text)) {
// send edited messages
actionsHandler.postEdit(inputField.text);
} else {
// send normal message
actionsHandler.postMessage(inputField.text.trim(), chatBoxHelper.attachmentPath,
chatBoxHelper.replyEventId, chatBoxHelper.editEventId, userAutocompleted, this.customEmojiModel);
}
currentRoom.markAllMessagesAsRead();
inputField.clear();
inputField.text = Qt.binding(function() {
return currentRoom ? currentRoom.cachedInput : "";
});
currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxEditId = "";
messageSent()
}
function complete() {
documentHandler.replaceAutoComplete(completionMenu.currentDisplayText);
if (completionMenu.completionType === ChatDocumentHandler.User
&& completionMenu.currentDisplayText.length > 0
&& completionMenu.currentItem.userId.length > 0) {
userAutocompleted[completionMenu.currentDisplayText] = completionMenu.currentItem.userId;
}
}
}

View File

@@ -4,58 +4,26 @@
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import Qt.labs.platform 1.1 as Platform
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component.ChatBox 1.0
import NeoChat.Component.Emoji 1.0
Item {
id: root
ColumnLayout {
id: chatBox
property alias inputFieldText: chatBar.inputFieldText
signal fancyEffectsReasonFound(string fancyEffect)
signal messageSent()
signal editLastUserMessage()
signal replyPreviousUserMessage()
Kirigami.Theme.colorSet: Kirigami.Theme.View
implicitWidth: {
let w = 0
for(let i = 0; i < visibleChildren.length; ++i) {
w = Math.max(w, Math.ceil(visibleChildren[i].implicitWidth))
}
return w
}
implicitHeight: {
let h = 0
for(let i = 0; i < visibleChildren.length; ++i) {
h += Math.ceil(visibleChildren[i].implicitHeight)
}
return h
}
// For some reason, this is needed to make the height animation work even though
// it used to work and height should be directly affected by implicitHeight
height: implicitHeight
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
spacing: 0
Kirigami.Separator {
id: connectionPaneSeparator
visible: connectionPane.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: connectionPane.top
z: 1
Layout.fillWidth: true
}
QQC2.Pane {
@@ -71,30 +39,25 @@ Item {
color: Kirigami.Theme.backgroundColor
}
visible: !Controller.isOnline
width: parent.width
Layout.fillWidth: true
QQC2.Label {
id: networkLabel
text: i18n("NeoChat is offline. Please check your network connection.")
}
anchors.bottom: emojiPickerLoaderSeparator.top
}
Kirigami.Separator {
id: emojiPickerLoaderSeparator
visible: emojiPickerLoader.visible
width: parent.width
Layout.fillWidth: true
height: visible ? implicitHeight : 0
anchors.bottom: emojiPickerLoader.top
z: 1
}
Loader {
id: emojiPickerLoader
active: visible
visible: chatBar.emojiPaneOpened
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: replySeparator.top
Layout.fillWidth: true
sourceComponent: QQC2.Pane {
topPadding: 0
bottomPadding: 0
@@ -106,178 +69,79 @@ Item {
onChosen: addText(emoji)
}
}
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
Kirigami.Separator {
id: replySeparator
visible: replyPane.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: replyPane.top
Layout.fillWidth: true
}
ReplyPane {
id: replyPane
visible: chatBoxHelper.isReplying || chatBoxHelper.isEditing
user: chatBoxHelper.replyUser
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: attachmentSeparator.top
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
visible: currentRoom.chatBoxReplyId.length > 0 || currentRoom.chatBoxEditId.length > 0
Layout.fillWidth: true
onReplyCancelled: {
root.focusInputField()
chatBox.focusInputField()
}
}
Kirigami.Separator {
id: attachmentSeparator
visible: attachmentPane.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: attachmentPane.top
Layout.fillWidth: true
}
AttachmentPane {
id: attachmentPane
visible: chatBoxHelper.hasAttachment
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: chatBarSeparator.top
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
visible: currentRoom.chatBoxAttachmentPath.length > 0
Layout.fillWidth: true
}
Kirigami.Separator {
id: chatBarSeparator
visible: chatBar.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: chatBar.top
Layout.fillWidth: true
}
ChatBar {
id: chatBar
visible: currentRoom.canSendEvent("m.room.message")
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: parent.bottom
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
Layout.fillWidth: true
onCloseAllTriggered: closeAll()
onMessageSent: {
closeAll()
checkForFancyEffectsReason()
root.messageSent();
}
onEditLastUserMessage: {
root.editLastUserMessage();
}
onReplyPreviousUserMessage: {
root.replyPreviousUserMessage();
}
}
function checkForFancyEffectsReason() {
if (!Config.showFancyEffects) {
return
chatBox.messageSent();
}
let text = root.inputFieldText.trim()
if (text.includes('\u{2744}')) {
root.fancyEffectsReasonFound("snowflake")
}
if (text.includes('\u{1F386}')) {
root.fancyEffectsReasonFound("fireworks")
}
if (text.includes('\u{1F387}')) {
root.fancyEffectsReasonFound("fireworks")
}
if (text.includes('\u{1F389}')) {
root.fancyEffectsReasonFound("confetti")
}
if (text.includes('\u{1F38A}')) {
root.fancyEffectsReasonFound("confetti")
Behavior on implicitHeight {
NumberAnimation {
property: "implicitHeight"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
function addText(text) {
root.inputFieldText = inputFieldText + text
chatBox.inputFieldText = inputFieldText + text
}
function insertText(str) {
root.inputFieldText = inputFieldText.substr(0, inputField.cursorPosition) + str + inputFieldText.substr(inputField.cursorPosition)
chatBox.inputFieldText = inputFieldText.substr(0, inputField.cursorPosition) + str + inputFieldText.substr(inputField.cursorPosition)
}
function focusInputField() {
chatBar.inputFieldForceActiveFocusTriggered()
}
Connections {
target: RoomManager
function onCurrentRoomChanged() {
chatBar.userAutocompleted = {};
}
}
Connections {
target: chatBoxHelper
function onShouldClearText() {
root.inputFieldText = "";
}
function onEditing(editContent, editFormatedContent) {
// Set the input field in edit mode
root.inputFieldText = editContent;
// clean autocompletion list
chatBar.userAutocompleted = {};
// Fill autocompletion list with values extracted from message.
// We can't just iterate on every user in the list and try to
// find matching display name since some users have display name
// matching frequent words and this will marks too many words as
// mentions.
const regex = /<a 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() {
chatBoxHelper.clear();
// TODO clear();
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.neochat 1.0
import NeoChat.Component 1.0
Popup {
id: control
id: completionMenu
width: parent.width
// Expose internal ListView properties.
property alias model: completionListView.model
property alias listView: completionListView
property alias currentIndex: completionListView.currentIndex
property alias currentItem: completionListView.currentItem
property alias count: completionListView.count
property alias delegate: completionListView.delegate
visible: completions.count > 0
// Autocomplee text
property string currentDisplayText: currentItem && (currentItem.displayName ?? "")
RoomListModel {
id: roomListModel
connection: Controller.activeConnection
}
property int completionType: ChatDocumentHandler.Emoji
property int beginPosition: 0
property int endPosition: 0
required property var chatDocumentHandler
Component.onCompleted: {
chatDocumentHandler.completionModel.roomListModel = roomListModel;
}
signal completeTriggered()
function incrementIndex() {
completions.incrementCurrentIndex()
}
Kirigami.Theme.colorSet: Kirigami.Theme.View
function decrementIndex() {
completions.decrementCurrentIndex()
}
function complete() {
completionMenu.chatDocumentHandler.complete(completions.currentIndex)
}
bottomPadding: 0
leftPadding: 0
rightPadding: 0
topPadding: 0
clip: true
bottomPadding: 0
implicitHeight: Math.min(completions.contentHeight, Kirigami.Units.gridUnit * 10)
onVisibleChanged: if (!visible) {
completionListView.currentIndex = 0;
}
contentItem: ListView {
id: completions
implicitHeight: Math.min(completionListView.contentHeight, Kirigami.Units.gridUnit * 10)
contentItem: ScrollView {
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ListView {
id: completionListView
implicitWidth: contentWidth
delegate: {
if (completionType === ChatDocumentHandler.Emoji) {
emojiDelegate
} else if (completionType === ChatDocumentHandler.Command) {
commandDelegate
} else if (completionType === ChatDocumentHandler.User) {
usernameDelegate
anchors.fill: parent
model: completionMenu.chatDocumentHandler.completionModel
currentIndex: 0
keyNavigationWraps: true
highlightMoveDuration: 100
delegate: Kirigami.BasicListItem {
text: model.text
subtitle: model.subtitle ?? ""
leading: RowLayout {
Kirigami.Avatar {
visible: model.icon !== "invalid"
Layout.preferredWidth: height
Layout.fillHeight: true
source: model.icon === "invalid" ? "" : ("image://mxc/" + model.icon)
name: model.text
}
}
keyNavigationWraps: true
//interactive: Window.window ? contentHeight + control.topPadding + control.bottomPadding > Window.window.height : false
clip: true
currentIndex: control.currentIndex || 0
onClicked: completionMenu.chatDocumentHandler.complete(model.index)
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
Component {
id: usernameDelegate
Kirigami.BasicListItem {
id: usernameItem
width: ListView.view.width ?? implicitWidth
property string displayName: modelData.displayName
property string userId: modelData.id
leading: Kirigami.Avatar {
implicitHeight: Kirigami.Units.gridUnit
implicitWidth: implicitHeight
source: modelData.avatarMediaId ? ("image://mxc/" + modelData.avatarMediaId) : ""
color: modelData.color ? Qt.darker(modelData.color, 1.1) : null
}
labelItem.textFormat: Text.PlainText
text: modelData.displayName
onClicked: completeTriggered();
}
}
Component {
id: emojiDelegate
Kirigami.BasicListItem {
id: emojiItem
width: ListView.view.width ?? implicitWidth
property string displayName: modelData.isCustom ? modelData.shortname : modelData.unicode
text: modelData.shortname
height: Kirigami.Units.gridUnit * 2
leading: Image {
source: modelData.isCustom ? modelData.unicode : ""
width: height
sourceSize.width: width
sourceSize.height: height
Rectangle {
anchors.fill: parent
visible: parent.status === Image.Loading
radius: height/2
gradient: ShimmerGradient { }
}
Label {
id: unicodeLabel
visible: !modelData.isCustom
font.family: 'emoji'
font.pixelSize: height - 2
text: modelData.unicode
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.fill: parent
}
}
onClicked: completeTriggered();
}
}
Component {
id: commandDelegate
Kirigami.BasicListItem {
id: commandItem
width: ListView.view.width ?? implicitWidth
text: "<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
Loader {
id: root
readonly property bool isEdit: chatBoxHelper.isEditing
property var user: null
property string avatarMediaUrl: user ? "image://mxc/" + user.avatarMediaId : ""
id: replyPane
property NeoChatUser user: currentRoom.chatBoxReplyUser ?? currentRoom.chatBoxEditUser
signal replyCancelled()
@@ -40,7 +38,7 @@ Loader {
Layout.alignment: textContentLayout.height > avatar.height ? Qt.AlignHCenter | Qt.AlignTop : Qt.AlignCenter
Layout.preferredWidth: Layout.preferredHeight
Layout.preferredHeight: fontMetrics.lineSpacing * 2 - fontMetrics.leading
source: root.avatarMediaUrl
source: user ? "image://mxc/" + currentRoom.getUser(user.id).avatarMediaId : ""
name: user ? user.displayName : ""
color: user ? user.color : "transparent"
visible: Boolean(user)
@@ -58,7 +56,7 @@ Loader {
text: {
let heading = "<b>%1</b>"
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/>"
} else {
heading = heading.arg(i18n("Replying to %1:", userName))
@@ -67,6 +65,7 @@ Loader {
return heading
}
}
//TODO edit user mentions
ScrollView {
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
@@ -81,11 +80,7 @@ Loader {
rightPadding: 0
topPadding: 0
bottomPadding: 0
text: {
const stylesheet = "<style> a{color:"+Kirigami.Theme.linkColor+";}.user-pill{}</style>";
const content = chatBoxHelper.isReplying ? chatBoxHelper.replyEventContent : chatBoxHelper.editContent;
return stylesheet + content;
}
text: "<style> a{color:" + Kirigami.Theme.linkColor + ";}.user-pill{}</style>" + (currentRoom.chatBoxEditId.length > 0 ? currentRoom.chatBoxEditMessage : currentRoom.chatBoxReplyMessage)
selectByMouse: true
selectByKeyboard: true
readOnly: true
@@ -99,15 +94,16 @@ Loader {
}
}
Button {
id: cancelReplyButton
Layout.alignment: avatar.Layout.alignment
icon.name: "dialog-cancel"
text: i18n("Cancel")
ToolButton {
display: AbstractButton.IconOnly
onClicked: {
chatBoxHelper.clear();
root.replyCancelled();
action: Kirigami.Action {
text: i18nc("@action:button", "Cancel reply")
icon.name: "dialog-close"
onTriggered: {
currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxEditId = "";
}
shortcut: "Escape"
}
ToolTip.text: text
ToolTip.visible: hovered

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,8 +30,6 @@ add_library(neochat STATIC
filetypesingleton.cpp
login.cpp
stickerevent.cpp
chatboxhelper.cpp
commandmodel.cpp
webshortcutmodel.cpp
blurhash.cpp
blurhashimageprovider.cpp
@@ -40,6 +38,9 @@ add_library(neochat STATIC
urlhelper.cpp
windowcontroller.cpp
linkpreviewer.cpp
completionmodel.cpp
completionproxymodel.cpp
actionsmodel.cpp
)
add_executable(neochat-app
@@ -84,7 +85,7 @@ else()
endif()
target_include_directories(neochat PRIVATE ${CMAKE_BINARY_DIR})
target_link_libraries(neochat PUBLIC Qt::Core Qt::Quick Qt::Qml Qt::Gui Qt::Multimedia Qt::Network Qt::QuickControls2 KF5::I18n KF5::Kirigami2 KF5::Notifications KF5::ConfigCore KF5::ConfigGui KF5::CoreAddons Quotient cmark::cmark ${QTKEYCHAIN_LIBRARIES})
target_link_libraries(neochat PUBLIC Qt::Core Qt::Quick Qt::Qml Qt::Gui Qt::Multimedia Qt::Network Qt::QuickControls2 KF5::I18n KF5::Kirigami2 KF5::Notifications KF5::ConfigCore KF5::ConfigGui KF5::CoreAddons KF5::SonnetCore Quotient cmark::cmark ${QTKEYCHAIN_LIBRARIES})
if(TARGET QCoro5::Coro)
target_link_libraries(neochat PUBLIC QCoro5::Coro)
else()

View File

@@ -3,21 +3,49 @@
#include "actionshandler.h"
#include "controller.h"
#include <csapi/joining.h>
#include <events/roommemberevent.h>
#include <cmark.h>
#include <KLocalizedString>
#include <QStringBuilder>
#include "actionsmodel.h"
#include "controller.h"
#include "customemojimodel.h"
#include "neochatconfig.h"
#include "neochatroom.h"
#include "roommanager.h"
#include "neochatuser.h"
using namespace Quotient;
QString markdownToHTML(const QString &markdown)
{
const auto str = markdown.toUtf8();
char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS);
const std::string html(tmp_buf);
free(tmp_buf);
auto result = QString::fromStdString(html).trimmed();
result.replace("<!-- raw HTML omitted -->", "");
result.replace("<p>", "");
result.replace("</p>", "");
return result;
}
ActionsHandler::ActionsHandler(QObject *parent)
: QObject(parent)
{
}
ActionsHandler::~ActionsHandler(){};
NeoChatRoom *ActionsHandler::room() const
{
return m_room;
@@ -33,298 +61,106 @@ void ActionsHandler::setRoom(NeoChatRoom *room)
Q_EMIT roomChanged();
}
Connection *ActionsHandler::connection() const
void ActionsHandler::handleMessage()
{
return m_connection;
}
void ActionsHandler::setConnection(Connection *connection)
{
if (m_connection == connection) {
checkEffects();
if (!m_room->chatBoxAttachmentPath().isEmpty()) {
auto path = m_room->chatBoxAttachmentPath();
path = path.mid(path.lastIndexOf('/') + 1);
m_room->uploadFile(m_room->chatBoxAttachmentPath(), m_room->chatBoxText().isEmpty() ? path : m_room->chatBoxText());
m_room->setChatBoxAttachmentPath({});
m_room->setChatBoxText({});
return;
}
if (m_connection != nullptr) {
disconnect(m_connection, &Connection::directChatAvailable, nullptr, nullptr);
}
m_connection = connection;
if (m_connection != nullptr) {
connect(m_connection, &Connection::directChatAvailable, this, [this](Quotient::Room *room) {
room->setDisplayed(true);
RoomManager::instance().enterRoom(qobject_cast<NeoChatRoom *>(room));
});
}
Q_EMIT connectionChanged();
}
QString handledText = m_room->chatBoxText();
void ActionsHandler::postEdit(const QString &text)
{
const auto localId = Controller::instance().activeConnection()->userId();
for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); ++it) {
const auto &evt = **it;
if (const auto event = eventCast<const RoomMessageEvent>(&evt)) {
if (event->senderId() == localId && event->hasTextContent()) {
static QRegularExpression re("^s/([^/]*)/([^/]*)(/g)?");
auto match = re.match(text);
if (!match.hasMatch()) {
// should not happen but still make sure to send the message normally
// just in case.
postMessage(text, QString(), QString(), QString(), QVariantMap(), nullptr);
std::sort(m_room->mentions()->begin(), m_room->mentions()->end(), [](const auto &a, const auto &b) -> bool {
return a.cursor.anchor() > b.cursor.anchor();
});
for (const auto &mention : *m_room->mentions()) {
handledText = handledText.replace(mention.cursor.anchor(),
mention.cursor.position() - mention.cursor.anchor(),
QStringLiteral("[%1](https://matrix.to/#/%2)").arg(mention.text, mention.id));
}
if (NeoChatConfig::allowQuickEdit()) {
QRegularExpression sed("^s/([^/]*)/([^/]*)(/g)?$");
auto match = sed.match(m_room->chatBoxText());
if (match.hasMatch()) {
const QString regex = match.captured(1);
const QString replacement = match.captured(2);
const QString flags = match.captured(3);
for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); it++) {
if (const auto event = eventCast<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,
const QString &attachmentPath,
const QString &replyEventId,
const QString &editEventId,
const QVariantMap &usernames,
CustomEmojiModel *cem)
{
QString rawText = text;
QString cleanedText = text;
auto preprocess = [cem](const QString &it) -> QString {
if (cem == nullptr) {
return it;
}
return cem->preprocessText(it);
};
for (auto it = usernames.constBegin(); it != usernames.constEnd(); it++) {
cleanedText = cleanedText.replace(it.key(), "[" + it.key() + "](https://matrix.to/#/" + it.value().toString() + ")");
}
if (attachmentPath.length() > 0) {
m_room->uploadFile(attachmentPath, cleanedText);
}
if (cleanedText.length() == 0) {
return;
}
auto messageEventType = RoomMessageEvent::MsgType::Text;
// Message commands
static const QString shrugPrefix = QStringLiteral("/shrug");
static const QString lennyPrefix = QStringLiteral("/lenny");
static const QString tableflipPrefix = QStringLiteral("/tableflip");
static const QString unflipPrefix = QStringLiteral("/unflip");
// static const QString plainPrefix = QStringLiteral("/plain "); // TODO
// static const QString htmlPrefix = QStringLiteral("/html "); // TODO
static const QString rainbowPrefix = QStringLiteral("/rainbow ");
static const QString rainbowmePrefix = QStringLiteral("/rainbowme ");
static const QString spoilerPrefix = QStringLiteral("/spoiler ");
static const QString mePrefix = QStringLiteral("/me ");
static const QString noticePrefix = QStringLiteral("/notice ");
// Actions commands
// static const QString ddgPrefix = QStringLiteral("/ddg "); // TODO
// static const QString nickPrefix = QStringLiteral("/nick "); // TODO
// static const QString meroomnickPrefix = QStringLiteral("/myroomnick "); // TODO
// static const QString roomavatarPrefix = QStringLiteral("/roomavatar "); // TODO
// static const QString myroomavatarPrefix = QStringLiteral("/myroomavatar "); // TODO
// static const QString myavatarPrefix = QStringLiteral("/myavatar "); // TODO
static const QString invitePrefix = QStringLiteral("/invite ");
static const QString joinPrefix = QStringLiteral("/join ");
static const QString joinShortPrefix = QStringLiteral("/j ");
static const QString partPrefix = QStringLiteral("/part");
static const QString leavePrefix = QStringLiteral("/leave");
static const QString ignorePrefix = QStringLiteral("/ignore ");
static const QString unignorePrefix = QStringLiteral("/unignore ");
// static const QString queryPrefix = QStringLiteral("/query "); // TODO
// static const QString msgPrefix = QStringLiteral("/msg "); // TODO
static const QString reactPrefix = QStringLiteral("/react ");
// Admin commands
static QStringList rainbowColors{"#ff2b00", "#ff5500", "#ff8000", "#ffaa00", "#ffd500", "#ffff00", "#d4ff00", "#aaff00", "#80ff00",
"#55ff00", "#2bff00", "#00ff00", "#00ff2b", "#00ff55", "#00ff80", "#00ffaa", "#00ffd5", "#00ffff",
"#00d4ff", "#00aaff", "#007fff", "#0055ff", "#002bff", "#0000ff", "#2a00ff", "#5500ff", "#7f00ff",
"#aa00ff", "#d400ff", "#ff00ff", "#ff00d4", "#ff00aa", "#ff0080", "#ff0055", "#ff002b", "#ff0000"};
if (cleanedText.indexOf(shrugPrefix) == 0) {
cleanedText = QStringLiteral("¯\\_(ツ)_/¯") % cleanedText.remove(0, shrugPrefix.length());
m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(lennyPrefix) == 0) {
cleanedText = QStringLiteral("( ͡° ͜ʖ ͡°)") % cleanedText.remove(0, lennyPrefix.length());
m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(tableflipPrefix) == 0) {
cleanedText = QStringLiteral("(╯°□°)╯︵ ┻━┻") % cleanedText.remove(0, tableflipPrefix.length());
m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(unflipPrefix) == 0) {
cleanedText = QStringLiteral("┬──┬ ( ゜-゜ノ)") % cleanedText.remove(0, unflipPrefix.length());
m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(rainbowPrefix) == 0) {
cleanedText = cleanedText.remove(0, rainbowPrefix.length());
QString rainbowText;
for (int i = 0; i < cleanedText.length(); i++) {
rainbowText = rainbowText % QStringLiteral("<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);
if (handledText.startsWith(QLatin1Char('/'))) {
for (const auto &action : ActionsModel::instance().allActions()) {
if (handledText.indexOf(action.prefix) == 1
&& (handledText.indexOf(" ") == action.prefix.length() + 1 || handledText.length() == action.prefix.length() + 1)) {
handledText = action.handle(handledText.mid(action.prefix.length() + 1).trimmed(), m_room);
if (action.messageType.has_value()) {
messageType = *action.messageType;
}
if (action.messageAction) {
break;
} else {
return;
}
}
Q_EMIT showMessage(MessageType::Error, i18n("Couldn't find a message to react to"));
return;
}
m_room->toggleReaction(replyEventId, rawText);
}
handledText = markdownToHTML(handledText);
handledText = CustomEmojiModel::instance().preprocessText(handledText);
if (handledText.length() == 0) {
return;
}
if (cleanedText.indexOf(mePrefix) == 0) {
cleanedText = cleanedText.remove(0, mePrefix.length());
messageEventType = RoomMessageEvent::MsgType::Emote;
rawText = rawText.remove(0, mePrefix.length());
} else if (cleanedText.indexOf(noticePrefix) == 0) {
cleanedText = cleanedText.remove(0, noticePrefix.length());
messageEventType = RoomMessageEvent::MsgType::Notice;
}
m_room->postMessage(rawText, preprocess(m_room->preprocessText(cleanedText)), messageEventType, replyEventId, editEventId);
m_room->postMessage(m_room->chatBoxText(), handledText, messageType, m_room->chatBoxReplyId(), m_room->chatBoxEditId());
}
void ActionsHandler::checkEffects()
{
std::optional<QString> effect = std::nullopt;
const auto &text = m_room->chatBoxText();
if (text.contains("\u2744")) {
effect = QLatin1String("snowflake");
} else if (text.contains("\u1F386")) {
effect = QLatin1String("fireworks");
} else if (text.contains("\u2F387")) {
effect = QLatin1String("fireworks");
} else if (text.contains("\u1F389")) {
effect = QLatin1String("confetti");
} else if (text.contains("\u1F38A")) {
effect = QLatin1String("confetti");
}
if (effect.has_value()) {
Q_EMIT showEffect(*effect);
}
}

View File

@@ -5,13 +5,11 @@
#include <QObject>
namespace Quotient
{
class Connection;
}
#include <events/roommessageevent.h>
class NeoChatRoom;
class CustomEmojiModel;
class NeoChatRoom;
/// \brief Handles user interactions with NeoChat (joining room, creating room,
/// sending message). Account management is handled by Controller.
@@ -19,56 +17,29 @@ class ActionsHandler : public QObject
{
Q_OBJECT
/// \brief The connection that will handle sending the message.
Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
/// \brief The connection that will handle sending the message.
/// \brief The room that messages will be sent to.
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
public:
enum MessageType {
Info,
Error,
};
Q_ENUM(MessageType);
explicit ActionsHandler(QObject *parent = nullptr);
~ActionsHandler();
[[nodiscard]] Quotient::Connection *connection() const;
void setConnection(Quotient::Connection *connection);
[[nodiscard]] NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
Q_SIGNALS:
/// \brief Show error or information message.
///
/// These messages will be displayed in the room view header.
void showMessage(ActionsHandler::MessageType messageType, QString message);
void roomChanged();
void connectionChanged();
void showEffect(QString effect);
public Q_SLOTS:
/// \brief Post a message.
///
/// This also interprets commands if any.
void postMessage(const QString &text,
const QString &attachementPath,
const QString &replyEventId,
const QString &editEventId,
const QVariantMap &usernames,
CustomEmojiModel *cem);
/// \brief Send edit instructions (.e.g s/hallo/hello/)
///
/// This will automatically edit the last message posted and send the sed
/// instruction to IRC.
void postEdit(const QString &text);
void handleMessage();
private:
Quotient::Connection *m_connection = nullptr;
NeoChatRoom *m_room = nullptr;
void checkEffects();
};
QString markdownToHTML(const QString &markdown);

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 <QQuickTextDocument>
#include <QStringBuilder>
#include <QSyntaxHighlighter>
#include <QTextBlock>
#include <QTextDocument>
#include <Sonnet/BackgroundChecker>
#include <Sonnet/Settings>
#include "actionsmodel.h"
#include "completionmodel.h"
#include "neochatroom.h"
#include "roomlistmodel.h"
class SyntaxHighlighter : public QSyntaxHighlighter
{
public:
QTextCharFormat mentionFormat;
QTextCharFormat errorFormat;
Sonnet::BackgroundChecker *checker = new Sonnet::BackgroundChecker;
Sonnet::Settings settings;
QList<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)
: QObject(parent)
, m_document(nullptr)
, m_cursorPosition(-1)
, m_selectionStart(-1)
, m_selectionEnd(-1)
, m_highlighter(new SyntaxHighlighter(this))
, m_completionModel(new CompletionModel())
{
connect(this, &ChatDocumentHandler::roomChanged, this, [this]() {
m_completionModel->setRoom(m_room);
static NeoChatRoom *previousRoom = nullptr;
if (previousRoom) {
disconnect(nullptr, &NeoChatRoom::chatBoxTextChanged, this, nullptr);
}
previousRoom = m_room;
connect(m_room, &NeoChatRoom::chatBoxTextChanged, this, [this]() {
int start = completionStartIndex();
m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start));
});
});
connect(this, &ChatDocumentHandler::documentChanged, this, [this]() {
m_highlighter->setDocument(m_document->textDocument());
});
connect(this, &ChatDocumentHandler::cursorPositionChanged, this, [this]() {
if (!m_room) {
return;
}
int start = completionStartIndex();
m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start));
});
}
int ChatDocumentHandler::completionStartIndex() const
{
if (!m_room) {
return 0;
}
const auto &cursor = cursorPosition();
const auto &text = m_room->chatBoxText();
auto start = std::min(cursor, text.size()) - 1;
while (start > -1) {
if (text.at(start) == QLatin1Char(' ')) {
start++;
break;
}
start--;
}
return start;
}
QQuickTextDocument *ChatDocumentHandler::document() const
@@ -49,67 +169,12 @@ void ChatDocumentHandler::setCursorPosition(int position)
if (position == m_cursorPosition) {
return;
}
m_cursorPosition = position;
if (m_room) {
m_cursorPosition = position;
}
Q_EMIT cursorPositionChanged();
}
int ChatDocumentHandler::selectionStart() const
{
return m_selectionStart;
}
void ChatDocumentHandler::setSelectionStart(int position)
{
if (position == m_selectionStart) {
return;
}
m_selectionStart = position;
Q_EMIT selectionStartChanged();
}
int ChatDocumentHandler::selectionEnd() const
{
return m_selectionEnd;
}
void ChatDocumentHandler::setSelectionEnd(int position)
{
if (position == m_selectionEnd) {
return;
}
m_selectionEnd = position;
Q_EMIT selectionEndChanged();
}
QTextCursor ChatDocumentHandler::textCursor() const
{
QTextDocument *doc = textDocument();
if (!doc) {
return QTextCursor();
}
QTextCursor cursor = QTextCursor(doc);
if (m_selectionStart != m_selectionEnd) {
cursor.setPosition(m_selectionStart);
cursor.setPosition(m_selectionEnd, QTextCursor::KeepAnchor);
} else {
cursor.setPosition(m_cursorPosition);
}
return cursor;
}
QTextDocument *ChatDocumentHandler::textDocument() const
{
if (!m_document) {
return nullptr;
}
return m_document->textDocument();
}
NeoChatRoom *ChatDocumentHandler::room() const
{
return m_room;
@@ -125,91 +190,55 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room)
Q_EMIT roomChanged();
}
QVariantMap ChatDocumentHandler::getAutocompletionInfo(bool isAutocompleting)
void ChatDocumentHandler::complete(int index)
{
QTextCursor cursor = textCursor();
if (cursor.block().text() == m_lastState) {
// ignore change, it was caused by autocompletion
return QVariantMap{
{"type", AutoCompletionType::Ignore},
};
if (m_completionModel->autoCompletionType() == ChatDocumentHandler::User) {
auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString();
auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('@'), cursorPosition() - 1);
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(name % " ");
cursor.setPosition(at);
cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
m_room->mentions()->push_back({cursor, name, 0, 0, id});
m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Command) {
auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('/'));
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(QStringLiteral("/%1 ").arg(command));
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Room) {
auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('#'), cursorPosition() - 1);
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(alias % " ");
cursor.setPosition(at);
cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
m_room->mentions()->push_back({cursor, alias, 0, 0, alias});
m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Emoji) {
auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char(':'));
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(shortcode);
}
QString text = cursor.block().text();
QString textBeforeCursor = text;
textBeforeCursor.truncate(m_cursorPosition);
QString autoCompletePrefix = textBeforeCursor.section(" ", -1);
if (autoCompletePrefix.isEmpty()) {
return QVariantMap{
{"type", AutoCompletionType::None},
};
}
if (autoCompletePrefix.startsWith("@") || autoCompletePrefix.startsWith(":") || autoCompletePrefix.startsWith("/")) {
m_autoCompleteBeginPosition = textBeforeCursor.lastIndexOf(" ") + 1; // 1 == space
if (autoCompletePrefix.startsWith("@")) {
autoCompletePrefix.remove(0, 1);
return QVariantMap{
{"keyword", autoCompletePrefix},
{"type", AutoCompletionType::User},
};
}
if (autoCompletePrefix.startsWith("/") && text.trimmed().length() <= 1) {
return QVariantMap{
{"keyword", autoCompletePrefix},
{"type", AutoCompletionType::Command},
};
}
if (!isAutocompleting) {
return QVariantMap{
{"keyword", autoCompletePrefix},
{"type", AutoCompletionType::Emoji},
};
} else {
return QVariantMap{
{"type", AutoCompletionType::Ignore},
{"keyword", autoCompletePrefix},
};
}
}
return QVariantMap{
{"type", AutoCompletionType::None},
};
}
void ChatDocumentHandler::replaceAutoComplete(const QString &word)
CompletionModel *ChatDocumentHandler::completionModel() const
{
QTextCursor cursor = textCursor();
if (cursor.block().text() == m_lastState) {
m_document->textDocument()->undo();
}
cursor.beginEditBlock();
cursor.select(QTextCursor::WordUnderCursor);
cursor.removeSelectedText();
cursor.deletePreviousChar();
while (!cursor.atBlockStart()) {
cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor);
if (cursor.selectedText() == " ") {
cursor.movePosition(QTextCursor::NextCharacter);
break;
}
}
cursor.insertHtml(word);
// Add space after autocomplete if not already there
if (!cursor.block().text().endsWith(QStringLiteral(" "))) {
cursor.insertText(QStringLiteral(" "));
}
m_lastState = cursor.block().text();
cursor.endEditBlock();
return m_completionModel;
}

View File

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

View File

@@ -4,12 +4,14 @@
#include "clipboard.h"
#include <QClipboard>
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QUrl>
Clipboard::Clipboard(QObject *parent)
@@ -29,27 +31,31 @@ QImage Clipboard::image() const
return m_clipboard->image();
}
bool Clipboard::saveImage(const QUrl &localPath) const
QString Clipboard::saveImage(QString localPath) const
{
if (!localPath.isLocalFile()) {
return false;
if (localPath.isEmpty()) {
localPath = QStringLiteral("file://%1/screenshots/%2.png")
.arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation),
QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-hh-mm-ss")));
}
QUrl url(localPath);
if (!url.isLocalFile()) {
return {};
}
auto image = this->image();
if (image.isNull()) {
return {};
}
auto i = image();
if (i.isNull()) {
return false;
}
QString path = QFileInfo(localPath.toLocalFile()).absolutePath();
QDir dir;
if (!dir.exists(path)) {
dir.mkpath(path);
if (!dir.exists(localPath)) {
dir.mkpath(localPath);
}
i.save(localPath.toLocalFile());
image.save(url.toLocalFile());
return true;
return localPath;
}
void Clipboard::saveText(QString message)

View File

@@ -23,7 +23,7 @@ public:
[[nodiscard]] bool hasImage() const;
[[nodiscard]] QImage image() const;
Q_INVOKABLE bool saveImage(const QUrl &localPath) const;
Q_INVOKABLE QString saveImage(QString localPath = {}) const;
Q_INVOKABLE void saveText(QString message);

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

View File

@@ -1,48 +1,39 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "customemojimodel_p.h"
#include "customemojimodel.h"
#include "controller.h"
#include "emojimodel.h"
#include <connection.h>
using namespace Quotient;
enum Roles {
Name,
ImageURL,
ModelData, // for emulating the regular emoji model's usage, otherwise the UI code would get too complicated
};
CustomEmojiModel::CustomEmojiModel(QObject *parent)
: QAbstractListModel(parent)
, d(new Private)
{
connect(this, &CustomEmojiModel::connectionChanged, this, &CustomEmojiModel::fetchEmojies);
connect(this, &CustomEmojiModel::connectionChanged, this, [this]() {
if (!d->conn)
connect(&Controller::instance(), &Controller::activeConnectionChanged, this, [this]() {
if (!Controller::instance().activeConnection()) {
return;
connect(d->conn, &Connection::accountDataChanged, this, [this](const QString &id) {
}
CustomEmojiModel::fetchEmojis();
disconnect(nullptr, &Connection::accountDataChanged, this, nullptr);
connect(Controller::instance().activeConnection(), &Connection::accountDataChanged, this, [this](const QString &id) {
if (id != QStringLiteral("im.ponies.user_emotes")) {
return;
}
fetchEmojies();
fetchEmojis();
});
});
}
CustomEmojiModel::~CustomEmojiModel()
{
}
QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const
{
const auto row = idx.row();
if (row >= d->emojies.length()) {
if (row >= m_emojis.length()) {
return QVariant();
}
const auto &data = d->emojies[row];
const auto &data = m_emojis[row];
switch (Roles(role)) {
case Roles::ModelData:
@@ -51,6 +42,8 @@ QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const
return data.name;
case Roles::ImageURL:
return QUrl(QStringLiteral("image://mxc/") + data.url.mid(6));
case Roles::MxcUrl:
return data.url.mid(6);
}
return QVariant();
@@ -60,7 +53,7 @@ int CustomEmojiModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return d->emojies.length();
return m_emojis.length();
}
QHash<int, QByteArray> CustomEmojiModel::roleNames() const
@@ -69,41 +62,25 @@ QHash<int, QByteArray> CustomEmojiModel::roleNames() const
{Name, "name"},
{ImageURL, "imageURL"},
{ModelData, "modelData"},
{MxcUrl, "mxcUrl"},
};
}
Connection *CustomEmojiModel::connection() const
QString CustomEmojiModel::preprocessText(const QString &text)
{
return d->conn;
}
void CustomEmojiModel::setConnection(Connection *it)
{
if (d->conn == it) {
return;
}
if (d->conn != nullptr) {
disconnect(d->conn, nullptr, this, nullptr);
}
d->conn = it;
Q_EMIT connectionChanged();
}
QString CustomEmojiModel::preprocessText(const QString &it)
{
auto cp = it;
for (const auto &emoji : std::as_const(d->emojies)) {
cp.replace(
auto handledText = text;
for (const auto &emoji : std::as_const(m_emojis)) {
handledText.replace(
emoji.regexp,
QStringLiteral(R"(<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 results;
for (const auto &emoji : std::as_const(d->emojies)) {
for (const auto &emoji : std::as_const(m_emojis)) {
if (results.length() >= 10)
break;
if (!emoji.name.contains(filter, Qt::CaseInsensitive))

View File

@@ -5,47 +5,46 @@
#include <QAbstractListModel>
#include <memory>
#include <QRegularExpression>
namespace Quotient
{
class Connection;
}
struct CustomEmoji {
QString name; // with :semicolons:
QString url; // mxc://
QRegularExpression regexp;
};
class CustomEmojiModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
public:
// constructors
enum Roles {
Name,
ImageURL,
ModelData, // for emulating the regular emoji model's usage, otherwise the UI code would get too complicated
MxcUrl,
};
Q_ENUM(Roles);
explicit CustomEmojiModel(QObject *parent = nullptr);
~CustomEmojiModel();
// model
static CustomEmojiModel &instance()
{
static CustomEmojiModel _instance;
return _instance;
}
QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QHash<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 QVariantList filterModel(const QString &filter);
Q_INVOKABLE void addEmoji(const QString &name, const QUrl &location);
Q_INVOKABLE void removeEmoji(const QString &name);
private:
struct Private;
std::unique_ptr<Private> d;
explicit CustomEmojiModel(QObject *parent = nullptr);
QList<CustomEmoji> m_emojis;
void fetchEmojies();
void fetchEmojis();
};

View File

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

View File

@@ -3,8 +3,6 @@
#include "neochatroom.h"
#include <cmark.h>
#include <QFileInfo>
#include <QMetaObject>
#include <QMimeDatabase>
@@ -642,24 +640,6 @@ void NeoChatRoom::removeLocalAlias(const QString &alias)
setLocalAliases(a);
}
QString NeoChatRoom::markdownToHTML(const QString &markdown)
{
const auto str = markdown.toUtf8();
char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS);
const std::string html(tmp_buf);
free(tmp_buf);
auto result = QString::fromStdString(html).trimmed();
result.replace("<!-- raw HTML omitted -->", "");
result.replace("<p>", "");
result.replace("</p>", "");
return result;
}
QString msgTypeToString(MessageEventType msgType)
{
switch (msgType) {
@@ -684,11 +664,6 @@ QString msgTypeToString(MessageEventType msgType)
}
}
QString NeoChatRoom::preprocessText(const QString &text)
{
return markdownToHTML(text);
}
void NeoChatRoom::postMessage(const QString &rawText, const QString &text, MessageEventType type, const QString &replyEventId, const QString &relateToEventId)
{
postHtmlMessage(rawText, text, type, replyEventId, relateToEventId);
@@ -1094,7 +1069,99 @@ void NeoChatRoom::reportEvent(const QString &eventId, const QString &reason)
auto job = connection()->callApi<ReportContentJob>(id(), eventId, -50, reason);
connect(job, &BaseJob::finished, this, [this, job]() {
if (job->error() == BaseJob::Success) {
Q_EMIT positiveMessage(i18n("Report sent successfully."));
Q_EMIT showMessage(Positive, i18n("Report sent successfully."));
Q_EMIT showMessage(MessageType::Positive, i18n("Report sent successfully."));
}
});
}
QString NeoChatRoom::chatBoxText() const
{
return m_chatBoxText;
}
void NeoChatRoom::setChatBoxText(const QString &text)
{
m_chatBoxText = text;
Q_EMIT chatBoxTextChanged();
}
QString NeoChatRoom::chatBoxReplyId() const
{
return m_chatBoxReplyId;
}
void NeoChatRoom::setChatBoxReplyId(const QString &replyId)
{
m_chatBoxReplyId = replyId;
Q_EMIT chatBoxReplyIdChanged();
}
QString NeoChatRoom::chatBoxEditId() const
{
return m_chatBoxEditId;
}
void NeoChatRoom::setChatBoxEditId(const QString &editId)
{
m_chatBoxEditId = editId;
Q_EMIT chatBoxEditIdChanged();
}
NeoChatUser *NeoChatRoom::chatBoxReplyUser() const
{
if (m_chatBoxReplyId.isEmpty()) {
return nullptr;
}
return static_cast<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 <QObject>
#include <events/roomevent.h>
#include <QTextCursor>
#include <qcoro/task.h>
class NeoChatUser;
class PushNotificationState : public QObject
{
Q_OBJECT
@@ -24,11 +27,20 @@ public:
Q_ENUM(State);
};
struct Mention {
QTextCursor cursor;
QString text;
int start = 0;
int position = 0;
QString id;
};
class NeoChatRoom : public Quotient::Room
{
Q_OBJECT
Q_PROPERTY(QVariantList usersTyping READ getUsersTyping NOTIFY typingChanged)
Q_PROPERTY(QString cachedInput MEMBER m_cachedInput NOTIFY cachedInputChanged)
Q_PROPERTY(bool hasFileUploading READ hasFileUploading WRITE setHasFileUploading NOTIFY hasFileUploadingChanged)
Q_PROPERTY(int fileUploadingProgress READ fileUploadingProgress NOTIFY fileUploadingProgressChanged)
Q_PROPERTY(QString avatarMediaId READ avatarMediaId NOTIFY avatarChanged STORED false)
@@ -40,7 +52,24 @@ class NeoChatRoom : public Quotient::Room
Q_PROPERTY(PushNotificationState::State pushNotificationState MEMBER m_currentPushNotificationState WRITE setPushNotificationState NOTIFY
pushNotificationStateChanged)
// Due to problems with QTextDocument, unlike the other properties here, chatBoxText is *not* used to store the text when switching rooms
Q_PROPERTY(QString chatBoxText READ chatBoxText WRITE setChatBoxText NOTIFY chatBoxTextChanged)
Q_PROPERTY(QString chatBoxReplyId READ chatBoxReplyId WRITE setChatBoxReplyId NOTIFY chatBoxReplyIdChanged)
Q_PROPERTY(QString chatBoxEditId READ chatBoxEditId WRITE setChatBoxEditId NOTIFY chatBoxEditIdChanged)
Q_PROPERTY(NeoChatUser *chatBoxReplyUser READ chatBoxReplyUser NOTIFY chatBoxReplyIdChanged)
Q_PROPERTY(QString chatBoxReplyMessage READ chatBoxReplyMessage NOTIFY chatBoxReplyIdChanged)
Q_PROPERTY(NeoChatUser *chatBoxEditUser READ chatBoxEditUser NOTIFY chatBoxEditIdChanged)
Q_PROPERTY(QString chatBoxEditMessage READ chatBoxEditMessage NOTIFY chatBoxEditIdChanged)
Q_PROPERTY(QString chatBoxAttachmentPath READ chatBoxAttachmentPath WRITE setChatBoxAttachmentPath NOTIFY chatBoxAttachmentPathChanged)
public:
enum MessageType {
Positive,
Info,
Error,
};
Q_ENUM(MessageType);
explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {});
[[nodiscard]] QVariantList getUsersTyping() const;
@@ -137,6 +166,29 @@ public:
Q_INVOKABLE void setPushNotificationState(PushNotificationState::State state);
QString chatBoxText() const;
void setChatBoxText(const QString &text);
QString chatBoxReplyId() const;
void setChatBoxReplyId(const QString &replyId);
NeoChatUser *chatBoxReplyUser() const;
QString chatBoxReplyMessage() const;
QString chatBoxEditId() const;
void setChatBoxEditId(const QString &editId);
NeoChatUser *chatBoxEditUser() const;
QString chatBoxEditMessage() const;
QString chatBoxAttachmentPath() const;
void setChatBoxAttachmentPath(const QString &attachmentPath);
QVector<Mention> *mentions();
QString savedText() const;
void setSavedText(const QString &savedText);
#ifndef QUOTIENT_07
Q_INVOKABLE QString htmlSafeMemberName(const QString &userId) const
{
@@ -145,7 +197,6 @@ public:
#endif
private:
QString m_cachedInput;
QSet<const Quotient::RoomEvent *> highlights;
bool m_hasFileUploading = false;
@@ -160,10 +211,16 @@ private:
void onAddHistoricalTimelineEvents(rev_iter_t from) override;
void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override;
static QString markdownToHTML(const QString &markdown);
QCoro::Task<void> doDeleteMessagesByUser(const QString &user);
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:
void countChanged();
void updatePushNotificationState(QString type);
@@ -179,14 +236,17 @@ Q_SIGNALS:
void isInviteChanged();
void displayNameChanged();
void pushNotificationStateChanged(PushNotificationState::State state);
void positiveMessage(const QString &message);
void showMessage(MessageType messageType, const QString &message);
void chatBoxTextChanged();
void chatBoxReplyIdChanged();
void chatBoxEditIdChanged();
void chatBoxAttachmentPathChanged();
public Q_SLOTS:
void uploadFile(const QUrl &url, const QString &body = QString());
void acceptInvitation();
void forget();
void sendTypingNotification(bool isTyping);
QString preprocessText(const QString &text);
/// @param rawText The text as it was typed.
/// @param cleanedText The text with link to the users.

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
#include <QDesktopServices>
#include <QStandardPaths>
#include <qt_connection_util.h>
#include <QQuickTextDocument>
#include <user.h>
#ifndef Q_OS_ANDROID
@@ -123,6 +124,12 @@ void RoomManager::openRoomForActiveConnection()
void RoomManager::enterRoom(NeoChatRoom *room)
{
if (m_chatDocumentHandler) {
// We're doing these things here because it is critical that they are switched at the same time
m_currentRoom->setSavedText(m_chatDocumentHandler->document()->textDocument()->toPlainText());
m_chatDocumentHandler->setRoom(room);
m_chatDocumentHandler->document()->textDocument()->setPlainText(room->savedText());
}
m_lastCurrentRoom = std::exchange(m_currentRoom, room);
Q_EMIT currentRoomChanged();
@@ -232,3 +239,15 @@ void RoomManager::leaveRoom(NeoChatRoom *room)
room->forget();
}
ChatDocumentHandler *RoomManager::chatDocumentHandler() const
{
return m_chatDocumentHandler;
}
void RoomManager::setChatDocumentHandler(ChatDocumentHandler *handler)
{
m_chatDocumentHandler = handler;
m_chatDocumentHandler->setRoom(m_currentRoom);
Q_EMIT chatDocumentHandlerChanged();
}

View File

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

View File

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