Use RichText text input to provide real mentions
This use internally now a QTextDocument and a new C++ class to manipulate the document with QTextCursor.
This commit is contained in:
@@ -116,7 +116,8 @@ ToolBar {
|
||||
keyNavigationWraps: true
|
||||
|
||||
delegate: Control {
|
||||
property string autoCompleteText: modelData.displayName ?? modelData.unicode
|
||||
property string autoCompleteText: modelData.displayName ? ("<a href=\"https://matrix.to/#/" + modelData.id + "\">" + modelData.displayName + "</a>:") : 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 + "<font color='" + rainbowColor[i % rainbowColor.length] + "'>" + text.charAt(i) + "</font>"
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ add_executable(neochat
|
||||
main.cpp
|
||||
notificationsmanager.cpp
|
||||
sortfilterroomlistmodel.cpp
|
||||
chatdocumenthandler.cpp
|
||||
../res.qrc
|
||||
)
|
||||
|
||||
|
||||
231
src/chatdocumenthandler.cpp
Normal file
231
src/chatdocumenthandler.cpp
Normal file
@@ -0,0 +1,231 @@
|
||||
#include "chatdocumenthandler.h"
|
||||
|
||||
#include <QQmlFile>
|
||||
#include <QQmlFileSelector>
|
||||
#include <QQuickTextDocument>
|
||||
#include <QTextBlock>
|
||||
#include <QTextDocument>
|
||||
#include <QStringBuilder>
|
||||
|
||||
#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("<font color='") % rainbowColors.at(i % rainbowColors.length()) % "'>" % cleanedText.at(i) % "</font>";
|
||||
}
|
||||
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();
|
||||
}
|
||||
86
src/chatdocumenthandler.h
Normal file
86
src/chatdocumenthandler.h
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QFont>
|
||||
#include <QObject>
|
||||
#include <QTextCursor>
|
||||
#include <QUrl>
|
||||
|
||||
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);
|
||||
@@ -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<AccountListModel>("org.kde.neochat", 1, 0, "AccountListModel");
|
||||
qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler");
|
||||
qmlRegisterType<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel");
|
||||
qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel");
|
||||
qmlRegisterType<MessageEventModel>("org.kde.neochat", 1, 0, "MessageEventModel");
|
||||
|
||||
Reference in New Issue
Block a user