diff --git a/imports/NeoChat/Component/ChatTextInput.qml b/imports/NeoChat/Component/ChatTextInput.qml
index 286dab1b7..de4bdf3bf 100644
--- a/imports/NeoChat/Component/ChatTextInput.qml
+++ b/imports/NeoChat/Component/ChatTextInput.qml
@@ -116,7 +116,8 @@ ToolBar {
keyNavigationWraps: true
delegate: Control {
- property string autoCompleteText: modelData.displayName ?? modelData.unicode
+ property string autoCompleteText: modelData.displayName ? ("" + modelData.displayName + ":") : modelData.unicode
+ property string displayText: modelData.displayName ?? modelData.unicode
property bool isEmoji: modelData.unicode != null
readonly property bool highlighted: autoCompleteListView.currentIndex == index
@@ -147,7 +148,7 @@ ToolBar {
Layout.fillHeight: true
visible: !isEmoji
- text: autoCompleteText
+ text: displayText
color: highlighted ? Kirigami.Theme.highlightTextColor : Kirigami.Theme.textColor
font.underline: highlighted
verticalAlignment: Text.AlignVCenter
@@ -159,7 +160,7 @@ ToolBar {
anchors.fill: parent
onClicked: {
autoCompleteListView.currentIndex = index
- inputField.replaceAutoComplete(autoCompleteText)
+ documentHandler.replaceAutoComplete(autoCompleteText)
}
}
}
@@ -221,14 +222,24 @@ ToolBar {
}
TextArea {
+ id: inputField
property real progress: 0
- property bool autoAppeared: false;
+ property bool autoAppeared: false
+
+ ChatDocumentHandler {
+ id: documentHandler
+ document: inputField.textDocument
+ cursorPosition: inputField.cursorPosition
+ selectionStart: inputField.selectionStart
+ selectionEnd: inputField.selectionEnd
+ room: currentRoom ?? null
+ }
Layout.fillWidth: true
- id: inputField
wrapMode: Text.Wrap
+ textFormat: TextEdit.RichText
placeholderText: i18n("Write your message...")
topPadding: 0
bottomPadding: 0
@@ -268,6 +279,11 @@ ToolBar {
}
Keys.onReturnPressed: {
+ if (isAutoCompleting) {
+ documentHandler.replaceAutoComplete(autoCompleteListView.currentItem.autoCompleteText)
+ isAutoCompleting = false;
+ return;
+ }
if (event.modifiers & Qt.ShiftModifier) {
insert(cursorPosition, "\n")
} else {
@@ -280,28 +296,28 @@ ToolBar {
Keys.onEscapePressed: closeAll()
- Keys.onBacktabPressed: if (isAutoCompleting) autoCompleteListView.decrementCurrentIndex()
+ Keys.onBacktabPressed: {
+ if (isAutoCompleting) {
+ autoCompleteListView.decrementCurrentIndex();
+ }
+ }
Keys.onTabPressed: {
- if (isAutoCompleting && autoAppeared === false) {
- autoAppeared = false;
+ if (!isAutoCompleting) {
+ return;
+ }
+
+ // TODO detect moved cursor
+
+ // ignore first time tab was clicked so that user can select
+ // first emoji/user
+ if (autoAppeared === false) {
autoCompleteListView.incrementCurrentIndex()
} else {
autoAppeared = false;
- autoCompleteBeginPosition = text.substring(0, cursorPosition).lastIndexOf(" ") + 1
- let autoCompletePrefix = text.substring(0, cursorPosition).split(" ").pop()
- if (!autoCompletePrefix) return
- if (autoCompletePrefix.startsWith(":")) {
- autoCompleteBeginPosition = text.substring(0, cursorPosition).lastIndexOf(" ") + 1
- autoCompleteModel = emojiModel.filterModel(autoCompletePrefix)
- } else {
- autoCompleteModel = currentRoom.getUsers(autoCompletePrefix)
- }
- if (autoCompleteModel.length === 0) return
- isAutoCompleting = true
- autoCompleteEndPosition = cursorPosition
}
- replaceAutoComplete(autoCompleteListView.currentItem.autoCompleteText)
+
+ documentHandler.replaceAutoComplete(autoCompleteListView.currentItem.autoCompleteText)
}
onTextChanged: {
@@ -310,76 +326,35 @@ ToolBar {
currentRoom.cachedInput = text
autoAppeared = false;
- if (cursorPosition !== autoCompleteBeginPosition && cursorPosition !== autoCompleteEndPosition) {
- isAutoCompleting = false;
- autoCompleteListView.currentIndex = 0;
- }
+ const autocompletionInfo = documentHandler.getAutocompletionInfo();
- let autoCompletePrefix = text.substring(0, cursorPosition).split(" ").pop();
- if (!autoCompletePrefix) {
+ if (autocompletionInfo.type === ChatDocumentHandler.Ignore) {
return;
}
- if (autoCompletePrefix.startsWith("@") || autoCompletePrefix.startsWith(":")) {
- if (autoCompletePrefix.startsWith("@")) {
- autoCompletePrefix = autoCompletePrefix.substring(1);
- autoCompleteBeginPosition = text.substring(0, cursorPosition).lastIndexOf(" ") + 1 // 1 = space
- autoCompleteModel = currentRoom.getUsers(autoCompletePrefix);
- } else {
- autoCompleteModel = emojiModel.filterModel(autoCompletePrefix);
- }
- if (autoCompleteModel.length === 0) {
- return;
- }
- isAutoCompleting = true
- autoAppeared = true;
- autoCompleteEndPosition = cursorPosition
+ if (autocompletionInfo.type === ChatDocumentHandler.None) {
+ isAutoCompleting = false;
+ autoCompleteListView.currentIndex = 0;
+ return;
}
+
+ if (autocompletionInfo.type === ChatDocumentHandler.User) {
+ autoCompleteModel = currentRoom.getUsers(autocompletionInfo.keyword);
+ } else {
+ autoCompleteModel = emojiModel.filterModel(autocompletionInfo.keyword);
+ }
+
+ if (autoCompleteModel.length === 0) {
+ return;
+ }
+ isAutoCompleting = true
+ autoAppeared = true;
+ autoCompleteEndPosition = cursorPosition
}
- function replaceAutoComplete(word) {
- remove(autoCompleteBeginPosition, autoCompleteEndPosition)
- autoCompleteEndPosition = autoCompleteBeginPosition + word.length
- insert(cursorPosition, word)
- }
-
- function postMessage(text) {
- if(!currentRoom) { return }
-
- if (hasAttachment) {
- currentRoom.uploadFile(attachmentPath, text)
- clearAttachment()
- return
- }
-
- if (text.trim().length === 0) { return }
-
- var PREFIX_ME = '/me '
- var PREFIX_NOTICE = '/notice '
- var PREFIX_RAINBOW = '/rainbow '
-
- var messageEventType = RoomMessageEvent.Text
-
- if (text.indexOf(PREFIX_RAINBOW) === 0) {
- text = text.substr(PREFIX_RAINBOW.length)
-
- var parsedText = ""
- var rainbowColor = ["#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"]
- for (var i = 0; i < text.length; i++) {
- parsedText = parsedText + "" + text.charAt(i) + ""
- }
- currentRoom.postHtmlMessage(text, parsedText, RoomMessageEvent.Text, replyEventID)
- return
- }
-
- if (text.indexOf(PREFIX_ME) === 0) {
- text = text.substr(PREFIX_ME.length)
- messageEventType = RoomMessageEvent.Emote
- } else if (text.indexOf(PREFIX_NOTICE) === 0) {
- text = text.substr(PREFIX_NOTICE.length)
- messageEventType = RoomMessageEvent.Notice
- }
-
- currentRoom.postArbitaryMessage(text, messageEventType, replyEventID)
+ function postMessage() {
+ documentHandler.postMessage(attachmentPath, replyEventID);
+ clearAttachment();
+ clear();
}
}
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index ba91a889a..c1c2864b8 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -15,6 +15,7 @@ add_executable(neochat
main.cpp
notificationsmanager.cpp
sortfilterroomlistmodel.cpp
+ chatdocumenthandler.cpp
../res.qrc
)
diff --git a/src/chatdocumenthandler.cpp b/src/chatdocumenthandler.cpp
new file mode 100644
index 000000000..f1268d5ed
--- /dev/null
+++ b/src/chatdocumenthandler.cpp
@@ -0,0 +1,231 @@
+#include "chatdocumenthandler.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "neochatroom.h"
+
+ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
+ : QObject(parent)
+ , m_document(nullptr)
+ , m_cursorPosition(-1)
+{
+}
+
+QQuickTextDocument *ChatDocumentHandler::document() const
+{
+ return m_document;
+}
+
+void ChatDocumentHandler::setDocument(QQuickTextDocument *document)
+{
+ if (document == m_document)
+ return;
+
+ if (m_document)
+ m_document->textDocument()->disconnect(this);
+ m_document = document;
+ Q_EMIT documentChanged();
+}
+
+int ChatDocumentHandler::cursorPosition() const
+{
+ return m_cursorPosition;
+}
+
+void ChatDocumentHandler::setCursorPosition(int position)
+{
+ if (position == m_cursorPosition)
+ return;
+
+ 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;
+}
+
+void ChatDocumentHandler::setRoom(NeoChatRoom *room)
+{
+ if (m_room == room)
+ return;
+
+ m_room = room;
+ Q_EMIT roomChanged();
+}
+
+QVariantMap ChatDocumentHandler::getAutocompletionInfo()
+{
+ QTextCursor cursor = textCursor();
+
+ if (cursor.block().text() == m_lastState) {
+ // ignore change, it was caused by autocompletion
+ return QVariantMap {
+ {"type", AutoCompletionType::Ignore},
+ };
+ }
+ if (m_cursorPosition != m_autoCompleteBeginPosition
+ && m_cursorPosition != m_autoCompleteEndPosition) {
+ // we moved our cursor, so cancel autocompletion
+ }
+
+ 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(":")) {
+ m_autoCompleteBeginPosition = textBeforeCursor.lastIndexOf(" ") + 1; // 1 == space
+
+ if (autoCompletePrefix.startsWith("@")) {
+ autoCompletePrefix.remove(0, 1);
+ return QVariantMap {
+ {"keyword", autoCompletePrefix},
+ {"type", AutoCompletionType::User},
+ };
+ } else {
+ return QVariantMap {
+ {"keyword", autoCompletePrefix},
+ {"type", AutoCompletionType::Emoji},
+ };
+ }
+ }
+
+ return QVariantMap {
+ {"type", AutoCompletionType::None},
+ };
+}
+
+void ChatDocumentHandler::postMessage(const QString &attachementPath, const QString &replyEventId) const
+{
+ if (!m_room || !m_document)
+ return;
+
+ QString cleanedText = m_document->textDocument()->toMarkdown();
+
+ cleanedText = cleanedText.trimmed();
+
+ if (attachementPath.length() > 0) {
+ m_room->uploadFile(attachementPath, cleanedText);
+ }
+
+ if (cleanedText.length() == 0)
+ return;
+
+ auto messageEventType = RoomMessageEvent::MsgType::Text;
+
+ const QString rainbowPrefix = QStringLiteral("/rainbow ");
+ const QString mePrefix = QStringLiteral("/me ");
+ const QString noticePrefix = QStringLiteral("/notice ");
+
+ if (cleanedText.indexOf(rainbowPrefix) == 0) {
+ cleanedText = cleanedText.remove(0, rainbowPrefix.length());
+ QString rainbowText;
+ 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" };
+
+ for (int i = 0; i < cleanedText.length(); i++) {
+ rainbowText = rainbowText % QStringLiteral("" % cleanedText.at(i) % "";
+ }
+ m_room->postHtmlMessage(cleanedText, rainbowText, messageEventType, replyEventId);
+ return;
+ }
+
+ if (cleanedText.indexOf(mePrefix) == 0) {
+ cleanedText = cleanedText.remove(0, mePrefix.length());
+ messageEventType = RoomMessageEvent::MsgType::Emote;
+ } else if (cleanedText.indexOf(noticePrefix) == 0) {
+ cleanedText = cleanedText.remove(0, noticePrefix.length());
+ messageEventType = RoomMessageEvent::MsgType::Notice;
+ }
+ m_room->postArbitaryMessage(cleanedText, messageEventType, replyEventId);
+}
+
+void ChatDocumentHandler::replaceAutoComplete(const QString &word)
+{
+ 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);
+ m_lastState = cursor.block().text();
+ cursor.endEditBlock();
+}
diff --git a/src/chatdocumenthandler.h b/src/chatdocumenthandler.h
new file mode 100644
index 000000000..679c7b76d
--- /dev/null
+++ b/src/chatdocumenthandler.h
@@ -0,0 +1,86 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Carl Schwan
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+class QTextDocument;
+class QQuickTextDocument;
+class NeoChatRoom;
+
+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(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
+
+public:
+ enum AutoCompletionType {
+ User,
+ Emoji,
+ None,
+ Ignore,
+ };
+ Q_ENUM(AutoCompletionType)
+
+ explicit ChatDocumentHandler(QObject *object = nullptr);
+
+ QQuickTextDocument *document() const;
+ void setDocument(QQuickTextDocument *document);
+
+ int cursorPosition() const;
+ void setCursorPosition(int position);
+
+ int selectionStart() const;
+ void setSelectionStart(int position);
+
+ int selectionEnd() const;
+ void setSelectionEnd(int position);
+
+ NeoChatRoom *room() const;
+ void setRoom(NeoChatRoom *room);
+
+ Q_INVOKABLE void postMessage(const QString &attachmentPath, const QString &replyEventId) const;
+
+ /// This function will look at the current QTextCursor and determine if there
+ /// is the posibility to autocomplete it.
+ Q_INVOKABLE QVariantMap getAutocompletionInfo();
+ Q_INVOKABLE void replaceAutoComplete(const QString &word);
+
+Q_SIGNALS:
+ void documentChanged();
+ void cursorPositionChanged();
+ void selectionStartChanged();
+ void selectionEndChanged();
+ void roomChanged();
+
+private:
+ QTextCursor textCursor() const;
+ QTextDocument *textDocument() const;
+
+ QQuickTextDocument *m_document;
+
+ NeoChatRoom *m_room;
+
+ int m_cursorPosition;
+ int m_selectionStart;
+ int m_selectionEnd;
+
+ int m_autoCompleteBeginPosition = -1;
+ int m_autoCompleteEndPosition = -1;
+
+ QString m_lastState;
+};
+
+Q_DECLARE_METATYPE(ChatDocumentHandler::AutoCompletionType);
diff --git a/src/main.cpp b/src/main.cpp
index 521a43f79..1b7dee376 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -40,6 +40,7 @@
#include "userdirectorylistmodel.h"
#include "userlistmodel.h"
#include "neochatconfig.h"
+#include "chatdocumenthandler.h"
using namespace Quotient;
@@ -81,6 +82,7 @@ int main(int argc, char *argv[])
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "Clipboard", &clipboard);
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "Config", config);
qmlRegisterType("org.kde.neochat", 1, 0, "AccountListModel");
+ qmlRegisterType("org.kde.neochat", 1, 0, "ChatDocumentHandler");
qmlRegisterType("org.kde.neochat", 1, 0, "RoomListModel");
qmlRegisterType("org.kde.neochat", 1, 0, "UserListModel");
qmlRegisterType("org.kde.neochat", 1, 0, "MessageEventModel");