Compare commits

..

1 Commits

Author SHA1 Message Date
Joshua Goins
9f3c542d04 List relevant side effects of leaving a room
Leaving a room is a big deal, yet we don't really inform the user all
that well. It's easy to leave a room that you have to be invited back
into, or lose access to history. If you're the only admin or room
creator, this becomes even more complex.
2026-02-14 13:57:56 -05:00
167 changed files with 25016 additions and 42281 deletions

View File

@@ -178,7 +178,7 @@ add_definitions(-DQT_NO_FOREACH)
add_subdirectory(src)
if (BUILD_TESTING)
find_package(Qt6 ${QT_MIN_VERSION} NO_MODULE COMPONENTS Test HttpServer QuickTest)
find_package(Qt6 ${QT_MIN_VERSION} NO_MODULE COMPONENTS Test HttpServer)
add_subdirectory(autotests)
# add_subdirectory(appiumtests)
if (NOT ANDROID)

View File

@@ -45,6 +45,12 @@ ecm_add_test(
TEST_NAME chatbarcachetest
)
ecm_add_test(
chatdocumenthandlertest.cpp
LINK_LIBRARIES neochat Qt::Test
TEST_NAME chatdocumenthandlertest
)
ecm_add_test(
timelinemessagemodeltest.cpp
LINK_LIBRARIES neochat Qt::Test
@@ -104,39 +110,3 @@ ecm_add_test(
LINK_LIBRARIES neochat Qt::Test neochat_server Devtools
TEST_NAME modeltest
)
macro(add_qml_tests)
if (WIN32)
set(_extra_args -platform offscreen)
endif()
foreach(test ${ARGV})
add_test(NAME ${test}
COMMAND qmltest
${_extra_args}
-input ${test}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
endforeach()
endmacro()
add_executable(qmltest qmltest.cpp
chatkeyhelpertesthelper.h
chatmarkdownhelpertestwrapper.h
chattextitemhelpertesthelper.h
)
qt_add_qml_module(qmltest URI NeoChatTestUtils)
target_link_libraries(qmltest
PRIVATE
Qt6::Qml
Qt6::QuickTest
LibNeoChat
LibNeoChatplugin
)
add_qml_tests(
chattextitemhelpertest.qml
chatmarkdownhelpertest.qml
chatkeyhelpertest.qml
)

View File

@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include <QObject>
#include <QTest>
#include "chatdocumenthandler.h"
#include "neochatconfig.h"
class ChatDocumentHandlerTest : public QObject
{
Q_OBJECT
private:
ChatDocumentHandler emptyHandler;
private Q_SLOTS:
void initTestCase();
void nullComplete();
};
void ChatDocumentHandlerTest::initTestCase()
{
// HACK: this is to stop KStatusNotifierItem SEGFAULTING on cleanup.
NeoChatConfig::self()->setSystemTray(false);
}
void ChatDocumentHandlerTest::nullComplete()
{
QTest::ignoreMessage(QtWarningMsg, "complete called with m_document set to nullptr.");
emptyHandler.complete(0);
}
QTEST_MAIN(ChatDocumentHandlerTest)
#include "chatdocumenthandlertest.moc"

View File

@@ -1,88 +0,0 @@
// SPDX-FileCopyrightText: 2026 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtTest
import org.kde.neochat.libneochat
import NeoChatTestUtils
TestCase {
name: "ChatKeyHelperTest"
TextEdit {
id: textEdit
Keys.onPressed: (event) => {
event.accepted = testHelper.keyHelper.handleKey(event.key, event.modifiers);
}
}
ChatTextItemHelper {
id: textItemHelper
textItem: textEdit
}
ChatKeyHelperTestHelper {
id: testHelper
textItem: textItemHelper
}
SignalSpy {
id: spyUp
target: testHelper.keyHelper
signalName: "unhandledUp"
}
SignalSpy {
id: spyDown
target: testHelper.keyHelper
signalName: "unhandledDown"
}
SignalSpy {
id: spyDelete
target: testHelper.keyHelper
signalName: "unhandledDelete"
}
SignalSpy {
id: spyBackSpace
target: testHelper.keyHelper
signalName: "unhandledBackspace"
}
function init(): void {
textEdit.clear();
spyUp.clear();
spyDown.clear();
spyDelete.clear();
spyBackSpace.clear();
textEdit.forceActiveFocus();
}
function cleanupTestCase(): void {
testHelper.textItem = null;
textItemHelper.textItem = null;
}
function test_upDown(): void {
textEdit.insert(0, "line 1\nline 2\nline 3")
textEdit.cursorPosition = 0;
keyClick(Qt.Key_Up);
compare(spyUp.count, 1);
compare(spyDown.count, 0);
keyClick(Qt.Key_Down);
compare(spyUp.count, 1);
compare(spyDown.count, 0);
keyClick(Qt.Key_Down);
compare(spyUp.count, 1);
compare(spyDown.count, 0);
keyClick(Qt.Key_Down);
compare(spyUp.count, 1);
compare(spyDown.count, 1);
}
}

View File

@@ -1,54 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQuickItem>
#include <QQuickTextDocument>
#include <QTextCursor>
#include <QTextDocumentFragment>
#include "chatkeyhelper.h"
#include "chattextitemhelper.h"
class ChatKeyHelperTestHelper : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(ChatTextItemHelper *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged)
Q_PROPERTY(ChatKeyHelper *keyHelper READ keyHelper CONSTANT)
public:
explicit ChatKeyHelperTestHelper(QObject *parent = nullptr)
: QObject(parent)
, m_keyHelper(new ChatKeyHelper(this))
{
}
ChatTextItemHelper *textItem() const
{
return m_keyHelper->textItem;
}
void setTextItem(ChatTextItemHelper *textItem)
{
if (textItem == m_keyHelper->textItem) {
return;
}
m_keyHelper->textItem = textItem;
Q_EMIT textItemChanged();
}
ChatKeyHelper *keyHelper() const
{
return m_keyHelper;
}
Q_SIGNALS:
void textItemChanged();
private:
QPointer<ChatKeyHelper> m_keyHelper;
};

View File

@@ -1,173 +0,0 @@
// SPDX-FileCopyrightText: 2026 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtTest
import org.kde.neochat.libneochat
import NeoChatTestUtils
TestCase {
name: "ChatMarkdownHelperTest"
TextEdit {
id: textEdit
textFormat: TextEdit.RichText
}
TextEdit {
id: textEdit2
}
ChatMarkdownHelperTestWrapper {
id: chatMarkdownHelper
textItem: textEdit
}
SignalSpy {
id: spyItem
target: chatMarkdownHelper
signalName: "textItemChanged"
}
SignalSpy {
id: spyUnhandledFormat
target: chatMarkdownHelper
signalName: "unhandledBlockFormat"
}
function initTestCase(): void {
textEdit.forceActiveFocus();
}
function cleanup(): void {
chatMarkdownHelper.clear();
compare(chatMarkdownHelper.checkText(""), true);
compare(chatMarkdownHelper.checkFormats([]), true);
compare(textEdit.cursorPosition, 0);
}
function test_item(): void {
spyItem.clear();
compare(chatMarkdownHelper.textItem, textEdit);
chatMarkdownHelper.textItem = textEdit2;
compare(chatMarkdownHelper.textItem, textEdit2);
chatMarkdownHelper.textItem = textEdit;
compare(chatMarkdownHelper.textItem, textEdit);
}
function test_textFormat_data() {
return [
{tag: "bold", input: "**b** ", outText: ["*", "**", "b", "b*", "b**", "b "], outFormats: [[], [], [RichFormat.Bold], [RichFormat.Bold], [RichFormat.Bold], []], unhandled: 0},
{tag: "italic", input: "*i* ", outText: ["*", "i", "i*", "i "], outFormats: [[], [RichFormat.Italic], [RichFormat.Italic], []], unhandled: 0},
{tag: "heading 1", input: "# h", outText: ["#", "# ", "h"], outFormats: [[], [], [RichFormat.Bold, RichFormat.Heading1]], unhandled: 0},
{tag: "heading 2", input: "## h", outText: ["#", "##", "## ", "h"], outFormats: [[], [], [], [RichFormat.Bold, RichFormat.Heading2]], unhandled: 0},
{tag: "heading 3", input: "### h", outText: ["#", "##", "###", "### ", "h"], outFormats: [[], [], [], [], [RichFormat.Bold, RichFormat.Heading3]], unhandled: 0},
{tag: "heading 4", input: "#### h", outText: ["#", "##", "###", "####", "#### ", "h"], outFormats: [[], [], [], [], [], [RichFormat.Bold, RichFormat.Heading4]], unhandled: 0},
{tag: "heading 5", input: "##### h", outText: ["#", "##", "###", "####", "#####", "##### ", "h"], outFormats: [[], [], [], [], [], [], [RichFormat.Bold, RichFormat.Heading5]], unhandled: 0},
{tag: "heading 6", input: "###### h", outText: ["#", "##", "###", "####", "#####", "######", "###### ", "h"], outFormats: [[], [], [], [], [], [] ,[], [RichFormat.Bold, RichFormat.Heading6]], unhandled: 0},
{tag: "quote", input: "> q", outText: [">", "> ", "q"], outFormats: [[], [], []], unhandled: 1},
{tag: "quote - no space", input: ">q", outText: [">", "q"], outFormats: [[], [], []], unhandled: 1},
{tag: "unorderedlist 1", input: "* l", outText: ["*", "* ", "l"], outFormats: [[], [], [RichFormat.UnorderedList]], unhandled: 0},
{tag: "unorderedlist 2", input: "- l", outText: ["-", "- ", "l"], outFormats: [[], [], [RichFormat.UnorderedList]], unhandled: 0},
{tag: "orderedlist 1", input: "1. l", outText: ["1", "1.", "1. ", "l"], outFormats: [[], [], [], [RichFormat.OrderedList]], unhandled: 0},
{tag: "orderedlist 2", input: "1) l", outText: ["1", "1)", "1) ", "l"], outFormats: [[], [], [], [RichFormat.OrderedList]], unhandled: 0},
{tag: "inline code", input: "`c` ", outText: ["`", "c", "c`", "c "], outFormats: [[], [RichFormat.InlineCode], [RichFormat.InlineCode], []], unhandled: 0},
{tag: "code", input: "``` ", outText: ["`", "``", "```", " "], outFormats: [[], [], [], []], unhandled: 1},
{tag: "strikethrough", input: "~~s~~ ", outText: ["~", "~~", "s", "s~", "s~~", "s "], outFormats: [[], [], [RichFormat.Strikethrough], [RichFormat.Strikethrough], [RichFormat.Strikethrough], []], unhandled: 0},
{tag: "underline", input: "_u_ ", outText: ["_", "u", "u_", "u "], outFormats: [[], [RichFormat.Underline], [RichFormat.Underline], []], unhandled: 0},
{tag: "multiple closable", input: "***_~~t~~_*** ", outText: ["*", "**", "*", "_", "~", "~~", "t", "t~", "t~~", "t_", "t*", "t**", "t*", "t "], outFormats: [[], [], [RichFormat.Bold], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Italic], []], unhandled: 0},
{tag: "nonclosable closable", input: "* **b** ", outText: ["*", "* ", "*", "**", "b", "b*", "b**", "b "], outFormats: [[], [], [RichFormat.UnorderedList], [RichFormat.UnorderedList], [RichFormat.Bold, RichFormat.UnorderedList], [RichFormat.Bold, RichFormat.UnorderedList], [RichFormat.Bold, RichFormat.UnorderedList], [RichFormat.UnorderedList]], unhandled: 0},
{tag: "not at line start", input: " 1) ", outText: [" ", " 1", " 1)", " 1) "], outFormats: [[], [], [], []], unhandled: 0},
]
}
function test_textFormat(data): void {
spyUnhandledFormat.clear();
compare(spyUnhandledFormat.count, 0);
for (let i = 0; i < data.input.length; i++) {
keyClick(data.input[i]);
compare(chatMarkdownHelper.checkText(data.outText[i]), true);
compare(chatMarkdownHelper.checkFormats(data.outFormats[i]), true);
}
compare(spyUnhandledFormat.count, data.unhandled);
}
function test_backspace(): void {
keyClick("*");
compare(chatMarkdownHelper.checkText("*"), true);
compare(chatMarkdownHelper.checkFormats([]), true);
keyClick("*");
compare(chatMarkdownHelper.checkText("**"), true);
compare(chatMarkdownHelper.checkFormats([]), true);
keyClick("b");
compare(chatMarkdownHelper.checkText("b"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
keyClick("o");
compare(chatMarkdownHelper.checkText("bo"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
keyClick("l");
compare(chatMarkdownHelper.checkText("bol"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
keyClick("d");
compare(chatMarkdownHelper.checkText("bold"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
keyClick(Qt.Key_Backspace);
compare(chatMarkdownHelper.checkText("bol"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
keyClick(Qt.Key_Backspace);
compare(chatMarkdownHelper.checkText("bo"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
keyClick("*");
compare(chatMarkdownHelper.checkText("bo*"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
keyClick("*");
compare(chatMarkdownHelper.checkText("bo**"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
keyClick(" ");
compare(chatMarkdownHelper.checkText("bo "), true);
compare(chatMarkdownHelper.checkFormats([]), true);
}
function test_cursorMove(): void {
keyClick("t");
keyClick("e");
keyClick("s");
keyClick("t");
compare(chatMarkdownHelper.checkText("test"), true);
compare(chatMarkdownHelper.checkFormats([]), true);
keyClick("*");
keyClick("*");
keyClick("b");
compare(chatMarkdownHelper.checkText("testb"), true);
compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true);
textEdit.cursorPosition = 2;
keyClick("*");
keyClick("*");
keyClick("b");
compare(chatMarkdownHelper.checkText("tebstb"), true);
compare(chatMarkdownHelper.checkFormats([]), true);
}
function test_insertText(): void {
textEdit.insert(0, "test");
compare(chatMarkdownHelper.checkText("test"), true);
compare(chatMarkdownHelper.checkFormats([]), true);
textEdit.insert(4, "**b");
compare(chatMarkdownHelper.checkText("test**b"), true);
compare(chatMarkdownHelper.checkFormats([]), true);
textEdit.clear();
textEdit.insert(0, "test");
compare(chatMarkdownHelper.checkText("test"), true);
compare(chatMarkdownHelper.checkFormats([]), true);
textEdit.insert(2, "**b");
compare(chatMarkdownHelper.checkText("te**bst"), true);
compare(chatMarkdownHelper.checkFormats([]), true);
}
}

View File

@@ -1,82 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQuickItem>
#include <QTextCursor>
#include "chatmarkdownhelper.h"
#include "chattextitemhelper.h"
#include "enums/richformat.h"
class ChatMarkdownHelperTestWrapper : public QObject
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The QML text Item the ChatMerkdownHelper is handling.
*/
Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged)
public:
explicit ChatMarkdownHelperTestWrapper(QObject *parent = nullptr)
: QObject(parent)
, m_chatMarkdownHelper(new ChatMarkdownHelper(this))
, m_textItem(new ChatTextItemHelper(this))
{
m_chatMarkdownHelper->setTextItem(m_textItem);
connect(m_chatMarkdownHelper, &ChatMarkdownHelper::textItemChanged, this, &ChatMarkdownHelperTestWrapper::textItemChanged);
connect(m_chatMarkdownHelper, &ChatMarkdownHelper::unhandledBlockFormat, this, &ChatMarkdownHelperTestWrapper::unhandledBlockFormat);
}
QQuickItem *textItem() const
{
return m_textItem->textItem();
}
void setTextItem(QQuickItem *textItem)
{
m_textItem->setTextItem(textItem);
}
Q_INVOKABLE bool checkText(const QString &text)
{
const auto doc = m_textItem->document();
if (!doc) {
return false;
}
return text == doc->toPlainText();
}
Q_INVOKABLE bool checkFormats(QList<RichFormat::Format> formats)
{
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor) == formats;
}
Q_INVOKABLE void clear()
{
auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return;
}
cursor.select(QTextCursor::Document);
cursor.removeSelectedText();
cursor.setBlockCharFormat(RichFormat::charFormatForFormat(RichFormat::Paragraph));
cursor.setBlockFormat(RichFormat::blockFormatForFormat(RichFormat::Paragraph));
}
Q_SIGNALS:
void textItemChanged();
void unhandledBlockFormat(RichFormat::Format format);
private:
QPointer<ChatMarkdownHelper> m_chatMarkdownHelper;
QPointer<ChatTextItemHelper> m_textItem;
};

View File

@@ -1,301 +0,0 @@
// SPDX-FileCopyrightText: 2026 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtTest
import org.kde.neochat.libneochat
import NeoChatTestUtils
TestCase {
name: "ChatTextItemHelperTest"
TextEdit {
id: textEdit
}
TextEdit {
id: textEdit2
}
ChatTextItemHelper {
id: textItemHelper
textItem: textEdit
}
ChatTextItemHelperTestHelper {
id: testHelper
textItem: textItemHelper
}
SignalSpy {
id: spyItem
target: textItemHelper
signalName: "textItemChanged"
}
SignalSpy {
id: spyContentsChanged
target: textItemHelper
signalName: "contentsChanged"
}
SignalSpy {
id: spyContentsChange
target: textItemHelper
signalName: "contentsChange"
}
SignalSpy {
id: spyCursor
target: textItemHelper
signalName: "cursorPositionChanged"
}
function init(): void {
testHelper.setFixedChars("", "");
textEdit.clear();
textEdit2.clear();
spyItem.clear();
spyContentsChange.clear();
spyContentsChanged.clear();
spyCursor.clear();
}
function cleanupTestCase(): void {
testHelper.textItem = null;
textItemHelper.textItem = null;
}
function test_item(): void {
compare(textItemHelper.textItem, textEdit);
compare(spyItem.count, 0);
textItemHelper.textItem = textEdit2;
compare(textItemHelper.textItem, textEdit2);
compare(spyItem.count, 1);
textItemHelper.textItem = textEdit;
compare(textItemHelper.textItem, textEdit);
compare(spyItem.count, 2);
}
function test_fixedChars(): void {
textEdit.forceActiveFocus();
testHelper.setFixedChars("1", "2");
compare(textEdit.text, "12");
compare(textEdit.cursorPosition, 1);
compare(spyCursor.count, 0);
keyClick("b");
compare(textEdit.text, "1b2");
compare(textEdit.cursorPosition, 2);
compare(spyCursor.count, 1);
keyClick(Qt.Key_Left);
compare(textEdit.text, "1b2");
compare(textEdit.cursorPosition, 1);
compare(spyCursor.count, 2);
keyClick(Qt.Key_Left);
compare(textEdit.text, "1b2");
compare(textEdit.cursorPosition, 1);
compare(spyCursor.count, 3);
keyClick(Qt.Key_Right);
compare(textEdit.text, "1b2");
compare(textEdit.cursorPosition, 2);
compare(spyCursor.count, 4);
keyClick(Qt.Key_Right);
compare(textEdit.text, "1b2");
compare(textEdit.cursorPosition, 2);
compare(spyCursor.count, 5);
}
function test_document(): void {
// We can't get to the QTextDocument from QML so we have to use a helper function.
compare(testHelper.compareDocuments(textEdit.textDocument), true);
textEdit.insert(0, "test text");
compare(testHelper.lineCount(), 1);
textEdit.insert(textEdit.text.length, "\ntest text");
compare(testHelper.lineCount(), 2);
textEdit.clear()
compare(textEdit.text.length, 0);
}
function test_takeFirstBlock(): void {
textEdit.insert(0, "test text");
compare(testHelper.firstBlockText(), "test text");
compare(textEdit.text.length, 0);
textEdit.insert(0, "test text\nmore test text");
compare(testHelper.firstBlockText(), "test text");
compare(textEdit.text, "more test text");
compare(testHelper.firstBlockText(), "more test text");
compare(textEdit.text, "");
compare(textEdit.text.length, 0);
}
function test_fillFragments(): void {
textEdit.insert(0, "before fragment\nmid fragment\nafter fragment");
compare(testHelper.checkFragments("before fragment\nmid fragment", "after fragment", ""), true);
textEdit.clear();
textEdit.insert(0, "before fragment\nmid fragment\nafter fragment");
textEdit.cursorPosition = 16;
compare(testHelper.checkFragments("before fragment", "mid fragment", "after fragment"), true);
textEdit.clear();
textEdit.insert(0, "before fragment\nmid fragment\nafter fragment");
textEdit.cursorPosition = 29;
compare(testHelper.checkFragments("before fragment\nmid fragment", "after fragment", ""), true);
textEdit.clear();
}
function test_insertFragment(): void {
testHelper.insertFragment("test text");
compare(textEdit.text, "test text");
compare(textEdit.cursorPosition, 9);
testHelper.insertFragment("beginning ", 1);
compare(textEdit.text, "beginning test text");
compare(textEdit.cursorPosition, 10);
testHelper.insertFragment(" end", 2);
compare(textEdit.text, "beginning test text end");
compare(textEdit.cursorPosition, 23);
textEdit.clear();
testHelper.insertFragment("test text", 0, true);
compare(textEdit.text, "test text");
compare(textEdit.cursorPosition, 0);
}
function test_cursor(): void {
// We can't get to the QTextCursor from QML so we have to use a helper function.
compare(testHelper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true);
compare(textEdit.cursorPosition, testHelper.cursorPosition());
// Check we get the appropriate content and cursor change signals when inserting text.
textEdit.insert(0, "test text")
compare(spyContentsChange.count, 1);
compare(spyContentsChange.signalArguments[0][0], 0);
compare(spyContentsChange.signalArguments[0][1], 0);
compare(spyContentsChange.signalArguments[0][2], 9);
compare(spyContentsChanged.count, 1);
compare(spyCursor.count, 1);
compare(spyCursor.signalArguments[0][0], true);
compare(testHelper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true);
compare(textEdit.cursorPosition, testHelper.cursorPosition());
// Check we get only get a cursor change signal when moving the cursor.
textEdit.cursorPosition = 4;
compare(spyContentsChanged.count, 1);
compare(spyCursor.count, 2);
compare(spyCursor.signalArguments[1][0], false);
textEdit.selectAll();
compare(spyContentsChanged.count, 1);
compare(spyCursor.count, 2);
compare(testHelper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true);
compare(textEdit.cursorPosition, testHelper.cursorPosition());
// Check we get the appropriate content and cursor change signals when removing text.
textEdit.clear();
compare(spyContentsChange.count, 2);
compare(spyContentsChange.signalArguments[1][0], 0);
compare(spyContentsChange.signalArguments[1][1], 9);
compare(spyContentsChange.signalArguments[1][2], 0);
compare(spyContentsChanged.count, 2);
compare(spyCursor.count, 3);
compare(spyCursor.signalArguments[2][0], true);
}
function test_setCursor(): void {
textEdit.insert(0, "test text");
compare(textEdit.cursorPosition, 9);
compare(spyCursor.count, 1);
testHelper.setCursorPosition(5);
compare(textEdit.cursorPosition, 5);
compare(spyCursor.count, 2);
testHelper.setCursorPosition(1);
compare(textEdit.cursorPosition, 1);
compare(spyCursor.count, 3);
textEdit.cursorVisible = false;
compare(textEdit.cursorVisible, false);
testHelper.setCursorVisible(true);
compare(textEdit.cursorVisible, true);
testHelper.setCursorVisible(false);
compare(textEdit.cursorVisible, false);
}
function test_setCursorFromTextItem(): void {
textEdit.insert(0, "line 1\nline 2");
textEdit2.insert(0, "line 1\nline 2");
testHelper.setCursorFromTextItem(textEdit2, false, 0);
compare(textEdit.cursorPosition, 7);
testHelper.setCursorFromTextItem(textEdit2, true, 7);
compare(textEdit.cursorPosition, 0);
testHelper.setCursorFromTextItem(textEdit2, false, 1);
compare(textEdit.cursorPosition, 8);
testHelper.setCursorFromTextItem(textEdit2, true, 8);
compare(textEdit.cursorPosition, 1);
testHelper.setFixedChars("1", "2");
testHelper.setCursorFromTextItem(textEdit2, false, 0);
compare(textEdit.cursorPosition, 8);
testHelper.setCursorFromTextItem(textEdit2, true, 7);
compare(textEdit.cursorPosition, 1);
}
function test_mergeFormat(): void {
textEdit.insert(0, "lots of text");
testHelper.setCursorPosition(0);
testHelper.mergeFormatOnCursor(RichFormat.Bold);
compare(testHelper.checkFormatsAtCursor([RichFormat.Bold]), true);
testHelper.mergeFormatOnCursor(RichFormat.Italic);
compare(testHelper.checkFormatsAtCursor([RichFormat.Bold, RichFormat.Italic]), true);
testHelper.setCursorPosition(6);
compare(testHelper.checkFormatsAtCursor([]), true);
testHelper.mergeFormatOnCursor(RichFormat.Underline);
compare(testHelper.checkFormatsAtCursor([RichFormat.Underline]), true);
testHelper.setCursorPosition(9);
compare(testHelper.checkFormatsAtCursor([]), true);
testHelper.mergeFormatOnCursor(RichFormat.Strikethrough);
compare(testHelper.checkFormatsAtCursor([RichFormat.Strikethrough]), true);
textEdit.clear();
textEdit.insert(0, "heading");
testHelper.mergeFormatOnCursor(RichFormat.Heading1);
compare(testHelper.checkFormatsAtCursor([RichFormat.Bold, RichFormat.Heading1]), true);
testHelper.mergeFormatOnCursor(RichFormat.Heading2);
compare(testHelper.checkFormatsAtCursor([RichFormat.Bold, RichFormat.Heading2]), true);
testHelper.mergeFormatOnCursor(RichFormat.Paragraph);
compare(testHelper.checkFormatsAtCursor([]), true);
textEdit.clear();
textEdit.insert(0, "text");
testHelper.mergeFormatOnCursor(RichFormat.UnorderedList);
compare(testHelper.checkFormatsAtCursor([RichFormat.UnorderedList]), true);
compare(testHelper.markdownText(), "- text");
testHelper.mergeFormatOnCursor(RichFormat.OrderedList);
compare(testHelper.checkFormatsAtCursor([RichFormat.OrderedList]), true);
compare(testHelper.markdownText(), "1. text");
textEdit.clear();
}
function test_list(): void {
compare(testHelper.canIndentListMoreAtCursor(), true);
testHelper.indentListMoreAtCursor();
compare(testHelper.canIndentListMoreAtCursor(), true);
testHelper.indentListMoreAtCursor();
compare(testHelper.canIndentListMoreAtCursor(), true);
testHelper.indentListMoreAtCursor();
compare(testHelper.canIndentListMoreAtCursor(), false);
compare(testHelper.canIndentListLessAtCursor(), true);
testHelper.indentListLessAtCursor();
compare(testHelper.canIndentListLessAtCursor(), true);
testHelper.indentListLessAtCursor();
compare(testHelper.canIndentListLessAtCursor(), true);
testHelper.indentListLessAtCursor();
compare(testHelper.canIndentListLessAtCursor(), false);
}
function test_forceActiveFocus(): void {
textEdit2.forceActiveFocus();
compare(textEdit.activeFocus, false);
testHelper.forceActiveFocus();
compare(textEdit.activeFocus, true);
}
}

View File

@@ -1,218 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQuickItem>
#include <QQuickTextDocument>
#include <QTextCursor>
#include <QTextDocumentFragment>
#include "chattextitemhelper.h"
class ChatTextItemHelperTestHelper : public QObject
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The QML text Item the TextItemHelper is handling.
*/
Q_PROPERTY(ChatTextItemHelper *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged)
public:
explicit ChatTextItemHelperTestHelper(QObject *parent = nullptr)
: QObject(parent)
{
}
ChatTextItemHelper *textItem() const
{
return m_textItem;
}
void setTextItem(ChatTextItemHelper *textItem)
{
if (textItem == m_textItem) {
return;
}
m_textItem = textItem;
Q_EMIT textItemChanged();
}
Q_INVOKABLE void setFixedChars(const QString &startChars, const QString &endChars)
{
if (!m_textItem) {
return;
}
m_textItem->setFixedChars(startChars, endChars);
}
Q_INVOKABLE bool compareDocuments(QQuickTextDocument *document)
{
if (!m_textItem) {
return false;
}
return document->textDocument() == m_textItem->document();
}
Q_INVOKABLE int lineCount()
{
if (!m_textItem) {
return -1;
}
return m_textItem->lineCount();
}
Q_INVOKABLE QString firstBlockText()
{
if (!m_textItem) {
return {};
}
return m_textItem->takeFirstBlock().toPlainText();
}
Q_INVOKABLE bool checkFragments(const QString &before, const QString &mid, const QString &after)
{
if (!m_textItem) {
return false;
}
bool hasBefore = false;
QTextDocumentFragment midFragment;
std::optional<QTextDocumentFragment> afterFragment = std::nullopt;
m_textItem->fillFragments(hasBefore, midFragment, afterFragment);
return hasBefore && m_textItem->document()->toPlainText() == before && midFragment.toPlainText() == mid && after.isEmpty()
? !afterFragment
: afterFragment->toPlainText() == after;
}
Q_INVOKABLE void insertFragment(const QString &text, ChatTextItemHelper::InsertPosition position = ChatTextItemHelper::Cursor, bool keepPosition = false)
{
if (!m_textItem) {
return;
}
const auto fragment = QTextDocumentFragment::fromPlainText(text);
m_textItem->insertFragment(fragment, position, keepPosition);
}
Q_INVOKABLE bool compareCursor(int cursorPosition, int selectionStart, int selectionEnd)
{
if (!m_textItem) {
return false;
}
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
const auto posSame = cursor.position() == cursorPosition;
const auto startSame = cursor.selectionStart() == selectionStart;
const auto endSame = cursor.selectionEnd() == selectionEnd;
return posSame && startSame && endSame;
}
Q_INVOKABLE int cursorPosition() const
{
if (!m_textItem) {
return -1;
}
return *m_textItem->cursorPosition();
}
Q_INVOKABLE void setCursorPosition(int pos)
{
if (!m_textItem) {
return;
}
m_textItem->setCursorPosition(pos);
}
Q_INVOKABLE void setCursorVisible(bool visible)
{
if (!m_textItem) {
return;
}
m_textItem->setCursorVisible(visible);
}
Q_INVOKABLE void setCursorFromTextItem(QQuickItem *item, bool infront, int cursorPos)
{
if (!m_textItem) {
return;
}
const auto textItem = new ChatTextItemHelper(this);
textItem->setTextItem(item);
textItem->setCursorPosition(cursorPos);
m_textItem->setCursorFromTextItem(textItem, infront);
textItem->deleteLater();
}
Q_INVOKABLE void mergeFormatOnCursor(RichFormat::Format format)
{
if (!m_textItem) {
return;
}
m_textItem->mergeFormatOnCursor(format);
}
Q_INVOKABLE bool checkFormatsAtCursor(QList<RichFormat::Format> formats)
{
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor) == formats;
}
Q_INVOKABLE bool canIndentListMoreAtCursor() const
{
if (!m_textItem) {
return false;
}
return m_textItem->canIndentListMoreAtCursor();
}
Q_INVOKABLE bool canIndentListLessAtCursor() const
{
if (!m_textItem) {
return false;
}
return m_textItem->canIndentListLessAtCursor();
}
Q_INVOKABLE void indentListMoreAtCursor()
{
if (!m_textItem) {
return;
}
m_textItem->indentListMoreAtCursor();
}
Q_INVOKABLE void indentListLessAtCursor()
{
if (!m_textItem) {
return;
}
m_textItem->indentListLessAtCursor();
}
Q_INVOKABLE void forceActiveFocus() const
{
if (!m_textItem) {
return;
}
m_textItem->forceActiveFocus();
}
Q_INVOKABLE QString markdownText() const
{
if (!m_textItem) {
return {};
}
return m_textItem->markdownText();
}
Q_SIGNALS:
void textItemChanged();
private:
QPointer<ChatTextItemHelper> m_textItem;
};

View File

@@ -29,7 +29,6 @@
#include "models/livelocationsmodel.h"
#include "models/locationsmodel.h"
#include "models/messagecontentfiltermodel.h"
#include "models/messagecontentmodel.h"
#include "models/notificationsmodel.h"
#include "models/permissionsmodel.h"
#include "models/pinnedmessagemodel.h"
@@ -180,7 +179,7 @@ void ModelTest::testRoomTreeModel()
void ModelTest::testMessageContentModel()
{
auto contentModel = std::make_unique<MessageContentModel>(room, eventId);
auto contentModel = std::make_unique<MessageContentModel>(room, nullptr, eventId);
auto tester = new QAbstractItemModelTester(contentModel.get(), contentModel.get());
tester->setUseFetchMore(true);
}
@@ -400,6 +399,7 @@ void ModelTest::testCompletionModel()
tester->setUseFetchMore(true);
model->setRoom(room);
model->setAutoCompletionType(CompletionModel::Room);
model->setText(u"foo"_s, u"#foo"_s);
auto roomListModel = new RoomListModel(this);
roomListModel->setConnection(connection);
model->setRoomListModel(roomListModel);

View File

@@ -1,9 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 Arjen Hiemstra <ahiemstra@heimr.nl>
*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include <quicktest.h>
QUICK_TEST_MAIN(NeoChat)

View File

@@ -517,89 +517,16 @@
<url>https://kde.org/announcements/megarelease/6/#neochat</url>
<description>
<p>In the newest version, when launching the app, you will get a welcome page that lets you choose which account you want to use and lets you log in to other accounts. The welcome screen will also warn you when NeoChat cannot load an account.</p>
<p xml:lang="ar">في الإصدار الأحدث، عند تشغيل التطبيق، ستظهر صفحة ترحيب تتيح اختيار الحساب المراد استخدامه وتسمح بالولوج إلى حسابات أخرى. كما تحذر شاشة الترحيب عندما يتعذر على نيوتشات تحميل حساب ما.</p>
<p xml:lang="ca">En la versió més recent, en llançar l'aplicació, obtindreu una pàgina de benvinguda que us permetrà triar quin compte voleu utilitzar i permetrà iniciar sessió en altres comptes. La pantalla de benvinguda també us avisarà quan el NeoChat no pugui carregar un compte.</p>
<p xml:lang="ca-valencia">En la versió més recent, en iniciar l'aplicació, obtindreu una pàgina de benvinguda que us permetrà triar quin compte voleu utilitzar i permetrà iniciar sessió en altres comptes. La pantalla de benvinguda també vos avisarà quan NeoChat no puga carregar un compte.</p>
<p xml:lang="ka">უახლეს ვერსიაში აპის გაშვებისას თქვენ მიღებთ მისალმების გვერდს, რომელიც საშუალებას გაძლევთ, აირჩიოთ ანგარიში, რომლის გამოყენებაც გსურთ და საშუალებას მოგცემთ, სხვა ანგარიშებში შეხვიდეთ. მისალმების ეკრანი ასევე გაგაფრთხილებთ, როცა NeoChat-ს ანგარიშის ჩატვირთვა არ შეეძლება.</p>
<p xml:lang="pt-BR">Na versão mais recente, ao iniciar o aplicativo, você verá uma página de boas-vindas que permite escolher qual conta deseja usar e fazer login em outras contas. A tela de boas-vindas também avisará quando o NeoChat não conseguir carregar uma conta.</p>
<p xml:lang="ru">В новой версии при запуске приложения открывается страница приветствия, на которой можно выбрать учётную запись для входа или добавить другие учётные записи. Также на странице приветствия отображается предупреждение, если NeoChat не удалось загрузить учётную запись.</p>
<p xml:lang="sl">V najnovejši različici boste ob zagonu aplikacije prejeli pozdravno stran, kjer lahko izberete, kateri račun želite uporabiti, in se prijavite v druge račune. Pozdravna stran vas bo opozorila tudi, ko NeoChat ne bo mogel naložiti računa.</p>
<p>NeoChat will also let you register a new account directly from the app itself. Deactivating your Matrix account is also possible from within NeoChat.</p>
<p xml:lang="ar">يتيح نيوتشات أيضاً تسجيل حساب جديد مباشرة من التطبيق نفسه. كما يمكن تعطيل حساب ماتركس من داخل نيوتشات.</p>
<p xml:lang="ca">El NeoChat també us permetrà registrar un compte nou directament des de l'aplicació mateixa. També és possible desactivar el vostre compte de Matrix des del NeoChat.</p>
<p xml:lang="ca-valencia">NeoChat també us permetrà registrar un compte nou directament des de l'aplicació mateixa. També és possible desactivar el vostre compte de Matrix des de NeoChat.</p>
<p xml:lang="ka">NeoChat ასევე საშუალებას მოგცემთ, დაარეგისტრიროთ ახალი ანგარიში პირდაპირ აპიდან. NeoChat-იდან ასევე შესაძლებელია თქვენი მატრიცის ანგარიშის დეაქტივაციაც.</p>
<p xml:lang="pt-BR">O NeoChat também permite que você registre uma nova conta diretamente pelo aplicativo. Desativar sua conta Matrix também é possível a partir do NeoChat.</p>
<p xml:lang="ru">В NeoChat также можно зарегистрировать новую учётную запись Matrix. Учётную запись Matrix также можно деактивировать непосредственно в NeoChat.</p>
<p xml:lang="sl">NeoChat vam omogoča tudi registracijo novega računa neposredno iz same aplikacije. Deaktivacija računa Matrix je mogoča tudi znotraj NeoChata.</p>
<p>Spaces are a relatively new feature of Matrix that let you group chat channels together. This is used to improve the discoverability of rooms, manage large communities, or just tidy all the channels you are in. You can now do all this without leaving NeoChat.</p>
<p xml:lang="ar">الفضاءات هي ميزة جديدة نسبياً في ماتركس تسمح بتجميع قنوات الدردشة معاً. يُستخدم هذا لتحسين قابلية اكتشاف الغرف، أو إدارة المجتمعات الكبيرة، أو مجرد ترتيب القنوات المشترك فيها. يمكن القيام بكل ذلك الآن دون مغادرة نيوتشات.</p>
<p xml:lang="ca">Els espais són una característica relativament nova de Matrix que us permet agrupar canals de xat junts. Això s'utilitza per a millorar la descoberta de sales, gestionar comunitats grans, o simplement ordenar tots els canals en els quals esteu. Ara podeu fer tot això sense sortir del NeoChat.</p>
<p xml:lang="ca-valencia">Els espais són una característica relativament nova de Matrix que us permet agrupar canals de xat junts. Açò s'utilitza per a millorar la descoberta de sales, gestionar comunitats grans, o senzillament ordenar tots els canals en els quals esteu. Ara podeu fer tot açò sense eixir de NeoChat.</p>
<p xml:lang="ka">სივრცეები მატრიცის, შედარებით, ახალი ფუნქციაა, რომელიც ჩატის არხების დაჯგუფების საშუალებას გაძლევთ. ის გამოიყენება ოთახების აღმოჩენადობისთვის, დიდი საზოგადოებების სამართავად და ზოგადად არხების ერთად შესაკრებად. ამისი გაკეთება NeoChat-იდან გაუსვლელად შეგიძლიათ.</p>
<p xml:lang="pt-BR">Os Espaços são um recurso relativamente novo do Matrix que permite agrupar canais de bate-papo. Isso é usado para melhorar a visibilidade das salas, gerenciar grandes comunidades ou simplesmente organizar todos os canais dos quais você participa. Agora você pode fazer tudo isso sem sair do NeoChat.</p>
<p xml:lang="ru">Пространства — это относительно новая функция Matrix, позволяющая группировать каналы чатов. Они используются для упрощения поиска комнат, управления большими сообществами или просто для упорядочивания всех ваших каналов. Теперь это можно делать, не покидая NeoChat.</p>
<p xml:lang="sl">Prostori so relativno nova funkcija Matrixa, ki omogoča združevanje klepetalnih kanalov. To se uporablja za izboljšanje vidnosti sob, upravljanje velikih skupnosti ali preprosto urejanje vseh kanalov, v katerih ste. Zdaj lahko vse to storite, ne da bi zapustili NeoChat.</p>
<p>NeoChat won't let you miss any new notifications anymore. We added a new page that includes all your recent notifications and when NeoChat is closed, you will still be able to receive push notifications. The main timeline will let you know when more messages are loading and when you reach the end of it.</p>
<p xml:lang="ar">لن يفوّت نيوتشات أي تنبيهات جديدة بعد الآن. أُضيفت صفحة جديدة تتضمن كافة التنبيهات الأخيرة، وعند إغلاق نيوتشات، ستظل القدرة على استقبال التنبيهات الدفعية قائمة. يُعلِمك الخط الزمني الرئيسي عند تحميل مزيد من الرسائل وعند الوصول إلى نهايته.</p>
<p xml:lang="ca">El NeoChat ja no deixarà que us perdeu cap notificació nova. Hem afegit una pàgina nova que inclou totes les notificacions recents i quan el NeoChat estigui tancat, encara podreu rebre notificacions automàtiques. La línia de temps principal us avisarà quan s'estiguin carregant més missatges i quan arribeu al seu final.</p>
<p xml:lang="ca-valencia">NeoChat ja no deixarà que vos perdeu cap notificació nova. Hem afegit una pàgina nova que inclou totes les notificacions recents i quan NeoChat estiga tancat, encara podreu rebre notificacions automàtiques. La línia de temps principal vos avisarà quan s'estiguen carregant més missatges i quan arribeu fins al seu final.</p>
<p xml:lang="ka">NeoChat აღარ მოგცემთ საშუალებას, ახალი შეტყობინებები გამოტოვოთ. ჩვენ დავამატეთ ახალი გვერდი, რომელიც თქვენს უახლეს გაფრთხილებებს შეიცავს და როცა NeoChat დახურულია, თქვენ მაინც შეძლებთ, პუშ-გაფრთხილებები მიიღოთ. მთავარი დროის ხაზი საშუალებას მოგცემთ, გაიგოთ, როდის მოხდება მეტი შეტყობინების ჩატვირთვა და როცა ბოლოში გახვალთ.</p>
<p xml:lang="pt-BR">O NeoChat não deixará você perder mais nenhuma notificação. Adicionamos uma nova página que inclui todas as suas notificações recentes e, mesmo com o NeoChat fechado, você ainda poderá receber notificações push. A linha do tempo principal mostrará quando novas mensagens estiverem sendo carregadas e quando você chegar ao final.</p>
<p xml:lang="ru">Вы более не пропустите ни одного нового уведомления NeoChat. Добавлена страница со всеми последними уведомлениями, а при закрытии приложения будут приходить push-уведомления. Основная лента сообщений теперь показывает процесс загрузки новых сообщений и её окончание.</p>
<p xml:lang="sl">Z NeoChatom ne boste več zamudili nobenega novega obvestila. Dodali smo novo stran, ki vključuje vsa vaša nedavna obvestila, in ko je NeoChat zaprt, boste še vedno lahko prejemali potisna obvestila. Glavna časovnica vas bo obvestila, kdaj se nalagajo nova sporočila in kdaj pridete do konca.</p>
<p>More NeoChat Goodies</p>
<p xml:lang="ar">مزايا إضافية في نيوتشات</p>
<p xml:lang="ca">Més millores del NeoChat</p>
<p xml:lang="ca-valencia">Més millores de NeoChat</p>
<p xml:lang="ka">NeoChat-ის მეტი სიკეთე</p>
<p xml:lang="pt-BR">Mais novidades do NeoChat</p>
<p xml:lang="ru">Дополнительные возможности NeoChat</p>
<p xml:lang="sl">Več dobrot NeoChata</p>
<ul>
<li>QR Codes to share contacts</li>
<li xml:lang="ar">رموز استجابة سريعة (QR) لمشاركة جهات الاتصال</li>
<li xml:lang="ca">Codis QR per a compartir contactes</li>
<li xml:lang="ca-valencia">Codis QR per a compartir contactes</li>
<li xml:lang="es">Códigos QR para compartir contactos</li>
<li xml:lang="ka">QR კოდები კონტაქტების გასაზიარებლად</li>
<li xml:lang="pt-BR">Códigos QR para compartilhar contatos</li>
<li xml:lang="ru">QR-коды для обмена контактами;</li>
<li xml:lang="sl">QR kode za deljenje stikov</li>
<li>Improved room upgrades</li>
<li xml:lang="ar">ترقيات محسّنة للغرف</li>
<li xml:lang="ca">Actualitzacions millorades de les sales</li>
<li xml:lang="ca-valencia">Actualitzacions millorades de les sales</li>
<li xml:lang="ka">გაუმჯობესდა ოთახის განახლებები</li>
<li xml:lang="pt-BR">Melhorias nas salas</li>
<li xml:lang="ru">Улучшены процедуры обновления комнат;</li>
<li xml:lang="sl">Izboljšane nadgradnje sob</li>
<li>Added button to reject invitation and ignore user</li>
<li xml:lang="ar">أُضيف زر لرفض الدعوة وتجاهل المستخدم</li>
<li xml:lang="ca">S'ha afegit un botó per a rebutjar una invitació i ignorar l'usuari</li>
<li xml:lang="ca-valencia">S'ha afegit un botó per a rebutjar una invitació i ignorar l'usuari</li>
<li xml:lang="ka">დაემატა ღილაკი მოსაწვევის უარყოფისთვის და მომხმარებლის დასაიგნორებლად</li>
<li xml:lang="pt-BR">Adicionado botão para rejeitar convite e ignorar usuário</li>
<li xml:lang="ru">Добавлена кнопка для отклонения приглашения и игнорирования пользователя;</li>
<li xml:lang="sl">Dodan gumb za zavrnitev povabila in ignoriranje uporabnika</li>
<li>Display device security details</li>
<li xml:lang="ar">عرض تفاصيل أمان الجهاز</li>
<li xml:lang="ca">Mostra els detalls de seguretat del dispositiu</li>
<li xml:lang="ca-valencia">Mostra els detalls de seguretat del dispositiu</li>
<li xml:lang="es">Mostrar detalles de la seguridad del dispositivo</li>
<li xml:lang="ka">მოწყობილობის უსაფრთხოების დეტალების ჩვენება</li>
<li xml:lang="pt-BR">Exibir detalhes de segurança do dispositivo</li>
<li xml:lang="ru">Просмотр подробных сведений о безопасности устройства;</li>
<li xml:lang="sl">Prikaži varnostne podrobnosti naprave</li>
<li>Added room security settings</li>
<li xml:lang="ar">أُضيفت إعدادات أمان الغرفة</li>
<li xml:lang="ca">S'ha afegit la configuració de seguretat de la sala</li>
<li xml:lang="ca-valencia">S'ha afegit la configuració de seguretat de la sala</li>
<li xml:lang="es">Se han añadido ajustes de la seguridad de las salas</li>
<li xml:lang="ka">დაემატა ოთახის უსაფრთხოების პარამეტრები</li>
<li xml:lang="pt-BR">Adicionadas configurações de segurança da sala</li>
<li xml:lang="ru">Добавлены параметры безопасности комнат;</li>
<li xml:lang="sl">Dodane varnostne nastavitve sobe</li>
</ul>
</description>
</release>
@@ -611,13 +538,6 @@
<url>https://kde.org/announcements/gear/23.08.0/#neochathttpsappskdeorgneochat</url>
<description>
<p>Apart from a visual overhaul, NeoChat can now display location events and also a map with the location of all the users currently broadcasting their location using Itineray's Matrix integration. Great for locating where your friends are.</p>
<p xml:lang="ar">بعيداً عن التجديد البصري، يمكن لنيوتشات الآن عرض أحداث الموقع وكذلك خريطة بمواقع جميع المستخدمين الذين يبثون مواقعهم حالياً باستخدام تكامل ماتركس مع Itineray. يعد هذا رائعاً لتحديد أماكن الأصدقاء.</p>
<p xml:lang="ca">A part d'una revisió visual, ara el NeoChat pot mostrar esdeveniments d'ubicació i també un mapa amb la ubicació de tots els usuaris que actualment emeten la seva ubicació utilitzant la integració de Matrix de l'Itineray. És genial per a localitzar on són els vostres amics.</p>
<p xml:lang="ca-valencia">A part d'una revisió visual, ara NeoChat pot mostrar esdeveniments d'ubicació i també un mapa amb la ubicació de tots els usuaris que actualment emeten la seua ubicació utilitzant la integració de Matrix de l'Itineray. És genial per a localitzar on són els vostres amics.</p>
<p xml:lang="ka">ვიზუალური მხრის განახლებასთან ერთად NeoChat-ს ახლა შეუძლია მდებარეობის მოვლენების ჩვენება და ასევე შეუძლია ყველა მომხმარებელი, რომელიც გადმოსცემს თავის მდებარეობას, Itinerary-ის მატრიცის ინტეგრაციით გაჩვენოთ. ეს კარგია იმისთვის, რომ გაიგოთ, სად არიან თქვენი მეგობრები.</p>
<p xml:lang="pt-BR">Além de uma reformulação visual, o NeoChat agora pode exibir eventos de localização e também um mapa com a localização de todos os usuários que estão transmitindo sua localização usando a integração Matrix do Itineray. Ótimo para localizar seus amigos.</p>
<p xml:lang="ru">Помимо визуального обновления, в NeoChat добавлена возможность отображения событий местоположения, а также карты с местоположением всех пользователей, которые в данный момент транслируют свои геоданные через интеграцию с Itineray в Matrix. Это удобно для того, чтобы узнать, где находятся ваши друзья.</p>
<p xml:lang="sl">Poleg vizualne prenove lahko NeoChat zdaj prikazuje lokacijske dogodke in tudi zemljevid z lokacijo vseh uporabnikov, ki trenutno oddajajo svojo lokacijo, z uporabo integracije Itineray Matrix. Odlično za iskanje lokacij vaših prijateljev.</p>
</description>
</release>
<release version="23.04.3" date="2023-07-06"/>
@@ -627,29 +547,8 @@
<url>https://kde.org/announcements/gear/23.04.0/#neochathttpsappskdeorgneochat</url>
<description>
<p>NeoChat improves its design with tweaks that provide a more compact layout and a simpler menu which works better for the collapsed room list.</p>
<p xml:lang="ar">يُحسّن نيوتشات تصميمه بتعديلات توفر مخططاً أكثر ضغطاً وقائمة أبسط تعمل بشكل أفضل مع قائمة الغرف المطوية.</p>
<p xml:lang="ca">El NeoChat millora el seu disseny amb retocs que proporcionen una disposició més compacta i un menú més senzill que funciona millor per a la llista reduïda de sales.</p>
<p xml:lang="ca-valencia">NeoChat millora el seu disseny amb retocs que proporcionen una disposició més compacta i un menú més senzill que funciona millor per a la llista reduïda de sales.</p>
<p xml:lang="ka">NeoChat-მა გააუმჯობესა თავისი დიზაინი ცვლილებებით, რომლებიც გაწვდით უფრო კომპაქტური განლაგებას და გამარტივებულ მენიუს, რომელიც უკეთ მუშაობს აკეცილი ოთახების სიისთვის.</p>
<p xml:lang="pt-BR">O NeoChat aprimorou seu design com ajustes que proporcionam um layout mais compacto e um menu mais simples, que funciona melhor para a lista de salas recolhida.</p>
<p xml:lang="ru">В оформлении NeoChat реализованы изменения для более компактного расположения элементов и упрощённого меню, которое лучше работает со свёрнутым списком комнат.</p>
<p xml:lang="sl">NeoChat izboljšuje svojo zasnovo s prilagoditvami, ki zagotavljajo bolj kompaktno postavitev in enostavnejši meni, ki bolje deluje za strnjen seznam sob.</p>
<p>We have also improved the video controls, added a new command /knock &lt;room-id&gt; to send a knock event to a room, and you can now edit a prior message inline, within the chat pane.</p>
<p xml:lang="ar">حُسّنت أيضاً عناصر التحكم في الفيديو، وأُضيف أمر جديد /knock &lt;room-id&gt; لإرسال حدث طرق إلى غرفة، ويمكن الآن تحرير رسالة سابقة في السطر نفسه داخل لوحة الدردشة.</p>
<p xml:lang="ca">També hem millorat els controls de vídeo, s'ha afegit una nova ordre /knock &lt;id-sala&gt; per a enviar un esdeveniment «knock» a una sala, i ara podeu editar un missatge anterior inclòs, dins de la subfinestra de xat.</p>
<p xml:lang="ca-valencia">També hem millorat els controls de vídeo, s'ha afegit una nova ordre /knock &lt;id-sala&gt; per a enviar un esdeveniment «knock» a una sala, i ara podeu editar un missatge anterior inclòs, dins de la subfinestra de xat.</p>
<p xml:lang="ka">ჩვენ ასევე გავაუმჯობესეთ ვიდეოს მართვა, დავამატეთ ახალი ბრძანება /knock &lt;ოთახის-id&gt; ოთახში დაკაკუნების მოვლენის გასაგზავნად და ასევე შეგიძლიათ, წინა შეტყობინება ხაზშივე, ჩატის პანელში ჩაასწოროთ.</p>
<p xml:lang="pt-BR">Também melhoramos os controles de vídeo, adicionamos um novo comando /knock &lt;room-id&gt; para enviar um evento knock para uma sala, e agora você pode editar uma mensagem anterior em linha, no painel de bate-papo.</p>
<p xml:lang="ru">Также улучшены элементы управления видео, добавлена новая команда /knock &lt;room-id&gt; для отправки события knock в комнату, а также реализовано редактирование предыдущих сообщений непосредственно в области чата.</p>
<p xml:lang="sl">Izboljšali smo tudi video kontrole, dodali nov ukaz /knock &lt;room-id&gt; za pošiljanje dogodka knock v sobo, zdaj pa lahko urejate prejšnje sporočilo v vrstici, v podoknu za klepet.</p>
<p>Other usability improvements include an overhaul of the keyboard navigation and shortcuts like Ctrl+PgUp/PgDn that allow you to skip from room to room.</p>
<p xml:lang="ar">تتضمن تحسينات سهولة الاستخدام الأخرى تجديد التنقل عبر لوحة المفاتيح واختصارات مثل Ctrl+PgUp/PgDn التي تسمح بالانتقال السريع بين الغرف.</p>
<p xml:lang="ca">Altres millores d'usabilitat inclouen una revisió de la navegació amb el teclat i dreceres com Ctrl+Re Pàg/Av Pàg que us permeten saltar de sala en sala.</p>
<p xml:lang="ca-valencia">Altres millores d'usabilitat inclouen una revisió de la navegació amb el teclat i dreceres com Ctrl+«Re Pàg»/«Av Pàg» que vos permeten saltar de sala en sala.</p>
<p xml:lang="ka">სხვა გამოყენებადობის გაუმჯობესებები შეიცავს კლავიატურის ნავიგაციის გადაკეთებას და ისეთ მალსახმობებს, როგორიცაა Ctrl+PgUp/PgDn, რომელიც ოთახიდან ოთახზე გადასვლის საშუალებას გაძლევთ.</p>
<p xml:lang="pt-BR">Outras melhorias de usabilidade incluem uma reformulação da navegação pelo teclado e atalhos como Ctrl+PgUp/PgDn que permitem que você pule de sala em sala.</p>
<p xml:lang="ru">К другим улучшениям в удобстве использования относится переработанная навигация с клавиатурой и комбинации клавиш, такие как Ctrl+PgUp/PgDn, для быстрого перехода между комнатами.</p>
<p xml:lang="sl">Druge izboljšave uporabnosti vključujejo prenovo navigacije s tipkovnico in bližnjice, kot sta Ctrl+PgUp/PgDn, ki omogočajo preskakovanje med sobami.</p>
</description>
<artifacts>
<artifact type="binary" platform="x86_64-windows-msvc">
@@ -663,69 +562,13 @@
<url>https://plasma-mobile.org/2023/01/30/january-blog-post/</url>
<description>
<p>New features and bugfixes:</p>
<p xml:lang="ar">ميزات جديدة وإصلاحات للعلل:</p>
<p xml:lang="ca">Característiques noves i correccions d'errors:</p>
<p xml:lang="ca-valencia">Característiques noves i esmenes d'errors:</p>
<p xml:lang="es">Nuevas funciones y corrección de fallos:</p>
<p xml:lang="ka">ახალი ფუნქციები და შეცდომების გასწორებები:</p>
<p xml:lang="pt-BR">Novas funcionalidades e correções de bugs:</p>
<p xml:lang="ru">Новые возможности и исправления ошибок:</p>
<p xml:lang="sl">Nove zmožnosti in popravki napak:</p>
<ul>
<li>Notifications will now be shown for all accounts, not just the active one</li>
<li xml:lang="ar">تُعرض التنبيهات الآن لكافة الحسابات، وليس فقط الحساب النشط</li>
<li xml:lang="ca">Ara es mostraran les notificacions per a tots els comptes, no només l'actiu</li>
<li xml:lang="ca-valencia">Ara es mostraran les notificacions per a tots els comptes, no només l'actiu</li>
<li xml:lang="es">Se muestran notificaciones de todas las cuentas, no solo de la activa</li>
<li xml:lang="ka">გაფრთხილებები ნაჩვენები იქნება ყველა ანგარიშისთვის და არა, მხოლოდ, აქტიურისთვის</li>
<li xml:lang="pt-BR">Agora, as notificações serão exibidas para todas as contas, não apenas para a conta ativa</li>
<li xml:lang="ru">Уведомления будут выводиться для всех учётных записей, а не только для активной;</li>
<li xml:lang="sl">Obvestila bodo zdaj prikazana za vse račune, ne le za aktivnega</li>
<li>There is a new "compact" mode for the room list</li>
<li xml:lang="ar">يوجد وضع "مضغوط" جديد لقائمة الغرف</li>
<li xml:lang="ca">Hi ha un mode «compacte» nou per a la llista de sales</li>
<li xml:lang="ca-valencia">Hi ha un mode «compacte» nou per a la llista de sales</li>
<li xml:lang="es">Nuevo modo «compacto» para la lista de salas</li>
<li xml:lang="ka">ოთახების სიისთვის არსებობს ახალი "კომპაქტური" რეჟიმი</li>
<li xml:lang="pt-BR">Existe um novo modo "compacto" para a lista de salas</li>
<li xml:lang="ru">Добавлен новый компактный режим списка комнат;</li>
<li xml:lang="sl">Za seznam sob je na voljo nov "kompaktni" način.</li>
<li>You can now search in the room history</li>
<li xml:lang="ar">يمكن البحث الآن في تاريخ الغرفة</li>
<li xml:lang="ca">Ara podeu cercar a l'historial de sales</li>
<li xml:lang="ca-valencia">Ara podeu buscar en l'historial de sales</li>
<li xml:lang="es">Ahora se puede buscar en el historial de salas</li>
<li xml:lang="ka">ახლა ოთახის ისტორიაში ძებნა შეგიძლიათ</li>
<li xml:lang="pt-BR">Agora você pode pesquisar no histórico da sala</li>
<li xml:lang="ru">Добавлен поиск по истории комнат;</li>
<li xml:lang="sl">Zdaj lahko iščete po zgodovini sobe</li>
<li>Emojis and Reactions have been significantly improved</li>
<li xml:lang="ar">حُسّنت الرموز التعبيرية والتفاعلات بشكل ملحوظ</li>
<li xml:lang="ca">Els emojis i les reaccions s'han millorat significativament</li>
<li xml:lang="ca-valencia">Els emoji i les reaccions s'han millorat significativament</li>
<li xml:lang="es">Los emojis y las reacciones se han mejorado significativamente</li>
<li xml:lang="ka">ემოჯიები და რეაქციები საგრძნობლად გაუმჯობესდა</li>
<li xml:lang="pt-BR">Os emojis e as reações foram significativamente aprimorados</li>
<li xml:lang="ru">Эмодзи и реакции были значительно улучшены;</li>
<li xml:lang="sl">Čustvenčki in reakcije so bili znatno izboljšani</li>
<li>Fixed several crashes around user invitations</li>
<li xml:lang="ar">أُصلحت عدة انهيارات تتعلق بدعوات المستخدمين</li>
<li xml:lang="ca">S'han corregit diverses fallades respecte les invitacions d'usuari</li>
<li xml:lang="ca-valencia">S'han corregit diverses fallades respecte les invitacions d'usuari</li>
<li xml:lang="es">Se han corregido varios fallos relacionados con las invitaciones de los usuarios</li>
<li xml:lang="ka">გასწორდა რამდენიმე შეცდომა მომხმარებლის მოწვევის ფუნქციის გარშემო</li>
<li xml:lang="pt-BR">Corrigidas várias falhas relacionadas a convites de usuários</li>
<li xml:lang="ru">Исправлено несколько аварийных завершений, связанных с приглашениями пользователей;</li>
<li xml:lang="sl">Odpravljenih je bilo več sesutij pri povabilih uporabnikov</li>
<li>Room permission settings can now be configured</li>
<li xml:lang="ar">يمكن ضبط إعدادات أذونات الغرفة الآن</li>
<li xml:lang="ca">Ara es pot configurar les opcions dels permisos de sala</li>
<li xml:lang="ca-valencia">Ara es pot configurar les opcions dels permisos de sala</li>
<li xml:lang="es">Ahora se pueden configurar los ajustes de los permisos de las salas</li>
<li xml:lang="ka">ახლა შეგიძლიათ ოთახზე წვდომების მორგება</li>
<li xml:lang="pt-BR">Agora é possível configurar as permissões da sala</li>
<li xml:lang="ru">Добавлена возможность настраивать разрешения для комнат;</li>
<li xml:lang="sl">Nastavitve dovoljenj za sobo je zdaj mogoče konfigurirati</li>
</ul>
</description>
</release>
@@ -739,88 +582,16 @@
<url>https://www.plasma-mobile.org/2022/06/28/plasma-mobile-gear-22-06/</url>
<description>
<p>This release brings you various small bugfixes and improvements:</p>
<p xml:lang="ar">يوفر هذا الإصدار إصلاحات وتحسينات متنوعة صغيرة:</p>
<p xml:lang="ca">Aquesta versió us ofereix diverses correccions d'errors i millores petites:</p>
<p xml:lang="ca-valencia">Esta versió vos oferix diverses esmenes d'errors i millores xicotetes:</p>
<p xml:lang="es">Esta versión proporciona diversas mejoras menores y correcciones de fallos:</p>
<p xml:lang="ka">ეს ვერსია შეიცავს რამდენიმე პატარა შეცდომის გასწორებას და გაუმჯობესებას:</p>
<p xml:lang="pt-BR">Esta versão traz diversas pequenas correções de bugs e melhorias:</p>
<p xml:lang="ru">В этом выпуске исправлены различные ошибки и внесены улучшения:</p>
<p xml:lang="sl">Ta izdaja vam prinaša različne manjše popravke napak in izboljšave:</p>
<ul>
<li>Sending of typing notifications can now be disabled.</li>
<li xml:lang="ar">يمكن تعطيل إرسال تنبيهات الكتابة الآن.</li>
<li xml:lang="ca">Ara es pot desactivar l'enviament de notificacions d'escriptura.</li>
<li xml:lang="ca-valencia">Ara es pot desactivar l'enviament de notificacions d'escriptura.</li>
<li xml:lang="ka">ახლა შეგიძლიათ, კრეფის შესახებ გაფრთხილება გამორთოთ.</li>
<li xml:lang="pt-BR">Agora é possível desativar o envio de notificações de digitação.</li>
<li xml:lang="ru">Отправку уведомлений о наборе текста теперь можно отключить;</li>
<li xml:lang="sl">Pošiljanje obvestil o tipkanju je zdaj mogoče onemogočiti.</li>
<li>In the room list, the scrollbar will now disappear correctly when it is not needed.</li>
<li xml:lang="ar">في قائمة الغرف، يختفي شريط التمرير الآن بشكل صحيح عند عدم الحاجة إليه.</li>
<li xml:lang="ca">A la llista de sales, la barra de desplaçament ara desapareixerà correctament quan no es necessiti.</li>
<li xml:lang="ca-valencia">En la llista de sales, la barra de desplaçament ara desapareixerà correctament quan no es necessite.</li>
<li xml:lang="ka">ოთახების სიაში ჩოჩია ახლა სწორად ქრება, როცა ის საჭირო არაა.</li>
<li xml:lang="pt-BR">Na lista de salas, a barra de rolagem agora desaparecerá corretamente quando não for necessária.</li>
<li xml:lang="ru">В списке комнат полоса прокрутки теперь скрывается, если не нужна;</li>
<li xml:lang="sl">Na seznamu sob bo drsnik zdaj pravilno izginil, ko ga ne boste potrebovali.</li>
<li>On wayland, NeoChat will now raise correctly when clicking on a notification.</li>
<li xml:lang="ar">في ويلاند، يبرز نيوتشات الآن بشكل صحيح عند النقر على التنبيه.</li>
<li xml:lang="ca">Al Wayland, ara el NeoChat elevarà correctament en fer clic a una notificació.</li>
<li xml:lang="ca-valencia">A Wayland, ara NeoChat elevarà correctament quan es clique damunt d'una notificació.</li>
<li xml:lang="ka">Wayland-ზე NeoChat ახლა სწორად ამოიწევა, როცა გაფრთხილებაზე დააწკაპუნებთ.</li>
<li xml:lang="pt-BR">No Wayland, o NeoChat agora será exibido corretamente ao clicar em uma notificação.</li>
<li xml:lang="ru">В сеансах Wayland NeoChat теперь активируется при щелчке по уведомлению;</li>
<li xml:lang="sl">Na Waylandu se NeoChat zdaj pravilno sproži ob kliku na obvestilo.</li>
<li>Several bugs have been fixed that would sometimes cause messages containing markdown and/or HTML elements to be sent incorrectly.</li>
<li xml:lang="ar">أُصلحت عدة علل كانت تتسبب أحياناً في إرسال الرسائل التي تحتوي على عناصر markdown أو HTML بشكل غير صحيح.</li>
<li xml:lang="ca">S'han corregit diversos errors que de vegades feien que els missatges que contenien elements de Markdown i/o HTML s'enviessin incorrectament.</li>
<li xml:lang="ca-valencia">S'han corregit diversos errors que de vegades feien que els missatges que contenien elements de Markdown i/o HTML s'enviaren incorrectament.</li>
<li xml:lang="ka">გასწორდა რამდენიმე შეცდომა, რომლებიც ხანდახან შეტყობინებებს, რომლებიც markdown-ს, ან/და HTML ელემენტებს შეიცავენ, არასწორად აგზავნიდნენ.</li>
<li xml:lang="pt-BR">Diversos erros foram corrigidos, os quais, por vezes, causavam o envio incorreto de mensagens contendo elementos Markdown e/ou HTML.</li>
<li xml:lang="ru">Исправлены ошибки, из-за которых сообщения, содержащие элементы разметки Markdown и/или HTML, иногда отправлялись некорректно;</li>
<li xml:lang="sl">Odpravljenih je bilo več hroščev, zaradi katerih so bila sporočila, ki so vsebovala elemente Markdown in/ali HTML, včasih napačno poslana.</li>
<li>The quick switcher can now be controlled using the mouse.</li>
<li xml:lang="ar">يمكن التحكم في المبدل السريع باستخدام الفأرة الآن.</li>
<li xml:lang="ca">El commutador ràpid ara es pot controlar amb el ratolí.</li>
<li xml:lang="ca-valencia">El commutador ràpid ara es pot controlar amb el ratolí.</li>
<li xml:lang="ka">სწრაფი გადამრთველის მართვა ახლა თაგუნათი შეგიძლიათ.</li>
<li xml:lang="pt-BR">O seletor rápido agora pode ser controlado usando o mouse.</li>
<li xml:lang="ru">Для быстрого переключения теперь возможно использовать мышь;</li>
<li xml:lang="sl">Hitri preklopnik je zdaj mogoče upravljati z miško.</li>
<li>There is now an option to disable automatic room sidebar opening when resizing the window.</li>
<li xml:lang="ar">يتوفر الآن خيار لتعطيل الفتح الآلي للشريط الجانبي للغرفة عند تغيير حجم النافذة.</li>
<li xml:lang="ca">Ara hi ha una opció per a desactivar l'obertura automàtica de la barra lateral de la sala en canviar la mida de la finestra.</li>
<li xml:lang="ca-valencia">Ara hi ha una opció per a desactivar l'obertura automàtica de la barra lateral de la sala en canviar la mida de la finestra.</li>
<li xml:lang="ka">ახლა გაქვთ არჩევანი, რომ გამორთოთ ავტომატური ოთახის გვერდითი პანელის გახსნა ფანჯრის ზომის შეცვლისას.</li>
<li xml:lang="pt-BR">Agora existe uma opção para desativar a abertura automática da barra lateral da sala ao redimensionar a janela.</li>
<li xml:lang="ru">Добавлена возможность отключить автоматическое открытие боковой панели комнат при изменении размера окна;</li>
<li xml:lang="sl">Zdaj je na voljo možnost onemogočanja samodejnega odpiranja stranske vrstice sobe pri spreminjanju velikosti okna.</li>
<li>Creation of custom emojis has been fixed.</li>
<li xml:lang="ar">أُصلح إنشاء الرموز التعبيرية المخصصة.</li>
<li xml:lang="ca">S'ha corregit la creació d'emojis personalitzats.</li>
<li xml:lang="ca-valencia">S'ha corregit la creació d'emoji personalitzats.</li>
<li xml:lang="es">Se ha corregido la creación de emojis personalizados.</li>
<li xml:lang="ka">გასწორდა მომხმარებლის ემოჯიების შექმნა.</li>
<li xml:lang="pt-BR">A criação de emojis personalizados foi corrigida.</li>
<li xml:lang="ru">Исправлено создание пользовательских эмодзи;</li>
<li xml:lang="sl">Ustvarjanje čustvenčkov po meri je bilo popravljeno.</li>
<li>Editing or replying to the last message using the keyboard shortcuts now works correctly.</li>
<li xml:lang="ar">يعمل تحرير الرسالة الأخيرة أو الرد عليها باستخدام اختصارات لوحة المفاتيح بشكل صحيح الآن.</li>
<li xml:lang="ca">L'edició o la resposta a l'últim missatge utilitzant les dreceres de teclat ara funciona correctament.</li>
<li xml:lang="ca-valencia">L'edició o la resposta a l'últim missatge utilitzant les dreceres de teclat ara funciona correctament.</li>
<li xml:lang="ka">ბოლო შეტყობინების ჩასწორება და მასზე პასუხი კლავიატურის მალსახმობებით ახლა სწორად მუშაობს.</li>
<li xml:lang="pt-BR">Agora, editar ou responder à última mensagem usando os atalhos de teclado funciona corretamente.</li>
<li xml:lang="ru">Исправлена работа комбинаций клавиш для редактирования или ответа на последнее сообщение.</li>
<li xml:lang="sl">Urejanje ali odgovarjanje na zadnje sporočilo z uporabo bližnjic na tipkovnici zdaj deluje pravilno.</li>
<li>When switching between rooms using the keyboard, the switching direction is now correct.</li>
<li xml:lang="ar">عند التبديل بين الغرف باستخدام لوحة المفاتيح، يكون اتجاه التبديل صحيحاً الآن.</li>
<li xml:lang="ca">Quan es canvia entre sales utilitzant el teclat, la direcció de commutació ara és correcta.</li>
<li xml:lang="ca-valencia">Quan es canvia entre sales utilitzant el teclat, la direcció de commutació ara és correcta.</li>
<li xml:lang="ka">ოთახებს შორის კლავიატურით გადართვისას გადართვის მიმართულება ახლა სწორია.</li>
<li xml:lang="pt-BR">Ao alternar entre salas usando o teclado, a direção da alternância agora está correta.</li>
<li xml:lang="ru">При переключении между комнатами с клавиатуры направление переключения теперь корректное;</li>
<li xml:lang="sl">Pri preklapljanju med sobami s tipkovnico je smer preklapljanja zdaj pravilna.</li>
</ul>
</description>
</release>
@@ -828,76 +599,18 @@
<url>https://www.plasma-mobile.org/2022/04/26/plasma-mobile-gear-22-04/</url>
<description>
<p>NeoChat now lets you filter and enter a room directly from KRunner (Plasma Search). Aside from that there is also various bug fixes regarding the typing notifications.</p>
<p xml:lang="ar">يتيح نيوتشات الآن ترشيح ودخول الغرفة مباشرة من KRunner (بحث بلازما). وبالإضافة إلى ذلك، تتوفر إصلاحات متنوعة للعلل المتعلقة بتنبيهات الكتابة.</p>
<p xml:lang="ca">El NeoChat ara permet filtrar i entrar a una sala directament des del KRunner (Cerca del Plasma). A part d'això també hi ha diverses correccions d'errors pel que fa a les notificacions d'escriptura.</p>
<p xml:lang="ca-valencia">NeoChat ara permet filtrar i entrar a una sala directament des de KRunner (Busca de Plasma). A part d'açò també hi ha diverses esmenes d'errors pel que fa a les notificacions d'escriptura.</p>
<p xml:lang="ka">NeoChat ახლა საშუალებას გაძლევთ, გაფილტროთ და შეხვიდეთ ოთახში პირდაპი KRunner-დან (Plasma-ის ძებნა). ამის გარდა ასევე გასწორდა სხვადასხვა შეცდომა კრეფის გაფრთხილების შესახებ.</p>
<p xml:lang="pt-BR">O NeoChat agora permite filtrar e entrar em uma sala diretamente do KRunner (Busca do Plasma). Além disso, também foram feitas diversas correções de bugs relacionados às notificações de digitação.</p>
<p xml:lang="ru">Добавлена возможность фильтрации и входа в комнату прямо из KRunner (поиск Plasma). Кроме того, исправлены различные ошибки, связанные с уведомлениями о наборе текста.</p>
<p xml:lang="sl">NeoChat zdaj omogoča filtriranje in vstop v sobo neposredno iz KRunnerja (iskanje v Plasmi). Poleg tega so bile odpravljene tudi različne napake v zvezi z obvestili o tipkanju.</p>
</description>
</release>
<release version="22.02" date="2022-02-09">
<description>
<p>NeoChat 22.02 focus on stability and adds a few quality of life improvements</p>
<p xml:lang="ar">يركز نيوتشات 22.02 على الاستقرار ويضيف بعض تحسينات جودة الاستخدام</p>
<p xml:lang="ca">El NeoChat 22.02 se centra en l'estabilitat i afegeix algunes millores de qualitat de vida</p>
<p xml:lang="ca-valencia">NeoChat 22.02 se centra en l'estabilitat i afig algunes millores de qualitat de vida</p>
<p xml:lang="ka">NeoChat 22.02-ის ფოკუსია სტაბილურობა და ამატებს რამდენიმე ცხოვრების დონის გაუმჯობესებას</p>
<p xml:lang="pt-BR">O NeoChat 22.02 foca na estabilidade e adiciona algumas melhorias de usabilidade</p>
<p xml:lang="ru">В NeoChat 22.02 основное внимание уделено стабильности и добавлено несколько улучшений для удобства использования.</p>
<p xml:lang="sl">NeoChat 22.02 se osredotoča na stabilnost in dodaja nekaj izboljšav kakovosti življenja</p>
<ul>
<li>Add support for minimizing to system tray on startup</li>
<li xml:lang="ar">إضافة دعم التصغير إلى صينية النظام عند بدء التشغيل</li>
<li xml:lang="ca">Afegeix la implementació per a minimitzar a la safata del sistema en iniciar</li>
<li xml:lang="ca-valencia">Afig la implementació per a minimitzar a la safata del sistema en iniciar</li>
<li xml:lang="ka">დაემატა გაშვებისას საათთან ჩაკეცვის მხარდაჭერა</li>
<li xml:lang="pt-BR">Adicionado suporte para minimizar para a bandeja do sistema na inicialização</li>
<li xml:lang="ru">Добавлена поддержка сворачивания в системный лоток при запуске;</li>
<li xml:lang="sl">Dodajte podporo za minimitziranje v sistemsko vrstico ob zagonu</li>
<li>Improved internet connectivity check</li>
<li xml:lang="ar">تحسين فحص الاتصال بالإنترنت</li>
<li xml:lang="ca">Millora de la verificació de la connectivitat a Internet</li>
<li xml:lang="ca-valencia">Millora de la verificació de la connectivitat a Internet</li>
<li xml:lang="es">Se ha mejorado la comprobación de la conectividad con internet.</li>
<li xml:lang="ka">გაუმჯობესდა ინტერნეტკავშირის შემოწმება</li>
<li xml:lang="pt-BR">Verificação de conectividade de internet aprimorada</li>
<li xml:lang="ru">Улучшена проверка подключения к Интернету;</li>
<li xml:lang="sl">Izboljšano preverjanje internetne povezave</li>
<li>Add support for sharing images and files with other apps (Nextcloud, Imgur, ...)</li>
<li xml:lang="ar">إضافة دعم مشاركة الصور والملفات مع تطبيقات أخرى (نكست كلاود، إمجور، ...)</li>
<li xml:lang="ca">Afegeix la implementació per a compartir imatges i fitxers amb altres aplicacions (Nextcloud, Imgur...)</li>
<li xml:lang="ca-valencia">Afig la implementació per a compartir imatges i fitxers amb altres aplicacions (Nextcloud, Imgur…)</li>
<li xml:lang="es">Se ha añadido compatibilidad para compartir imágenes y archivos con otras aplicaciones (Nextcloud, Imgur, etc.).</li>
<li xml:lang="ka">დაემატა გამოსახულებებისა და ფაილების სხვა აპებთან (Nextcloud, Imgur,...) გაზიარების მხარდაჭერა</li>
<li xml:lang="pt-BR">Adicionado suporte para compartilhamento de imagens e arquivos com outros aplicativos (Nextcloud, Imgur, ...)</li>
<li xml:lang="ru">Добавлена возможность обмена изображениями и файлами с другими приложениями (Nextcloud, Imgur, и прочими службами);</li>
<li xml:lang="sl">Doda podporo za deljenje slik in datotek z drugimi aplikacijami (Nextcloud, Imgur, ...)</li>
<li>Implement adding labels for account. This allow for an easier organization when using multiple accounts.</li>
<li xml:lang="ar">تطبيق إضافة لصائق للحساب. يسمح هذا بتنظيم أسهل عند استخدام حسابات متعددة.</li>
<li xml:lang="ca">Implementa l'addició d'etiquetes al compte. Això permet una organització més fàcil quan s'utilitzen diversos comptes.</li>
<li xml:lang="ca-valencia">Implementa l'addició d'etiquetes al compte. Açò permet una organització més fàcil quan s'utilitzen diversos comptes.</li>
<li xml:lang="ka">ახლა ანგარიშებს შეიძლიათ, ჭდეები დაამატოთ. ეს აადვილებს ორგანიზებას, როცა ერთზე მეტ ანგარიშს იყენებთ.</li>
<li xml:lang="pt-BR">Implementada a adição de etiquetas para contas. Isso facilita a organização ao usar várias contas.</li>
<li xml:lang="ru">Реализовано добавление меток для учётных записей; это упрощает организацию при использовании нескольких учётных записей.</li>
<li xml:lang="sl">Implementira dodajanje oznak za račun. To omogoča lažjo organizacijo pri uporabi več računov.</li>
<li>Redesign of our config dialogs to follow the new Plasma System Settings style</li>
<li xml:lang="ar">إعادة تصميم حوارات الضبط لاتباع نمط إعدادات نظام بلازما الجديد</li>
<li xml:lang="ca">Redisseny dels diàlegs de configuració per a seguir l'estil nou de l'arranjament del sistema del Plasma</li>
<li xml:lang="ca-valencia">Redisseny dels diàlegs de configuració per a seguir l'estil nou de Configuració del sistema de Plasma</li>
<li xml:lang="ka">შეიცვალა დიზაინი კონფიგურაციის დიალოგებისთვის, რომ ისინი Plasma-ის სისტემური პარამეტრების სტილს მიჰყვებოდნენ</li>
<li xml:lang="pt-BR">Redesenho das caixas de diálogo de configuração para seguir o novo estilo das Configurações do Sistema do Plasma.</li>
<li xml:lang="ru">Реализован редизайн диалогов настройки в соответствии со стилем нового приложения «Параметры системы» Plasma;</li>
<li xml:lang="sl">Preoblikovanje naših konfiguracijskih pogovornih oken, da sledijo novemu slogu nastavitev sistema Plasma</li>
<li>Fix various others issues and small feature requests. Decreasing the total amount of open issues by 20%.</li>
<li xml:lang="ar">إصلاح قضايا متنوعة أخرى وطلبات ميزات صغيرة، مما يقلل إجمالي القضايا المفتوحة بنسبة 20%.</li>
<li xml:lang="ca">Corregeix diversos problemes i peticions de funcionalitats petites. Disminució de la quantitat total de problemes oberts en un 20%.</li>
<li xml:lang="ca-valencia">Corregix diversos problemes i peticions de característiques xicotetes. Disminució de la quantitat total de problemes oberts en un 20%.</li>
<li xml:lang="ka">გასწორდა სხვადასხვა შეცდომები და პატარა ფუნქციის მოთხოვნები. ღია პრობლემების ჯამური რაოდენობა შემცირდა 20%-ით.</li>
<li xml:lang="pt-BR">Corrigido diversos outros problemas e implementação de pequenas solicitações de melhorias. Reduzido o número total de bugs em aberto em 20%.</li>
<li xml:lang="ru">Исправлены прочие ошибки и реализованы небольшие запросы на новые возможности; общее количество открытых проблем сокращено на 20%.</li>
<li xml:lang="sl">Odpravi različne druge težave in manjše zahteve za zmožnosti. Zmanjšajte skupno število odprtih težav za 20%.</li>
</ul>
</description>
<url>https://www.plasma-mobile.org/2022/02/09/plasma-mobile-gear-22-02/#neochat</url>
@@ -905,144 +618,22 @@
<release version="21.12" date="2021-12-07">
<description>
<p>NeoChat 21.12 brings lots of new features and fixes</p>
<p xml:lang="ar">يوفر نيوتشات 21.12 الكثير من الميزات والإصلاحات الجديدة</p>
<p xml:lang="ca">El NeoChat 21.12 aporta moltes funcionalitats noves i correccions</p>
<p xml:lang="ca-valencia">NeoChat 21.12 aporta moltes característiques noves i correccions</p>
<p xml:lang="es">NeoChat 21.12 proporciona muchas funciones nuevas y correcciones.</p>
<p xml:lang="ka">NeoChat 21.12 ბევრ ახალი ფუნქციას და შეცდომების გასწორებას შეიცავს</p>
<p xml:lang="pt-BR">O NeoChat 21.12 traz muitas novidades e correções</p>
<p xml:lang="ru">В NeoChat 21.12 добавлено множество новых возможностей и исправлений</p>
<p xml:lang="sl">NeoChat 21.12 prinaša veliko novih zmožnosti in popravkov</p>
<ul>
<li>Solved various problems related to login, logout and account switching</li>
<li xml:lang="ar">حُلّت مشكلات متنوعة متعلقة بالولوج والخروج وتبديل الحسابات</li>
<li xml:lang="ca">S'han resolt diversos problemes relacionats amb l'inici de sessió, la sortida i el canvi de compte</li>
<li xml:lang="ca-valencia">S'han resolt diversos problemes relacionats amb l'inici de sessió, l'eixida i el canvi de compte</li>
<li xml:lang="es">Se han solucionado diversos problemas relacionados con el inicio y el cierre de sesión y con el cambio de cuenta.</li>
<li xml:lang="ka">გადაიჭრა შესვლასთან, გასვლასთან და ანგარიშის გადართვასთან დაკავშირებული სხვადასხვა პრობლემა</li>
<li xml:lang="pt-BR">Resolvidos diversos problemas relacionados a login, logout e troca de contas</li>
<li xml:lang="ru">Устранены проблемы, связанные с входом, выходом и переключением учётных записей;</li>
<li xml:lang="sl">Rešene različne težave, povezane s prijavo, odjavo in preklapljanjem računov</li>
<li>Fixed a few problems in the timeline layout</li>
<li xml:lang="ar">أُصلحت بعض المشكلات في مخطط الخط الزمني</li>
<li xml:lang="ca">S'han corregit alguns problemes en la disposició de la línia de temps</li>
<li xml:lang="ca-valencia">S'han corregit alguns problemes en la disposició de la línia de temps</li>
<li xml:lang="es">Se han corregido varios problemas en la disposición de la línea de tiempo</li>
<li xml:lang="ka">გასწორდა დროის ხაზის განლაგების რამდენიმე პრობლემა</li>
<li xml:lang="pt-BR">Corrigidos alguns problemas no layout da linha do tempo</li>
<li xml:lang="ru">Исправлены некоторые проблемы в расположении ленты событий;</li>
<li xml:lang="sl">Odpravljenih je bilo nekaj težav v postavitvi časovnice</li>
<li>Added Spell checking while writing a message</li>
<li xml:lang="ar">أُضيف التدقيق الإملائي أثناء كتابة الرسالة</li>
<li xml:lang="ca">S'ha afegit la verificació ortogràfica mentre s'escriu un missatge</li>
<li xml:lang="ca-valencia">S'ha afegit la verificació ortogràfica mentre s'escriu un missatge</li>
<li xml:lang="es">Se ha añadido comprobación ortográfica durante la escritura de mensajes.</li>
<li xml:lang="ka">დაემატა მართლწერის შემოწმება შეტყობინების წერისას</li>
<li xml:lang="pt-BR">Adicionada verificação ortográfica durante a escrita de mensagens</li>
<li xml:lang="ru">Добавлена проверка орфографии при написании сообщения;</li>
<li xml:lang="sl">Dodano preverjanje črkovanja med pisanjem sporočila</li>
<li>Improved Settings pages</li>
<li xml:lang="ar">تحسين صفحات الإعدادات</li>
<li xml:lang="ca">Pàgines de configuració millorades</li>
<li xml:lang="ca-valencia">Pàgines de configuració millorades</li>
<li xml:lang="es">Se han mejorado las páginas de preferencias.</li>
<li xml:lang="ka">გაუმჯობესდა მორგების გვერდები</li>
<li xml:lang="pt-BR">Páginas de configurações aprimoradas</li>
<li xml:lang="ru">Улучшены страницы настроек;</li>
<li xml:lang="sl">Izboljšane strani z nastavitvami</li>
<li>Many improvements to the android and general mobile support</li>
<li xml:lang="ar">تحسينات كثيرة لدعم أندرويد والأجهزة المحمولة بشكل عام</li>
<li xml:lang="ca">Moltes millores a l'Android i suport general de mòbils</li>
<li xml:lang="ca-valencia">Moltes millores a Android i suport general de mòbils</li>
<li xml:lang="ka">ბევრი გაუმჯობესება Android-ის და ზოგადი მობილური მხარდაჭერაში</li>
<li xml:lang="pt-BR">Muitas melhorias no Android e no suporte geral para dispositivos móveis</li>
<li xml:lang="ru">Многочисленные улучшения для Android и мобильных устройств в целом;</li>
<li xml:lang="sl">Številne izboljšave podpore za Android in splošno mobilno tehnologijo</li>
<li>Show blurhashes while images load</li>
<li xml:lang="ar">عرض blurhashes أثناء تحميل الصور</li>
<li xml:lang="ca">Mostra «blurhashes» mentre es carreguen les imatges</li>
<li xml:lang="ca-valencia">Mostra «blurhashes» mentre es carreguen les imatges</li>
<li xml:lang="ka">დაბინდული ადგილების ჩვენება, სანამ გამოსახულებები ჩაიტვირთება</li>
<li xml:lang="pt-BR">Exibir os ícones de desfoque enquanto as imagens carregam</li>
<li xml:lang="ru">Отображение размытых хешей во время загрузки изображений;</li>
<li xml:lang="sl">Prikaži zamegljene črtice med nalaganjem slik</li>
<li>Support showing custom emojis</li>
<li xml:lang="ar">دعم عرض الرموز التعبيرية المخصصة</li>
<li xml:lang="ca">Permet mostrar emojis personalitzats</li>
<li xml:lang="ca-valencia">Permet mostrar emoji personalitzats</li>
<li xml:lang="es">Compatibilidad con emojis personalizados.</li>
<li xml:lang="ka">მორგებული ემოჯიების ჩვენების მხარდაჭერა</li>
<li xml:lang="pt-BR">Suporte para exibição de emojis personalizados</li>
<li xml:lang="ru">Отображение пользовательских эмодзи;</li>
<li xml:lang="sl">Podpora za prikazovanje čustvenčkov po meri</li>
<li>Added a global menu</li>
<li xml:lang="ar">أُضيفت قائمة عامة</li>
<li xml:lang="ca">S'ha afegit un menú global</li>
<li xml:lang="ca-valencia">S'ha afegit un menú global</li>
<li xml:lang="es">Se ha añadido un menú global.</li>
<li xml:lang="ka">დაემატა გლობალური მენიუ</li>
<li xml:lang="pt-BR">Adicionado um menu global</li>
<li xml:lang="ru">Добавлено глобальное меню;</li>
<li xml:lang="sl">Dodan globalni meni</li>
<li>Added support for spoilers</li>
<li xml:lang="ar">أُضيف دعم المحتوى المحروق</li>
<li xml:lang="ca">S'ha afegit la implementació per als espòilers</li>
<li xml:lang="ca-valencia">S'ha afegit la implementació per als espòilers</li>
<li xml:lang="ka">დაემატა სპოილერების მხარდაჭერა</li>
<li xml:lang="pt-BR">Adicionado suporte para spoilers</li>
<li xml:lang="ru">Добавлена поддержка скрытого текста;</li>
<li xml:lang="sl">Dodana podpora za spojlerje</li>
<li>Added a quick switcher to switch between rooms</li>
<li xml:lang="ar">أُضيف مبدل سريع للتبديل بين الغرف</li>
<li xml:lang="ca">S'ha afegit un commutador ràpid per a canviar entre sales</li>
<li xml:lang="ca-valencia">S'ha afegit un commutador ràpid per a canviar entre sales</li>
<li xml:lang="es">Se ha añadido un selector rápido para cambiar de sala.</li>
<li xml:lang="ka">დაემატა სწრაფი გადამრთველი ოთახებს შორის</li>
<li xml:lang="pt-BR">Adicionado um botão de troca rápida para alternar entre salas</li>
<li xml:lang="ru">Добавлен быстрый переключатель для перехода между комнатами;</li>
<li xml:lang="sl">Dodan hitri preklopnik za preklapljanje med sobami</li>
<li>Added support for an optional fancy blur background effect</li>
<li xml:lang="ar">أُضيف دعم لتأثير خلفية ضبابية أنيقة اختياري</li>
<li xml:lang="ca">S'ha afegit la implementació per a un efecte de difuminat de fons opcional</li>
<li xml:lang="ca-valencia">S'ha afegit la implementació per a un efecte de difuminat de fons opcional</li>
<li xml:lang="ka">დაემატა არასავალდებულო მდიდრული ბუნდოვანი ფონის ეფექტის მხარდაჭერა</li>
<li xml:lang="pt-BR">Adicionada a opção de um efeito de desfoque de fundo sofisticado</li>
<li xml:lang="ru">Добавлена поддержка необязательного эффекта размытого фона;</li>
<li xml:lang="sl">Dodana podpora za izbirni učinek zameglitve ozadja</li>
<li>Resizable left and right drawers</li>
<li xml:lang="ar">أدراج يمنى ويسرى قابلة لتغيير الحجم</li>
<li xml:lang="ca">Calaixos esquerra i dreta redimensionables</li>
<li xml:lang="ca-valencia">Calaixos esquerra i dreta redimensionables</li>
<li xml:lang="ka">ზომაცვლადი მარცხენა და მარჯვენა უჯრები</li>
<li xml:lang="pt-BR">Gavetas redimensionáveis ​​à esquerda e à direita</li>
<li xml:lang="ru">Изменяемые размеры левой и правой панелей;</li>
<li xml:lang="sl">Spremenljiva velikost levega in desnega predala</li>
<li>Added Syntax highlighting in raw json messages</li>
<li xml:lang="ar">أُضيف تمييز الصيغة في رسائل json الخام</li>
<li xml:lang="ca">S'ha afegit el ressaltat de sintaxi en els missatges JSON en brut</li>
<li xml:lang="ca-valencia">S'ha afegit el ressaltat de sintaxi en els missatges JSON en brut</li>
<li xml:lang="ka">დაემატა სინტაქსის გამოკვეთა დაუმუშავებელ JSON შეტყობინებებში</li>
<li xml:lang="pt-BR">Adicionada a coloração de sintaxe em mensagens JSON brutas</li>
<li xml:lang="ru">Добавлена подсветка синтаксиса в необработанных JSON-сообщениях;</li>
<li xml:lang="sl">Dodano označevanje skladnje v sporočilih raw json</li>
<li>Better wayland support</li>
<li xml:lang="ar">دعم أفضل لويلاند</li>
<li xml:lang="ca">Millor suport del Wayland</li>
<li xml:lang="ca-valencia">Millor suport de Wayland</li>
<li xml:lang="es">Mejor compatibilidad con Wayland.</li>
<li xml:lang="ka">Wayland-ის უკეთესი მხარდაჭერა</li>
<li xml:lang="pt-BR">Melhor suporte ao Wayland</li>
<li xml:lang="ru">Улучшена поддержка Wayland;</li>
<li xml:lang="sl">Boljša podpora za Wayland</li>
<li>Improved file reception and download</li>
<li xml:lang="ar">تحسين استقبال الملفات وتنزيلها</li>
<li xml:lang="ca">Recepció i baixada de fitxers millorades</li>
<li xml:lang="ca-valencia">Recepció i baixada de fitxers millorades</li>
<li xml:lang="es">Se ha mejorado la recepción y la descarga de archivos.</li>
<li xml:lang="ka">გაუმჯობესდა ფაილების მიღება და გადმოწერა</li>
<li xml:lang="pt-BR">Recepção e download de arquivos aprimorados</li>
<li xml:lang="ru">Улучшены приём и загрузка файлов;</li>
<li xml:lang="sl">Izboljšan sprejem in prenos datotek</li>
</ul>
</description>
<url>https://www.plasma-mobile.org/2021/12/07/plasma-mobile-gear-21-12/</url>
@@ -1050,29 +641,8 @@
<release version="1.2.0" date="2021-06-01">
<description>
<p>NeoChat 1.2 brings a major redesign of the user interface. The chat page is now using bubbles for the messages and the input component was completely rewritten with a nicer look as well.</p>
<p xml:lang="ar">يوفر نيوتشات 1.2 إعادة تصميم كبرى لواجهة المستخدم. تستخدم صفحة الدردشة الآن فقاعات للرسائل، كما أُعيدت كتابة مكون الإدخال بالكامل بمظهر أجمل.</p>
<p xml:lang="ca">El NeoChat 1.2 aporta un redisseny important de la interfície d'usuari. La pàgina de xat ara utilitza bombolles per als missatges i el component d'entrada s'ha reescrit completament amb un aspecte més agradable també.</p>
<p xml:lang="ca-valencia">NeoChat 1.2 aporta un redisseny important de la interfície d'usuari. La pàgina de xat ara utilitza bambolles per als missatges i el component d'entrada s'ha reescrit completament amb un aspecte més agradable també.</p>
<p xml:lang="ka">NeoChat 1.2 მომხმარებლის ინტერფეისის დიზაინის თითქმის სრულ ცვლილებას შეიცავს. ჩატის გვერდი ახლა შეტყობინებებისთვის ბუშტებს იყენებს და შეყვანის კომპონენტი მთლიანად თავიდანაა დაწერილი, რომ უკეთ გამოიყურებოდეს.</p>
<p xml:lang="pt-BR">O NeoChat 1.2 traz uma grande reformulação da interface do usuário. A página de bate-papo agora utiliza balões para as mensagens e o componente de entrada foi completamente reescrito com um visual mais agradável.</p>
<p xml:lang="ru">В версии NeoChat 1.2 полностью изменён пользовательский интерфейс. На странице чата сообщения отображаются в виде пузырей, а компонент ввода был полностью переписан и получил более приятный внешний вид.</p>
<p xml:lang="sl">NeoChat 1.2 prinaša veliko prenovo uporabniškega vmesnika. Stran za klepet zdaj uporablja mehurčke za sporočila, vhodna komponenta pa je bila popolnoma prepisana in ima lepši videz.</p>
<p>It's now possible to send custom reactions by replying to a comment with /react &lt;message&gt;.</p>
<p xml:lang="ar">أصبح من الممكن الآن إرسال تفاعلات مخصصة عبر الرد على تعليق بالأمر /react &lt;message&gt;.</p>
<p xml:lang="ca">Ara és possible enviar reaccions personalitzades responent a un comentari amb /react &lt;missatge&gt;.</p>
<p xml:lang="ca-valencia">Ara és possible enviar reaccions personalitzades responent a un comentari amb /react &lt;missatge&gt;.</p>
<p xml:lang="ka">ახლა შესაძლებელია მორგებული რეაქციების გაგზავნა კომენტარზე ბრძანებით /react &lt;message&gt; პასუხის საშუალებით.</p>
<p xml:lang="pt-BR">Agora é possível enviar reações personalizadas respondendo a um comentário com /react &lt;mensagem&gt;.</p>
<p xml:lang="ru">Теперь можно отправлять собственные реакции, ответив на сообщение командой /react &lt;сообщение&gt;.</p>
<p xml:lang="sl">Zdaj je mogoče poslati odzive po meri tako, da na komentar odgovorite z /react &lt;message&gt;.</p>
<p>NeoChat now supports opening Matrix URIs from your browser.</p>
<p xml:lang="ar">يدعم نيوتشات الآن فتح معرفات Matrix URI من المتصفح.</p>
<p xml:lang="ca">El NeoChat ara permet obrir els URI de Matrix des del navegador.</p>
<p xml:lang="ca-valencia">NeoChat ara permet obrir els URI de Matrix des del navegador.</p>
<p xml:lang="ka">NeoChat-ს ახლა მატრიცის URI-ების გახსნა შეუძლია თქვენი ბრაუზერიდან.</p>
<p xml:lang="pt-BR">O NeoChat agora suporta a abertura de URIs do Matrix a partir do seu navegador.</p>
<p xml:lang="ru">В NeoChat добавлена поддержка открытия URI Matrix из браузера.</p>
<p xml:lang="sl">NeoChat zdaj podpira odpiranje URI-jev Matrix iz vašega brskalnika.</p>
</description>
<url>https://carlschwan.eu/2021/06/01/neochat-1.2/</url>
</release>
@@ -1080,108 +650,23 @@
<release version="1.1.0" date="2021-02-22">
<description>
<p>Probably the highlight of this release is the completely new login page. It detects the server configuration based on your Matrix Id. This allows you to login to servers requiring Single Sign On (SSO) (like the Mozilla or the incoming Fedora Matrix instance).</p>
<p xml:lang="ar">لعل أبرز ما في هذا الإصدار هو صفحة الولوج الجديدة كلياً. فهي تكتشف ضبط الخادم بناءً على معرف ماتركس الخاص بك. يتيح هذا الولوج إلى الخوادم التي تتطلب الولوج الموحد (SSO) (مثل خوادم موزيلا أو خادم ماتركس القادم لفيدورا).</p>
<p xml:lang="ca">Probablement el més destacat d'aquest llançament és la pàgina d'inici de sessió completament nova. Detecta la configuració del servidor basant-se en l'identificador de Matrix. Això permet iniciar sessió en servidors que requereixen inici de sessió únic (SSO) (com el Mozilla o la instància d'entrada de Matrix de Fedora).</p>
<p xml:lang="ca-valencia">Probablement el més destacat d'este llançament és la pàgina d'inici de sessió completament nova. Detecta la configuració del servidor basant-se en l'identificador de Matrix. Açò permet iniciar sessió en servidors que requerixen inici de sessió únic (SSO) (com Mozilla o la instància d'entrada de Matrix de Fedora).</p>
<p xml:lang="ka">ალბათ ამ ვერსიის გამოკვეთილი ცვლილება სრულიად ახალი შესვლის გვერდია. ის სერვერის კონფიგურაციას თქვენი მატრიცის ID-ის მიხედვით ადგენს. ეს საშუალებას გაძლევთ, შეხვიდეთ სერვერებზე, რომლებიც SSO-ით (მაგ Mozilla, ან შემომავალი Fedora-ის მატრიცის გაშვებული ასლი) შესვლას ითხოვს.</p>
<p xml:lang="pt-BR">Provavelmente, o grande destaque desta versão é a página de login completamente nova. Ela detecta a configuração do servidor com base no seu ID do Matrix. Isso permite que você faça login em servidores que exigem Single Sign-On (SSO) (como a instância do Mozilla Matrix ou a futura instância do Fedora Matrix).</p>
<p xml:lang="ru">Главным нововведением этой версии стала полностью переработанная страница входа. Она определяет конфигурацию сервера по вашему идентификатору Matrix, что позволяет входить на серверы с единой системой аутентификации (например, Mozilla или готовящийся к запуску экземпляр Fedora Matrix).</p>
<p xml:lang="sl">Verjetno vrhunec te izdaje je popolnoma nova prijavna stran. Zazna konfiguracijo strežnika na podlagi vašega Matrix ID-ja. To vam omogoča prijavo na strežnike, ki zahtevajo enotno prijavo (SSO) (kot sta Mozilla ali prihajajoči pojavek Fedora Matrix).</p>
<p>Servers that require agreeing to the TOS before usage are correctly detected now and redirect to their TOS webpage, allowing the user to agree to them instead of silently failing to load the account.</p>
<p xml:lang="ar">تُكتشف الخوادم التي تتطلب الموافقة على شروط الخدمة قبل الاستخدام بشكل صحيح الآن وتُحوّل إلى صفحة شروط الخدمة الخاصة بها، مما يسمح للمستخدم بالموافقة عليها بدلاً من فشل تحميل الحساب بصمت.</p>
<p xml:lang="ca">Els servidors que requereixen acceptar el TOS abans de l'ús ara es detecten correctament i redirigeixen a la seva pàgina web del TOS, que permet a l'usuari acceptar-lo en lloc de no carregar el compte en silenci.</p>
<p xml:lang="ca-valencia">Els servidors que requerixen acceptar TOS abans de l'ús ara es detecten correctament i redirigixen a la seua pàgina web de TOS, que permet a l'usuari acceptar-lo en lloc de no carregar el compte en silenci.</p>
<p xml:lang="ka">სერვერები, რომლებიც გამოყენების პირობებზე დათანხმებას ითხოვენ, ახლა სწორად არიან აღმოჩენილები და გადაგამისამართებთ მათი გამოყენების პირობების ვებგვერდზე, სადაც მომხმარებელს საშუალება აქვს, დაეთანხმოს მას იმის მაგიერ, ანგარიშის ჩატვირთვა შეცდომის გარეშე, ჩუმად ჩავარდეს.</p>
<p xml:lang="pt-BR">Os servidores que exigem a aceitação dos Termos de Serviço antes do uso agora são detectados corretamente e redirecionam para a página dos Termos de Serviço, permitindo que o usuário os aceite em vez de simplesmente não conseguir carregar a conta.</p>
<p xml:lang="ru">Теперь серверы, требующие согласия с условиями использования перед началом работы, определяются корректно и перенаправляют пользователя на соответствующую веб-страницу, позволяя принять условия вместо аварийного завершения загрузки учётной записи.</p>
<p xml:lang="sl">Strežniki, ki pred uporabo zahtevajo strinjanje s pogoji uporabe, so zdaj pravilno zaznani in preusmerjajo na njihovo spletno stran s pogoji uporabe, kar uporabniku omogoča, da se z njimi strinja, namesto da se račun tiho ne naloži.</p>
<p>It is now possible to open a room into a new window. This allows you to view and interact with multiple rooms at the same time.</p>
<p xml:lang="ar">أصبح من الممكن الآن فتح غرفة في نافذة جديدة. يتيح هذا عرض غرف متعددة والتفاعل معها في الوقت ذاته.</p>
<p xml:lang="ca">Ara és possible obrir una sala en una finestra nova. Això permet veure i interactuar amb diverses sales alhora.</p>
<p xml:lang="ca-valencia">Ara és possible obrir una sala en una finestra nova. Açò permet veure i interactuar amb diverses sales alhora.</p>
<p xml:lang="ka">ახლა შესაძლებელია, ოთახი ცალკე ფანჯარაში გახსნათ. ეს საშუალებას გაძლევთ, ერთდროულად მრავალ ოთახში ისაუბროთ.</p>
<p xml:lang="pt-BR">Agora é possível abrir uma sala em uma nova janela. Isso permite visualizar e interagir com várias salas ao mesmo tempo.</p>
<p xml:lang="ru">Добавлена возможность открытия комнаты в отдельном окне для одновременного просмотра и взаимодействия с несколькими комнатами.</p>
<p xml:lang="sl">Zdaj je mogoče odpreti sobo v novem oknu. To vam omogoča ogled in interakcijo z več sobami hkrati.</p>
<p>We added a few commands to NeoChat (/shrug, /lenny, /join, /ignore, ...).</p>
<p xml:lang="ar">أُضيفت بضعة أوامر لنيوتشات (/shrug, /lenny, /join, /ignore, ...).</p>
<p xml:lang="ca">Hem afegit algunes ordres al NeoChat (/shrug, /lenny, /join, /ignore...).</p>
<p xml:lang="ca-valencia">Hem afegit algunes ordres a NeoChat (/shrug, /lenny, /join, /ignore…).</p>
<p xml:lang="ka">NeoChat-ს რამდენიმე ბრძანება (/shrug, /lenny, /join, /ignore, ...) დავამატეთ.</p>
<p xml:lang="pt-BR">Adicionamos alguns comandos ao NeoChat (/shrug, /lenny, /join, /ignore, ...).</p>
<p xml:lang="ru">Добавлены команды для NeoChat (/shrug, /lenny, /join, /ignore, …).</p>
<p xml:lang="sl">V NeoChat smo dodali nekaj ukazov (/shrug, /lenny, /join, /ignore, ...).</p>
<p>We improved the Plasma integration a bit. Now the number of unread messages is displayed in the Plasma Taskbar.</p>
<p xml:lang="ar">حُسّن تكامل بلازما قليلاً. يُعرض الآن عدد الرسائل غير المقروءة في شريط مهام بلازما.</p>
<p xml:lang="ca">Hem millorat una mica la integració amb el Plasma. Ara es mostra el nombre de missatges sense llegir a la barra de tasques del Plasma.</p>
<p xml:lang="ca-valencia">Hem millorat una mica la integració amb Plasma. Ara es mostra el nombre de missatges sense llegir a la barra de tasques de Plasma.</p>
<p xml:lang="ka">ოდნავ გავაუმჯობესეთ Plasma-ის ინტეგრაცია. ახლა წაუკითხავი შეტყობინებების რაოდენობა Plasma-ის ამოცანათა პანელზე გამოჩნდება.</p>
<p xml:lang="pt-BR">Aprimoramos um pouco a integração com o Plasma. Agora, o número de mensagens não lidas é exibido na barra de tarefas do Plasma.</p>
<p xml:lang="ru">Улучшена интеграция с Plasma. Теперь число непрочитанных сообщений отображается в панели задач Plasma.</p>
<p xml:lang="sl">Nekoliko smo izboljšali integracijo s Plasmo. Zdaj je število neprebranih sporočil prikazano v opravilni vrstici Plasme.</p>
</description>
<url>https://carlschwan.eu/2021/02/22/neochat-1.1/</url>
</release>
<release version="1.0.1" date="2021-01-13">
<description>
<p>This version fixes several bugs.</p>
<p xml:lang="ar">تُصلح هذه النسخة عدة علل.</p>
<p xml:lang="ca">Aquesta versió corregeix diversos errors.</p>
<p xml:lang="ca-valencia">Esta versió corregix diversos errors.</p>
<p xml:lang="es">Esta versión corrige algunos fallos.</p>
<p xml:lang="ka">ეს ვერსია რამდენიმე შეცდომას ასწორებს.</p>
<p xml:lang="pt-BR">Esta versão corrige vários bugs.</p>
<p xml:lang="ru">В этой версии исправлены несколько ошибок.</p>
<p xml:lang="sl">Ta različica odpravlja več napak.</p>
<ul>
<li>NeoChat doesn't require a .well-know configuration in the server to work.</li>
<li xml:lang="ar">لا يتطلب نيوتشات ضبط .well-know في الخادم ليعمل.</li>
<li xml:lang="ca">El NeoChat no requereix una configuració «.well-know» al servidor per a funcionar.</li>
<li xml:lang="ca-valencia">NeoChat no requerix una configuració «.well-know» al servidor per a funcionar.</li>
<li xml:lang="ka">NeoChat-ს სამუშაოდ სერვერზე .well-know კონფიგურაცია არ სჭირდება.</li>
<li xml:lang="pt-BR">O NeoChat não requer uma configuração .well-know no servidor para funcionar.</li>
<li xml:lang="ru">Для работы NeoChat не требуется конфигурация .well-known на сервере;</li>
<li xml:lang="sl">NeoChat za delovanje ne potrebuje konfiguracije .well-know na strežniku.</li>
<li>Edited messages won't show up duplicated anymore.</li>
<li xml:lang="ar">لن تظهر الرسائل المحررة مكررة بعد الآن.</li>
<li xml:lang="ca">Els missatges editats ja no es mostraran duplicats.</li>
<li xml:lang="ca-valencia">Els missatges editats ja no es mostraran duplicats.</li>
<li xml:lang="ka">ჩასწორებული შეტყობინებები გამეორებულად აღარ გამოჩნდება.</li>
<li xml:lang="pt-BR">As mensagens editadas não aparecerão mais duplicadas.</li>
<li xml:lang="ru">Редактируемые сообщения больше не отображаются как дубликаты;</li>
<li xml:lang="sl">Urejena sporočila se ne bodo več prikazovala kot podvojena.</li>
<li>Various graphic glitches have been fixed.</li>
<li xml:lang="ar">أُصلحت مشكلات رسومية متنوعة.</li>
<li xml:lang="ca">S'han corregit diversos errors gràfics.</li>
<li xml:lang="ca-valencia">S'han corregit diversos errors gràfics.</li>
<li xml:lang="ka">გასწორდა სხვადასხვა გრაფიკული შეცდომა.</li>
<li xml:lang="pt-BR">Diversos problemas gráficos foram corrigidos.</li>
<li xml:lang="ru">Исправлены различные графические дефекты;</li>
<li xml:lang="sl">Odpravljene so bile različne grafične napake.</li>
<li>NeoChat now ask for consent to terms and conditions if required instead of displaying nothing.</li>
<li xml:lang="ar">يطلب نيوتشات الآن الموافقة على الشروط والأحكام إذا كانت مطلوبة بدلاً من عدم عرض شيء.</li>
<li xml:lang="ca">El NeoChat ara demana consentiment als termes i condicions si es requereixen en lloc de no mostrar res.</li>
<li xml:lang="ca-valencia">NeoChat ara demana consentiment als termes i condicions si es requerixen en lloc de no mostrar res.</li>
<li xml:lang="ka">NeoChat ახლა გკითხავთ, ეთანხმებით თუ არა გამოყენების პირობებს, თუ ეს აუცილებელია, არაფრის ჩვენების მაგიერ.</li>
<li xml:lang="pt-BR">O NeoChat agora solicita a aceitação dos termos e condições, se necessário, em vez de não exibir nada.</li>
<li xml:lang="ru">В NeoChat реализован запрос согласия с условиями использования, если это требуется;</li>
<li xml:lang="sl">NeoChat zdaj po potrebi zahteva soglasje s pogoji uporabe, namesto da ne prikaže ničesar.</li>
<li>Users avatar in the room list are now displayed correctly.</li>
<li xml:lang="ar">تُعرض صور المستخدمين الرمزية في قائمة الغرف بشكل صحيح الآن.</li>
<li xml:lang="ca">Els avatars d'usuaris a la llista de sales ara es mostren correctament.</li>
<li xml:lang="ca-valencia">Els avatars d'usuaris a la llista de sales ara es mostren correctament.</li>
<li xml:lang="ka">მომხმარებლის ავატარი ოთახების სიაში ახლა სწორადაა ნაჩვენები.</li>
<li xml:lang="pt-BR">Os avatares dos usuários na lista de salas agora são exibidos corretamente.</li>
<li xml:lang="ru">Аватары пользователей в списке комнат отображаются корректно;</li>
<li xml:lang="sl">Uporabnikovi avatarji v seznamu sob so zdaj pravilno prikazani.</li>
<li>Fix image saving</li>
<li xml:lang="ar">إصلاح حفظ الصور</li>
<li xml:lang="ca">Corregeix el desament de les imatges</li>
<li xml:lang="ca-valencia">Corregix la guardada de les imatges</li>
<li xml:lang="ka">გასწორდა სურათების შენახვა</li>
<li xml:lang="pt-BR">Corrigido salvamento de imagem</li>
<li xml:lang="ru">Исправлено сохранение изображений;</li>
<li xml:lang="sl">Popravi shranjevanje slike</li>
</ul>
</description>
<url>https://carlschwan.eu/2020/01/13/neochat-1.0.1/</url>
@@ -1189,13 +674,6 @@
<release version="1.0" date="2020-12-23">
<description>
<p>Initial release of NeoChat, the KDE matrix client.</p>
<p xml:lang="ar">الإصدار الأولي لنيوتشات، عميل ماتركس لكيدي.</p>
<p xml:lang="ca">Versió inicial de NeoChat, el client de Matrix de KDE.</p>
<p xml:lang="ca-valencia">Versió inicial de NeoChat, el client de Matrix de KDE.</p>
<p xml:lang="ka">NeoChat-ის, KDE-ის მატრიცის კლიენტის პირველი ვერსია.</p>
<p xml:lang="pt-BR">Lançamento inicial do NeoChat, o cliente Matrix do KDE.</p>
<p xml:lang="ru">Первый выпуск NeoChat — клиента Matrix для KDE.</p>
<p xml:lang="sl">Začetna izdaja NeoChata, odjemalca matrixa za KDE.</p>
</description>
<url>https://carlschwan.eu/2020/12/23/announcing-neochat-1.0-the-kde-matrix-client/</url>
</release>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,7 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/AttachmentPane.qml
qml/QuickFormatBar.qml
qml/UserDetailDialog.qml
qml/OpenFileDialog.qml
qml/KeyVerificationDialog.qml
qml/ConfirmLogoutDialog.qml
qml/VerificationMessage.qml
@@ -78,6 +79,7 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/EmojiSas.qml
qml/VerificationCanceled.qml
qml/MessageSourceSheet.qml
qml/LocationChooser.qml
qml/InvitationView.qml
qml/AvatarTabButton.qml
qml/OsmLocationPlugin.qml
@@ -103,6 +105,7 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/HoverLinkIndicator.qml
qml/AvatarNotification.qml
qml/ReasonDialog.qml
qml/NewPollDialog.qml
qml/UserMenu.qml
qml/MeetingDialog.qml
qml/SeenByDialog.qml

View File

@@ -207,6 +207,10 @@
</entry>
</group>
<group name="FeatureFlags">
<entry name="Threads" type="bool">
<label>Enable threads</label>
<default>false</default>
</entry>
<entry name="Phone3PId" type="bool">
<label>Enable add phone numbers as 3PIDs</label>
<default>false</default>

View File

@@ -21,7 +21,6 @@ Delegates.RoundedItemDelegate {
signal contextMenuRequested
signal selected
activeFocusOnTab: true
padding: Kirigami.Units.largeSpacing
QQC2.ToolTip.visible: hovered

View File

@@ -8,26 +8,45 @@ import org.kde.kirigami as Kirigami
import org.kde.neochat
import Quotient
Kirigami.PromptDialog {
id: root
required property NeoChatRoom room
title: root.room.isSpace ? i18nc("@title:dialog", "Confirm Leaving Space") : i18nc("@title:dialog", "Confirm Leaving Room")
subtitle: root.room ? i18nc("Do you really want to leave <room name>?", "Do you really want to leave %1?", root.room.displayNameForHtml) : ""
dialogType: Kirigami.PromptDialog.Warning
standardButtons: QQC2.Dialog.Cancel
subtitle: {
if (root.room) {
let message = xi18nc("@info Do you really want to leave <room name>?", "Do you really want to leave %1?", root.room.displayNameForHtml)
onAccepted: root.room.forget()
// List any possible side-effects the user needs to be made aware of.
if (root.room.historyVisibility !== "world_readable" && root.room.historyVisibility !== "shared") {
message += xi18nc("@info", "<br><strong>This room's history is limited to when you rejoin the room.</strong>")
}
if (root.room.joinRule === JoinRule.JoinRule.Invite) {
message += xi18nc("@info", "<br><strong>This room can only be rejoined with an invite.</strong>");
}
return message;
}
return "";
}
dialogType: Kirigami.PromptDialog.Warning
onRejected: {
root.close();
}
footer: QQC2.DialogButtonBox {
standardButtons: QQC2.Dialog.Cancel
QQC2.Button {
text: i18nc("@action:button Leave this room/space", "Leave")
icon.name: "arrow-left-symbolic"
onClicked: root.accept()
text: i18nc("@action:button", "Leave Room")
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
icon.name: "arrow-left-symbolic"
//onClicked: root.room.forget();
}
}
}

View File

@@ -15,16 +15,22 @@ Kirigami.PromptDialog {
title: i18nc("@title:dialog", "Sign out")
subtitle: i18n("Are you sure you want to sign out?")
dialogType: Kirigami.PromptDialog.Warning
standardButtons: QQC2.Dialog.Cancel
onAccepted: root.connection.logout(true)
onRejected: {
root.close();
}
footer: QQC2.DialogButtonBox {
standardButtons: QQC2.Dialog.Cancel
QQC2.Button {
text: i18nc("@action:button", "Sign out")
onClicked: root.accept()
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
onClicked: {
root.connection.logout(true);
root.close();
root.accepted();
}
}
}
}

View File

@@ -17,5 +17,12 @@ Kirigami.PromptDialog {
standardButtons: QQC2.DialogButtonBox.Open | QQC2.DialogButtonBox.Cancel
onAccepted: Qt.openUrlExternally(root.link)
onAccepted: {
Qt.openUrlExternally(root.link);
root.close();
}
onRejected: {
root.close();
}
}

View File

@@ -4,7 +4,6 @@
import QtQuick
import QtQuick.Window
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
@@ -25,8 +24,6 @@ Kirigami.Dialog {
signal roomSelected(string roomId, string displayName, url avatarUrl, string alias, string topic, int memberCount, bool isJoined)
title: i18nc("@title", "Manually Enter a Room")
showCloseButton: false
standardButtons: QQC2.Dialog.Cancel
width: Math.min(root.Window.window.width, Kirigami.Units.gridUnit * 24)
leftPadding: 0
@@ -34,26 +31,35 @@ Kirigami.Dialog {
topPadding: 0
bottomPadding: 0
onAccepted: {
// We don't necessarily have all the info so fill out the best we can.
let roomId = roomIdAliasText.isAlias() ? "" : roomIdAliasText.text;
let displayName = "";
let avatarUrl = "";
let alias = roomIdAliasText.isAlias() ? roomIdAliasText.text : "";
let topic = "";
let memberCount = -1;
let isJoined = false;
if (roomIdAliasText.room) {
roomId = roomIdAliasText.room.id;
displayName = roomIdAliasText.room.displayName;
avatarUrl = roomIdAliasText.room.avatarUrl.toString().length > 0 ? connection.makeMediaUrl(roomIdAliasText.room.avatarUrl) : "";
alias = roomIdAliasText.room.canonicalAlias;
topic = roomIdAliasText.room.topic;
memberCount = roomIdAliasText.room.joinedCount;
isJoined = true;
standardButtons: Kirigami.Dialog.Cancel
customFooterActions: [
Kirigami.Action {
enabled: roomIdAliasText.isValidText
text: i18n("OK")
icon.name: "dialog-ok"
onTriggered: {
// We don't necessarily have all the info so fill out the best we can.
let roomId = roomIdAliasText.isAlias() ? "" : roomIdAliasText.text;
let displayName = "";
let avatarUrl = "";
let alias = roomIdAliasText.isAlias() ? roomIdAliasText.text : "";
let topic = "";
let memberCount = -1;
let isJoined = false;
if (roomIdAliasText.room) {
roomId = roomIdAliasText.room.id;
displayName = roomIdAliasText.room.displayName;
avatarUrl = roomIdAliasText.room.avatarUrl.toString().length > 0 ? connection.makeMediaUrl(roomIdAliasText.room.avatarUrl) : "";
alias = roomIdAliasText.room.canonicalAlias;
topic = roomIdAliasText.room.topic;
memberCount = roomIdAliasText.room.joinedCount;
isJoined = true;
}
root.roomSelected(roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined);
root.close();
}
}
root.roomSelected(roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined);
}
]
contentItem: ColumnLayout {
spacing: 0
@@ -104,16 +110,4 @@ Kirigami.Dialog {
roomIdAliasText.forceActiveFocus();
timer.restart();
}
footer: QQC2.DialogButtonBox {
QQC2.Button {
text: i18nc("@action:button Join this room/space", "Join")
icon.name: "checkmark"
enabled: roomIdAliasText.isValidText
onClicked: root.accept()
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
}
}
}

View File

@@ -24,16 +24,25 @@ Kirigami.Dialog {
signal userSelected(string userId)
title: i18nc("@title", "User ID")
showCloseButton: false
width: Math.min(QQC2.ApplicationWindow.window.width, Kirigami.Units.gridUnit * 24)
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
standardButtons: QQC2.Dialog.Cancel
onAccepted: root.userSelected(userIdText.text)
standardButtons: Kirigami.Dialog.Cancel
customFooterActions: [
Kirigami.Action {
enabled: userIdText.isValidText
text: i18n("OK")
icon.name: "dialog-ok"
onTriggered: {
root.userSelected(userIdText.text)
root.accept();
}
}
]
contentItem: ColumnLayout {
spacing: 0
@@ -70,16 +79,4 @@ Kirigami.Dialog {
userIdText.forceActiveFocus();
timer.restart();
}
footer: QQC2.DialogButtonBox {
QQC2.Button {
text: i18nc("@action:button Perform an action with this user ID", "Ok")
icon.name: "checkmark"
enabled: userIdText.isValidText
onClicked: root.accept()
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
}
}
}

View File

@@ -4,7 +4,6 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
@@ -15,16 +14,11 @@ Kirigami.PromptDialog {
title: hasExistingMeeting ? i18nc("@title", "Join Meeting") : i18nc("@title", "Start Meeting")
subtitle: hasExistingMeeting ? i18nc("@info:label", "You are about to join a Jitsi meeting in your web browser.") : i18nc("@info:label", "You are about to start a new Jitsi meeting in your web browser.")
standardButtons: QQC2.Dialog.Cancel
standardButtons: Kirigami.Dialog.Cancel
footer: QQC2.DialogButtonBox {
QQC2.Button {
icon.name: "camera-video-symbolic"
text: root.hasExistingMeeting ? i18nc("@action:button Join the Jitsi meeting", "Join") : i18nc("@action:button Start a new Jitsi meeting", "Start")
onClicked: root.accept()
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
}
customFooterActions: Kirigami.Action {
icon.name: "camera-video-symbolic"
text: root.hasExistingMeeting ? i18nc("@action:button Join the Jitsi meeting", "Join") : i18nc("@action:button Start a new Jitsi meeting", "Start")
onTriggered: root.accept()
}
}

View File

@@ -19,12 +19,22 @@ Kirigami.Dialog {
required property NeoChatRoom room
standardButtons: Kirigami.Dialog.Cancel
customFooterActions: [
Kirigami.Action {
enabled: optionModel.allValuesSet && questionTextField.text.length > 0
text: i18nc("@action:button", "Send")
icon.name: "document-send"
onTriggered: {
root.room.postPoll(pollTypeCombo.currentValue, questionTextField.text, optionModel.values())
root.close()
}
}
]
width: Math.min(QQC2.ApplicationWindow.window.width, Kirigami.Units.gridUnit * 24)
title: i18nc("@title: create new poll in the room", "Create Poll")
showCloseButton: false
standardButtons: QQC2.Dialog.Cancel
onAccepted: root.room.postPoll(pollTypeCombo.currentValue, questionTextField.text, optionModel.values())
contentItem: ColumnLayout {
spacing: 0
@@ -138,16 +148,4 @@ Kirigami.Dialog {
onClicked: optionModel.append({optionText: ""})
}
}
footer: QQC2.DialogButtonBox {
QQC2.Button {
enabled: optionModel.allValuesSet && questionTextField.text.length > 0
text: i18nc("@action:button", "Send")
icon.name: "document-send"
onClicked: root.accept()
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
}
}
}

View File

@@ -361,6 +361,7 @@ Kirigami.Page {
id: chatBar
width: parent.width
currentRoom: root.currentRoom
connection: root.currentRoom.connection as NeoChatConnection
// Creating a reply (or doing anything in the chat bar) can change the height, but this isn't picked up on the root's onHeightChanged.
onHeightChanged: root.resetViewSettling()

View File

@@ -16,7 +16,7 @@ KirigamiComponents.ConvergentContextMenu {
id: root
required property Kirigami.ApplicationWindow window
required property NeochatRoomMember author
required property var author
headerContentItem: RowLayout {
id: detailRow
@@ -68,7 +68,7 @@ KirigamiComponents.ConvergentContextMenu {
text: i18nc("@action:button", "Mention")
icon.name: "username-copy-symbolic"
onTriggered: {
RoomManager.currentRoom.mainCache.mentionAdded(root.author.disambiguatedName, "https://matrix.to/#/" + root.author.id);
RoomManager.currentRoom.mainCache.mentionAdded(root.author.id);
}
}
}

View File

@@ -129,6 +129,15 @@ RoomManager::RoomManager(QObject *parent)
m_messageFilterModel->invalidate();
}
});
ContentProvider::self().setThreadsEnabled(NeoChatConfig::threads());
MessageModel::setThreadsEnabled(NeoChatConfig::threads());
connect(NeoChatConfig::self(), &NeoChatConfig::ThreadsChanged, this, [this] {
ContentProvider::self().setThreadsEnabled(NeoChatConfig::threads());
MessageModel::setThreadsEnabled(NeoChatConfig::threads());
if (m_timelineModel) {
Q_EMIT m_timelineModel->threadsEnabledChanged();
}
});
connect(NeoChatConfig::self(), &NeoChatConfig::SortOrderChanged, this, [this]() {
m_sortFilterRoomTreeModel->invalidate();
});

View File

@@ -8,9 +8,6 @@ ecm_add_qml_module(Chatbar GENERATE_PLUGIN_SOURCE
QML_FILES
AttachDialog.qml
ChatBar.qml
ChatBarCore.qml
RichEditBar.qml
SendBar.qml
CompletionMenu.qml
EmojiDelegate.qml
EmojiGrid.qml
@@ -18,25 +15,6 @@ ecm_add_qml_module(Chatbar GENERATE_PLUGIN_SOURCE
EmojiPicker.qml
EmojiDialog.qml
EmojiTonesPicker.qml
StylePicker.qml
StyleDelegate.qml
ImageEditorPage.qml
VoiceMessageDialog.qml
LinkDialog.qml
LocationChooser.qml
NewPollDialog.qml
TableDialog.qml
StyleButton.qml
SOURCES
chatbuttonhelper.cpp
styledelegatehelper.cpp
)
target_include_directories(Chatbar PRIVATE ${CMAKE_BINARY_DIR})
target_link_libraries(Chatbar PRIVATE
Qt::Core
Qt::Quick
Qt::QuickControls2
KF6::Kirigami
LibNeoChat
)

View File

@@ -1,6 +1,5 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
// SPDX-FileCopyrightText: 2026 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
pragma ComponentBehavior: Bound
@@ -26,26 +25,27 @@ import org.kde.neochat.libneochat as LibNeoChat
*
* @sa ChatBar
*/
Item {
QQC2.Control {
id: root
/**
* @brief The current room that user is viewing.
*/
required property LibNeoChat.NeoChatRoom currentRoom
required property NeoChatRoom currentRoom
required property NeoChatConnection connection
onActiveFocusChanged: textField.forceActiveFocus()
onCurrentRoomChanged: {
_private.chatBarCache = currentRoom.mainCache
if (ShareHandler.text.length > 0 && ShareHandler.room === root.currentRoom.id) {
contentModel.focusedTextItem.
textField.text = ShareHandler.text;
ShareHandler.text = "";
ShareHandler.room = "";
}
}
onActiveFocusChanged: if (activeFocus) {
core.forceActiveFocus();
}
Connections {
target: ShareHandler
function onRoomChanged(): void {
@@ -60,59 +60,358 @@ Item {
Connections {
target: root.currentRoom.mainCache
function onMentionAdded(text: string, hRef: string): void {
core.completionModel.insertCompletion(text, hRef);
function onMentionAdded(mention: string): void {
// add mention text
textField.append(mention + " ");
// move cursor to the end
textField.cursorPosition = textField.text.length;
// move the focus back to the chat bar
core.model.refocusCurrentComponent();
textField.forceActiveFocus(Qt.OtherFocusReason);
}
}
implicitHeight: core.implicitHeight + Kirigami.Units.largeSpacing
/**
* @brief The list of actions in the ChatBar.
*
* Each of these will be visualised in the ChatBar so new actions can be added
* by appending to this list.
*/
property list<BusyAction> actions: [
BusyAction {
id: attachmentAction
ChatBarCore {
id: core
anchors.top: root.top
anchors.horizontalCenter: root.horizontalCenter
isBusy: root.currentRoom && root.currentRoom.hasFileUploading
Message.room: root.currentRoom
room: root.currentRoom
maxAvailableWidth: chatBarSizeHelper.availableWidth
}
MouseArea {
id: hoverArea
anchors {
top: chatModeButton.top
left: root.left
right: root.right
bottom: core.top
// Matrix does not allow sending attachments in replies
visible: _private.chatBarCache.replyId.length === 0 && _private.chatBarCache.attachmentPath.length === 0
icon.name: "mail-attachment"
text: i18nc("@action:button", "Attach an image or file")
displayHint: Kirigami.DisplayHint.IconOnly
onTriggered: {
if (Clipboard.hasImage) {
let dialog = attachDialog.createObject(root.QQC2.Overlay.overlay) as AttachDialog;
dialog.chosen.connect(path => _private.chatBarCache.attachmentPath = path);
dialog.open();
} else {
let dialog = openFileDialog.createObject(root.QQC2.Overlay.overlay) as OpenFileDialog;
dialog.chosen.connect(path => _private.chatBarCache.attachmentPath = path);
dialog.open();
}
}
tooltip: text
},
BusyAction {
id: emojiAction
isBusy: false
visible: !Kirigami.Settings.isMobile
icon.name: "smiley"
text: i18nc("@action:button", "Emojis & Stickers")
displayHint: Kirigami.DisplayHint.IconOnly
checkable: true
onTriggered: {
if (emojiDialog.visible) {
emojiDialog.close();
} else {
emojiDialog.open();
}
}
tooltip: text
},
BusyAction {
id: mapButton
icon.name: "mark-location-symbolic"
isBusy: false
text: i18nc("@action:button", "Send a Location")
displayHint: QQC2.AbstractButton.IconOnly
onTriggered: {
(locationChooser.createObject(QQC2.Overlay.overlay, {
room: root.currentRoom
}) as LocationChooser).open();
}
tooltip: text
},
BusyAction {
id: pollButton
icon.name: "amarok_playcount"
isBusy: false
text: i18nc("@action:button", "Create a Poll")
displayHint: QQC2.AbstractButton.IconOnly
onTriggered: {
(newPollDialog.createObject(QQC2.Overlay.overlay, {
room: root.currentRoom
}) as NewPollDialog).open();
}
tooltip: text
},
BusyAction {
icon.name: "microphone"
isBusy: false
text: i18nc("@action:button", "Send a Voice Message")
displayHint: QQC2.AbstractButton.IconOnly
onTriggered: {
let dialog = voiceMessageDialog.createObject(root, {
room: root.currentRoom
}) as VoiceMessageDialog;
dialog.open();
}
tooltip: text
},
BusyAction {
id: sendAction
isBusy: false
icon.name: "document-send"
text: i18nc("@action:button", "Send message")
displayHint: Kirigami.DisplayHint.IconOnly
checkable: true
onTriggered: {
_private.postMessage();
}
tooltip: text
}
propagateComposedEvents: true
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
QQC2.Button {
id: chatModeButton
anchors {
bottom: core.top
bottomMargin: Kirigami.Units.smallSpacing
horizontalCenter: root.horizontalCenter
]
spacing: 0
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Separator {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
}
visible: hoverArea.containsMouse || hovered || core.hovered
width: Kirigami.Units.iconSizes.enormous
height: Kirigami.Units.iconSizes.smallMedium
icon.name: NeoChatConfig.sendMessageWith === 0 ? "arrow-up" : "arrow-down"
text: NeoChatConfig.sendMessageWith === 0 ? i18nc("@action:button", "Enter rich text mode") : i18nc("@action:button", "Exit rich text mode")
display: QQC2.AbstractButton.IconOnly
onClicked: NeoChatConfig.sendMessageWith = NeoChatConfig.sendMessageWith === 0 ? 1 : 0
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: text
}
leftPadding: rightPadding
rightPadding: (root.width - chatBarSizeHelper.availableWidth) / 2
topPadding: 0
bottomPadding: 0
contentItem: ColumnLayout {
spacing: 0
Item {
// Required to adjust for the top separator
Layout.preferredHeight: 1
Layout.fillWidth: true
}
Loader {
id: replyLoader
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
Layout.preferredHeight: active ? (item as Item).implicitHeight : 0
active: visible
visible: root.currentRoom.mainCache.replyId.length > 0
sourceComponent: replyPane
}
RowLayout {
visible: replyLoader.visible && !root.currentRoom.mainCache.relationAuthorIsPresent
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
source: "help-hint-symbolic"
color: Kirigami.Theme.disabledTextColor
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
}
QQC2.Label {
text: i18nc("@info", "The user you're replying to has left the room, and can't be notified.")
color: Kirigami.Theme.disabledTextColor
}
}
Loader {
id: attachLoader
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
Layout.preferredHeight: active ? (item as Item).implicitHeight : 0
active: visible
visible: root.currentRoom.mainCache.attachmentPath.length > 0
sourceComponent: attachmentPane
}
RowLayout {
QQC2.ScrollView {
id: chatBarScrollView
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.fillWidth: true
Layout.maximumHeight: Kirigami.Units.gridUnit * 8
Layout.minimumHeight: Kirigami.Units.gridUnit * 3
// HACK: This is to stop the ScrollBar flickering on and off as the height is increased
QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded
Behavior on implicitHeight {
NumberAnimation {
id: chatBarHeightAnimation
duration: Kirigami.Units.shortDuration
easing.type: Easing.InOutCubic
}
}
QQC2.TextArea {
id: textField
placeholderText: root.currentRoom.usesEncryption ? i18nc("@placeholder", "Send an encrypted message…") : root.currentRoom.mainCache.attachmentPath.length > 0 ? i18nc("@placeholder", "Set an attachment caption…") : i18nc("@placeholder", "Send a message…")
verticalAlignment: TextEdit.AlignVCenter
wrapMode: TextEdit.Wrap
// This has to stay PlainText or else formatting starts breaking in strange ways
textFormat: TextEdit.PlainText
font.pointSize: Kirigami.Theme.defaultFont.pointSize * NeoChatConfig.fontScale
Accessible.description: placeholderText
Kirigami.SpellCheck.enabled: false
Timer {
id: repeatTimer
interval: 5000
}
onTextChanged: {
if (!repeatTimer.running && NeoChatConfig.typingNotifications) {
var textExists = text.length > 0;
root.currentRoom.sendTypingNotification(textExists);
textExists ? repeatTimer.start() : repeatTimer.stop();
}
}
onSelectedTextChanged: {
if (selectedText.length > 0) {
quickFormatBar.selectionStart = selectionStart;
quickFormatBar.selectionEnd = selectionEnd;
quickFormatBar.open();
} else if (quickFormatBar.visible) {
quickFormatBar.close();
}
}
QuickFormatBar {
id: quickFormatBar
x: textField.cursorRectangle.x
y: textField.cursorRectangle.y - height
onFormattingSelected: (format, selectionStart, selectionEnd) => _private.formatText(format, selectionStart, selectionEnd)
}
Keys.onEnterPressed: event => {
const controlIsPressed = event.modifiers & Qt.ControlModifier;
if (completionMenu.visible) {
completionMenu.complete();
} else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile || NeoChatConfig.sendMessageWith === 1 && !controlIsPressed || NeoChatConfig.sendMessageWith === 0 && controlIsPressed) {
textField.insert(cursorPosition, "\n");
} else if (NeoChatConfig.sendMessageWith === 0 && !controlIsPressed || NeoChatConfig.sendMessageWith === 1 && controlIsPressed) {
_private.postMessage();
}
}
Keys.onReturnPressed: event => {
const controlIsPressed = event.modifiers & Qt.ControlModifier;
if (completionMenu.visible) {
completionMenu.complete();
} else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile || NeoChatConfig.sendMessageWith === 1 && !controlIsPressed || NeoChatConfig.sendMessageWith === 0 && controlIsPressed) {
textField.insert(cursorPosition, "\n");
} else if (NeoChatConfig.sendMessageWith === 0 && !controlIsPressed || NeoChatConfig.sendMessageWith === 1 && controlIsPressed) {
_private.postMessage();
}
}
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete();
} else {
contextDrawer.handle.children[0].forceActiveFocus()
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
event.accepted = _private.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
root.currentRoom.replyLastMessage();
} else if (event.key === Qt.Key_Up && textField.text.length === 0) {
root.currentRoom.editLastMessage();
} else if (event.key === Qt.Key_Up && completionMenu.visible) {
completionMenu.decrementIndex();
} else if (event.key === Qt.Key_Down && completionMenu.visible) {
completionMenu.incrementIndex();
} else if (event.key === Qt.Key_Backspace || event.key === Qt.Key_Delete) {
if (textField.text == selectedText || textField.text.length <= 1) {
root.currentRoom.sendTypingNotification(false);
repeatTimer.stop();
}
if (quickFormatBar.visible && selectedText.length > 0) {
quickFormatBar.close();
}
} else if (event.key === Qt.Key_Escape && completionMenu.visible) {
completionMenu.close();
}
}
Keys.onShortcutOverride: event => {
if ((_private.chatBarCache.isReplying || _private.chatBarCache.attachmentPath.length > 0) && event.key === Qt.Key_Escape) {
_private.chatBarCache.attachmentPath = "";
_private.chatBarCache.replyId = "";
_private.chatBarCache.threadId = "";
event.accepted = true;
}
}
background: MouseArea {
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
z: 1
}
}
}
RowLayout {
id: actionsRow
spacing: 0
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: Kirigami.Units.smallSpacing * 4
Repeater {
model: root.actions
delegate: QQC2.ToolButton {
id: actionDelegate
required property BusyAction modelData
icon.name: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source)
onClicked: if (!pieProgress.visible) {
modelData.trigger()
}
padding: Kirigami.Units.smallSpacing
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: modelData.tooltip
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
PieProgressBar {
id: pieProgress
anchors.fill: parent
visible: actionDelegate.modelData.isBusy
progress: root.currentRoom.fileUploadingProgress
}
}
}
}
}
}
LibNeoChat.DelegateSizeHelper {
id: chatBarSizeHelper
parentItem: root
@@ -120,8 +419,195 @@ Item {
endBreakpoint: Kirigami.Units.gridUnit * 66
startPercentWidth: 100
endPercentWidth: NeoChatConfig.compactLayout ? 100 : 85
leftPadding: NeoChatConfig.compactLayout ? Kirigami.Units.largeSpacing * 2 : 0
rightPadding: NeoChatConfig.compactLayout ? Kirigami.Units.largeSpacing * 2 : 0
maxWidth: NeoChatConfig.compactLayout ? root.width - Kirigami.Units.largeSpacing * 2 : Kirigami.Units.gridUnit * 60
}
Component {
id: replyPane
Item {
implicitHeight: replyComponent.implicitHeight
ReplyComponent {
id: replyComponent
replyContentModel: ContentProvider.contentModelForEvent(root.currentRoom, _private.chatBarCache.replyId, true)
Message.maxContentWidth: (replyLoader.item as Item).width
// When the user replies to a message and the preview is loaded, make sure the text field is focused again
Component.onCompleted: textField.forceActiveFocus(Qt.OtherFocusReason)
}
QQC2.Button {
id: cancelButton
anchors.top: parent.top
anchors.right: parent.right
display: QQC2.AbstractButton.IconOnly
text: i18nc("@action:button", "Cancel reply")
icon.name: "dialog-close"
onClicked: {
_private.chatBarCache.replyId = "";
_private.chatBarCache.attachmentPath = "";
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
}
Component {
id: attachmentPane
AttachmentPane {
attachmentPath: _private.chatBarCache.attachmentPath
onAttachmentCancelled: {
_private.chatBarCache.attachmentPath = "";
root.forceActiveFocus();
}
}
}
QtObject {
id: _private
property ChatBarCache chatBarCache
function postMessage() {
_private.chatBarCache.postMessage();
repeatTimer.stop();
root.currentRoom.markAllMessagesAsRead();
textField.clear();
}
function formatText(format, selectionStart, selectionEnd) {
let index = textField.cursorPosition;
/*
* There cannot be white space at the beginning or end of the string for the
* formatting to work so move the sectionStart and sectionEnd markers past any whitespace.
*/
let innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
if (innerText.charAt(innerText.length - 1) === " ") {
let trimmedRightString = innerText.replace(/\s*$/, "");
let trimDifference = innerText.length - trimmedRightString.length;
selectionEnd -= trimDifference;
}
if (innerText.charAt(0) === " ") {
let trimmedLeftString = innerText.replace(/^\s*/, "");
let trimDifference = innerText.length - trimmedLeftString.length;
selectionStart = selectionStart + trimDifference;
}
let startText = textField.text.substr(0, selectionStart);
// Needs updating with the new selectionStart and selectionEnd with white space trimmed.
innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
let endText = textField.text.substr(selectionEnd);
textField.text = "";
textField.text = startText + format.start + innerText + format.end + format.extra + endText;
/*
* Put the cursor where it was when the popup was opened accounting for the
* new markup.
*
* The exception is for a hyperlink where it is placed ready to start typing
* the url.
*/
if (format.extra !== "") {
textField.cursorPosition = selectionEnd + format.start.length + format.end.length;
} else if (index == selectionStart) {
textField.cursorPosition = index;
} else {
textField.cursorPosition = index + format.start.length + format.end.length;
}
}
function pasteImage() {
let localPath = Clipboard.saveImage();
if (localPath.length === 0) {
return false;
}
_private.chatBarCache.attachmentPath = localPath;
return true;
}
}
ChatDocumentHandler {
id: documentHandler
type: ChatBarType.Room
textItem: textField
room: root.currentRoom
}
Component {
id: openFileDialog
OpenFileDialog {
parentWindow: Window.window
currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
}
}
Component {
id: attachDialog
AttachDialog {
anchors.centerIn: parent
}
}
Component {
id: locationChooser
LocationChooser {}
}
Component {
id: newPollDialog
NewPollDialog {}
}
Component {
id: voiceMessageDialog
VoiceMessageDialog {}
}
CompletionMenu {
id: completionMenu
chatDocumentHandler: documentHandler
connection: root.connection
x: 1
y: -height
width: parent.width - 1
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
EmojiDialog {
id: emojiDialog
x: root.width - width
y: -implicitHeight
modal: false
includeCustom: true
closeOnChosen: false
currentRoom: root.currentRoom
onChosen: emoji => root.insertText(emoji)
onClosed: if (emojiAction.checked) {
emojiAction.checked = false;
}
}
function insertText(text) {
let initialCursorPosition = textField.cursorPosition;
textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition);
textField.cursorPosition = initialCursorPosition + text.length;
}
component BusyAction : Kirigami.Action {
required property bool isBusy
}
}

View File

@@ -1,129 +0,0 @@
// SPDX-FileCopyrightText: 2026 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtCore
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.neochat.libneochat as LibNeoChat
QQC2.Control {
id: root
/**
* @brief The current room that user is viewing.
*/
required property LibNeoChat.NeoChatRoom room
property int chatBarType: LibNeoChat.ChatBarType.Room
required property real maxAvailableWidth
readonly property ChatBarMessageContentModel model: ChatBarMessageContentModel {
type: root.chatBarType
room: root.room
sendMessageWithEnter: NeoChatConfig.sendMessageWith === 0
}
readonly property LibNeoChat.CompletionModel completionModel: LibNeoChat.CompletionModel {
room: root.room
type: root.chatBarType
textItem: root.model.focusedTextItem
roomListModel: RoomManager.roomListModel
userListModel: RoomManager.userListModel
onIsCompletingChanged: {
if (!isCompleting) {
return;
}
let dialog = Qt.createComponent('org.kde.neochat.chatbar', 'CompletionMenu').createObject(root.model.focusedTextItem.textItem, {
model: root.completionModel,
keyHelper: root.model.keyHelper
}).open();
}
}
Message.contentModel: root.model
onActiveFocusChanged: root.model.refocusCurrentComponent()
implicitWidth: root.maxAvailableWidth - (root.maxAvailableWidth >= (parent?.width ?? 0) ? Kirigami.Units.largeSpacing * 2 : 0)
topPadding: Kirigami.Units.smallSpacing
bottomPadding: Kirigami.Units.smallSpacing
contentItem: ColumnLayout {
RichEditBar {
id: richEditBar
visible: NeoChatConfig.sendMessageWith === 1
maxAvailableWidth: root.maxAvailableWidth - Kirigami.Units.largeSpacing * 2
room: root.room
contentModel: root.model
onClicked: root.model.refocusCurrentComponent()
}
Kirigami.Separator {
Layout.fillWidth: true
visible: NeoChatConfig.sendMessageWith === 1
}
RowLayout {
QQC2.ScrollView {
id: chatScrollView
Layout.fillWidth: true
Layout.maximumHeight: Kirigami.Units.gridUnit * (root.model.hasAttachment ? 12 : 8)
contentWidth: availableWidth
clip: true
ColumnLayout {
readonly property real visibleTop: chatScrollView.QQC2.ScrollBar.vertical.position * chatScrollView.contentHeight
readonly property real visibleBottom: chatScrollView.QQC2.ScrollBar.vertical.position * chatScrollView.contentHeight + chatScrollView.QQC2.ScrollBar.vertical.size * chatScrollView.contentHeight
readonly property rect cursorRectInColumn: mapFromItem(root.model.focusedTextItem.textItem, root.model.focusedTextItem.cursorRectangle);
onCursorRectInColumnChanged: {
if (chatScrollView.QQC2.ScrollBar.vertical.visible) {
if (cursorRectInColumn.y < visibleTop) {
chatScrollView.QQC2.ScrollBar.vertical.position = cursorRectInColumn.y / chatScrollView.contentHeight
} else if (cursorRectInColumn.y + cursorRectInColumn.height > visibleBottom) {
chatScrollView.QQC2.ScrollBar.vertical.position = (cursorRectInColumn.y + cursorRectInColumn.height - (chatScrollView.QQC2.ScrollBar.vertical.size * chatScrollView.contentHeight)) / chatScrollView.contentHeight
}
}
}
width: chatScrollView.width
spacing: Kirigami.Units.smallSpacing
Repeater {
id: chatContentView
model: root.model
delegate: BaseMessageComponentChooser {
rightAnchorMargin: chatScrollView.QQC2.ScrollBar.vertical.visible ? chatScrollView.QQC2.ScrollBar.vertical.width : 0
}
}
}
}
SendBar {
room: root.room
contentModel: root.model
maxAvailableWidth: root.maxAvailableWidth
}
}
}
background: Kirigami.ShadowedRectangle {
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
radius: Kirigami.Units.cornerRadius
color: Kirigami.Theme.backgroundColor
border {
color: Kirigami.ColorUtils.linearInterpolation(Kirigami.Theme.backgroundColor, Kirigami.Theme.textColor, Kirigami.Theme.frameContrast)
width: 1
}
}
}

View File

@@ -11,54 +11,24 @@ import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.kirigamiaddons.labs.components as KirigamiComponents
import org.kde.neochat.libneochat as LibNeoChat
import org.kde.neochat
QQC2.Popup {
id: root
property alias model: completions.model
required property NeoChatConnection connection
required property var chatDocumentHandler
required property LibNeoChat.ChatKeyHelper keyHelper
visible: completions.count > 0
Connections {
target: keyHelper
function onUnhandledUp(isCompleting: bool): void {
if (!isCompleting) {
return;
}
root.decrementIndex();
}
function onUnhandledDown(isCompleting: bool): void {
if (!isCompleting) {
return;
}
root.incrementIndex();
}
function onUnhandledTab(isCompleting: bool): void {
if (!isCompleting) {
return;
}
root.completeCurrent();
}
function onUnhandledReturn(isCompleting: bool): void {
if (!isCompleting) {
return;
}
root.completeCurrent();
}
function onCloseCompletion(): void {
root.close();
root.model.ignoreCurrentCompletion();
}
onVisibleChanged: if (visible) {
root.open();
}
x: model.textItem.textItem.cursorRectangle.x - Kirigami.Units.largeSpacing
y: model.textItem.textItem.cursorRectangle.y - implicitHeight - Kirigami.Units.smallSpacing
Component.onCompleted: {
chatDocumentHandler.completionModel.roomListModel = RoomManager.roomListModel;
chatDocumentHandler.completionModel.userListModel = RoomManager.userListModel;
}
function incrementIndex() {
completions.incrementCurrentIndex();
@@ -68,8 +38,8 @@ QQC2.Popup {
completions.decrementCurrentIndex();
}
function completeCurrent() {
model.insertCompletion(completions.currentItem.replacedText, completions.currentItem.hRef);
function complete() {
root.chatDocumentHandler.complete(completions.currentIndex);
}
leftPadding: 0
@@ -79,57 +49,61 @@ QQC2.Popup {
implicitHeight: Math.min(completions.contentHeight, Kirigami.Units.gridUnit * 10)
contentItem: QQC2.ScrollView {
contentWidth: Kirigami.Units.gridUnit * 20
contentItem: ColumnLayout {
spacing: 0
Kirigami.Separator {
Layout.fillWidth: true
}
QQC2.ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: contentHeight
Layout.maximumHeight: Kirigami.Units.gridUnit * 10
ListView {
id: completions
currentIndex: 0
keyNavigationWraps: true
highlightMoveDuration: 100
onCountChanged: currentIndex = 0
delegate: Delegates.RoundedItemDelegate {
id: completionDelegate
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
required property int index
required property string displayName
required property string subtitle
required property string iconName
required property string replacedText
required property url hRef
ListView {
id: completions
text: displayName
model: root.chatDocumentHandler.completionModel
currentIndex: 0
keyNavigationWraps: true
highlightMoveDuration: 100
onCountChanged: currentIndex = 0
delegate: Delegates.RoundedItemDelegate {
id: completionDelegate
contentItem: RowLayout {
KirigamiComponents.Avatar {
visible: completionDelegate.iconName !== "invalid"
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
source: completionDelegate.iconName === "invalid" ? "" : completionDelegate.iconName
name: completionDelegate.text
}
Delegates.SubtitleContentItem {
itemDelegate: completionDelegate
labelItem.textFormat: Text.PlainText
labelItem.clip: true // Intentional to limit insane Unicode in display names
subtitle: completionDelegate.subtitle ?? ""
subtitleItem.textFormat: Text.PlainText
required property int index
required property string displayName
required property string subtitle
required property string iconName
text: displayName
contentItem: RowLayout {
KirigamiComponents.Avatar {
visible: completionDelegate.iconName !== "invalid"
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
source: completionDelegate.iconName === "invalid" ? "" : completionDelegate.iconName
name: completionDelegate.text
}
Delegates.SubtitleContentItem {
itemDelegate: completionDelegate
labelItem.textFormat: Text.PlainText
labelItem.clip: true // Intentional to limit insane Unicode in display names
subtitle: completionDelegate.subtitle ?? ""
subtitleItem.textFormat: Text.PlainText
}
}
onClicked: root.chatDocumentHandler.complete(completionDelegate.index)
}
onClicked: root.model.insertCompletion(replacedText, hRef)
}
}
}
background: Kirigami.ShadowedRectangle {
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.View
radius: Kirigami.Units.cornerRadius
background: Rectangle {
color: Kirigami.Theme.backgroundColor
border {
width: 1
color: Kirigami.ColorUtils.linearInterpolation(Kirigami.Theme.backgroundColor, Kirigami.Theme.textColor, Kirigami.Theme.frameContrast)
}
}
}

View File

@@ -1,33 +0,0 @@
// SPDX-FileCopyrightText: 2024 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
FormCard.FormCardDialog {
id: root
property alias linkText: linkTextField.text
property alias linkUrl: linkUrlField.text
title: i18nc("@title:window", "Insert Link")
standardButtons: QQC2.Dialog.Ok | QQC2.Dialog.Cancel
FormCard.FormTextFieldDelegate {
id: linkTextField
label: i18nc("@label:textbox", "Link Text:")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: linkUrlField
label: i18nc("@label:textbox", "Link URL:")
}
}

View File

@@ -1,420 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtCore
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat.libneochat as LibNeoChat
import org.kde.neochat.messagecontent as MessageContent
pragma ComponentBehavior: Bound
RowLayout {
id: root
/**
* @brief The current room that user is viewing.
*/
required property LibNeoChat.NeoChatRoom room
required property MessageContent.ChatBarMessageContentModel contentModel
required property real maxAvailableWidth
readonly property real uncompressedImplicitWidth: boldButton.implicitWidth +
italicButton.implicitWidth +
extraTextFormatRow.implicitWidth +
listRow.implicitWidth +
styleButton.implicitWidth +
emojiButton.implicitWidth +
linkButton.implicitWidth +
root.spacing * 7 +
Kirigami.Units.gridUnit
readonly property real listCompressedImplicitWidth: boldButton.implicitWidth +
italicButton.implicitWidth +
extraTextFormatRow.implicitWidth +
compressedListButton.implicitWidth +
styleButton.uncompressedWidth +
emojiButton.implicitWidth +
linkButton.implicitWidth +
root.spacing * 7 +
Kirigami.Units.gridUnit
readonly property real extraTextCompressedImplicitWidth: boldButton.implicitWidth +
italicButton.implicitWidth +
compressedExtraTextFormatButton.implicitWidth +
compressedListButton.implicitWidth +
styleButton.uncompressedWidth +
emojiButton.implicitWidth +
linkButton.implicitWidth +
root.spacing * 7 +
Kirigami.Units.gridUnit
readonly property ChatButtonHelper chatButtonHelper: ChatButtonHelper {
textItem: root.contentModel.focusedTextItem
inQuote: root.contentModel.focusType == LibNeoChat.MessageComponentType.Quote
hasAttachment: root.contentModel.hasAttachment
}
signal clicked
QQC2.ToolButton {
id: boldButton
Shortcut {
sequence: "Ctrl+B"
onActivated: boldButton.clicked()
}
icon.name: "format-text-bold"
enabled: root.chatButtonHelper.richFormatEnabled
text: i18nc("@action:button", "Bold")
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: root.chatButtonHelper.bold
onClicked: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.Bold);
root.clicked()
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
id: italicButton
Shortcut {
sequence: "Ctrl+I"
onActivated: italicButton.clicked()
}
icon.name: "format-text-italic"
enabled: root.chatButtonHelper.richFormatEnabled
text: i18nc("@action:button", "Italic")
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: root.chatButtonHelper.italic
onClicked: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.Italic);
root.clicked()
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
RowLayout {
id: extraTextFormatRow
visible: root.maxAvailableWidth > root.listCompressedImplicitWidth
QQC2.ToolButton {
id: underlineButton
Shortcut {
sequence: "Ctrl+U"
onActivated: underlineButton.clicked()
}
icon.name: "format-text-underline"
enabled: root.chatButtonHelper.richFormatEnabled
text: i18nc("@action:button", "Underline")
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: root.chatButtonHelper.underline
onClicked: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.Underline);
root.clicked();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
icon.name: "format-text-strikethrough"
enabled: root.chatButtonHelper.richFormatEnabled
text: i18nc("@action:button", "Strikethrough")
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: root.chatButtonHelper.strikethrough
onClicked: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.Strikethrough);
root.clicked()
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
QQC2.ToolButton {
id: compressedExtraTextFormatButton
visible: root.maxAvailableWidth < root.listCompressedImplicitWidth
icon.name: "dialog-text-and-font"
enabled: root.chatButtonHelper.richFormatEnabled
text: i18nc("@action:button", "Format Text")
display: QQC2.AbstractButton.IconOnly
checkable: true
onClicked: {
let dialog = compressedTextFormatMenu.createObject(compressedExtraTextFormatButton) as QQC2.Menu
dialog.onClosed.connect(() => {
compressedExtraTextFormatButton.checked = false;
});
dialog.open();
compressedExtraTextFormatButton.checked = true;
}
Component {
id: compressedTextFormatMenu
QQC2.Menu {
y: -implicitHeight
QQC2.MenuItem {
icon.name: "format-text-underline"
text: i18nc("@action:button", "Underline")
checkable: true
checked: root.chatButtonHelper.underline
onTriggered: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.Underline);
root.clicked();
}
}
QQC2.MenuItem {
icon.name: "format-text-strikethrough"
text: i18nc("@action:button", "Strikethrough")
checkable: true
checked: root.chatButtonHelper.strikethrough
onTriggered: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.Strikethrough);
root.clicked();
}
}
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
StyleButton {
id: styleButton
Layout.minimumWidth: compressed ? -1 : Kirigami.Units.gridUnit * 10 + Kirigami.Units.largeSpacing * 2
icon.name: "typewriter"
text: i18nc("@action:button", "Text Style")
style: root.chatButtonHelper.currentStyle
inQuote: root.contentModel.focusType == LibNeoChat.MessageComponentType.Quote
compressed: root.maxAvailableWidth < root.extraTextCompressedImplicitWidth
enabled: root.chatButtonHelper.styleFormatEnabled
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: styleMenu.visible
onClicked: {
if (styleMenu.visible) {
styleMenu.close();
return;
}
open = true;
styleMenu.open();
}
StylePicker {
id: styleMenu
width: styleButton.compressed ? implicitWidth : styleButton.width
chatContentModel: root.contentModel
chatButtonHelper: root.chatButtonHelper
inQuote: root.contentModel.focusType == LibNeoChat.MessageComponentType.Quote
onClosed: {
root.clicked()
styleButton.open = false;
}
}
}
QQC2.ToolButton {
id: emojiButton
visible: !Kirigami.Settings.isMobile
icon.name: "smiley"
text: i18n("Emojis & Stickers")
display: QQC2.AbstractButton.IconOnly
checkable: true
onClicked: {
let dialog = (emojiDialog.createObject(root) as EmojiDialog).open();
}
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: text
}
QQC2.ToolButton {
id: linkButton
enabled: root.chatButtonHelper.richFormatEnabled
icon.name: "insert-link-symbolic"
text: root.chatButtonHelper.currentLinkUrl.length > 0 ? i18nc("@action:button", "Edit link") : i18nc("@action:button", "Insert link")
display: QQC2.AbstractButton.IconOnly
onClicked: {
let dialog = linkDialog.createObject(QQC2.Overlay.overlay, {
linkText: root.chatButtonHelper.currentLinkText,
linkUrl: root.chatButtonHelper.currentLinkUrl
})
dialog.onAccepted.connect(() => {
root.chatButtonHelper.updateLink(dialog.linkUrl, dialog.linkText)
root.clicked();
});
dialog.open();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
RowLayout {
id: listRow
visible: root.maxAvailableWidth > root.uncompressedImplicitWidth
QQC2.ToolButton {
icon.name: "format-list-unordered"
enabled: root.chatButtonHelper.richFormatEnabled
text: i18nc("@action:button", "Unordered List")
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: root.chatButtonHelper.unorderedList
onClicked: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.UnorderedList);
root.clicked();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
icon.name: "format-list-ordered"
enabled: root.chatButtonHelper.richFormatEnabled
text: i18nc("@action:button", "Ordered List")
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: root.chatButtonHelper.orderedList
onClicked: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.OrderedList);
root.clicked();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
id: indentAction
icon.name: "format-indent-more"
enabled: root.chatButtonHelper.richFormatEnabled && root.chatButtonHelper.canIndentListMore
text: i18nc("@action:button", "Increase List Level")
display: QQC2.AbstractButton.IconOnly
onClicked: {
root.chatButtonHelper.indentListMore();
root.clicked();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
id: dedentAction
icon.name: "format-indent-less"
enabled: root.chatButtonHelper.richFormatEnabled && root.chatButtonHelper.canIndentListLess
text: i18nc("@action:button", "Decrease List Level")
display: QQC2.AbstractButton.IconOnly
onClicked: {
root.chatButtonHelper.indentListLess();
root.clicked();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
QQC2.ToolButton {
id: compressedListButton
enabled: root.chatButtonHelper.richFormatEnabled
visible: root.maxAvailableWidth < root.uncompressedImplicitWidth
icon.name: "format-list-unordered"
text: i18nc("@action:button", "List Style")
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: compressedListMenu.visible
onClicked: {
compressedListMenu.open()
}
QQC2.Menu {
id: compressedListMenu
y: -implicitHeight
QQC2.MenuItem {
icon.name: "format-list-unordered"
text: i18nc("@action:button", "Unordered List")
onTriggered: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.UnorderedList);
root.clicked();
}
}
QQC2.MenuItem {
icon.name: "format-list-ordered"
text: i18nc("@action:button", "Ordered List")
onTriggered: {
root.chatButtonHelper.setFormat(LibNeoChat.RichFormat.OrderedList);
root.clicked();
}
}
QQC2.MenuItem {
icon.name: "format-indent-more"
text: i18nc("@action:button", "Increase List Level")
enabled: root.chatButtonHelper.canIndentListMore
onTriggered: {
root.chatButtonHelper.indentListMore();
root.clicked();
}
}
QQC2.MenuItem {
icon.name: "format-indent-less"
text: i18nc("@action:button", "Decrease List Level")
enabled: root.chatButtonHelper.canIndentListLess
onTriggered: {
root.chatButtonHelper.indentListLess();
root.clicked();
}
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
Component {
id: linkDialog
LinkDialog {}
}
Component {
id: emojiDialog
EmojiDialog {
x: root.width - width
y: -implicitHeight
modal: false
includeCustom: true
closeOnChosen: false
currentRoom: root.room
onChosen: emoji => {
root.chatButtonHelper.insertText(emoji);
close();
}
onClosed: if (emojiButton.checked) {
emojiButton.checked = false;
}
}
}
}

View File

@@ -1,235 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtCore
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat.libneochat as LibNeoChat
import org.kde.neochat.messagecontent as MessageContent
pragma ComponentBehavior: Bound
RowLayout {
id: root
/**
* @brief The current room that user is viewing.
*/
required property LibNeoChat.NeoChatRoom room
property LibNeoChat.ChatBarCache chatBarCache
required property MessageContent.ChatBarMessageContentModel contentModel
required property real maxAvailableWidth
readonly property real overflowWidth: Kirigami.Units.gridUnit * 30
function openLocationChooser(): void {
Qt.createComponent('org.kde.neochat.chatbar', 'LocationChooser').createObject(QQC2.ApplicationWindow.overlay, {
room: root.room
}).open();
}
function openNewPollDialog(): void {
Qt.createComponent('org.kde.neochat.chatbar', 'NewPollDialog').createObject(QQC2.Overlay.overlay, {
room: root.room
}).open();
}
function addAttachment(): void {
if (!root.contentModel.hasRichFormatting) {
if (LibNeoChat.Clipboard.hasImage) {
attachDialog();
} else {
fileDialog();
}
return;
}
let warningDialog = Qt.createComponent('org.kde.kirigami', 'PromptDialog').createObject(QQC2.Overlay.overlay, {
dialogType: Kirigami.PromptDialog.Warning,
title: attachmentButton.text,
subtitle: i18nc("@Warning: that any rich text in the chat bar will be switched for the plain text equivalent.", "Attachments can only have plain text captions, all rich formatting will be removed"),
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
});
warningDialog.onAccepted.connect(() => {
if (LibNeoChat.Clipboard.hasImage) {
attachmentButton.attachDialog();
} else {
attachmentButton.fileDialog();
}
});
warningDialog.open();
}
function attachDialog(): void {
let dialog = Qt.createComponent('org.kde.neochat.chatbar', 'AttachDialog').createObject(QQC2.Overlay.overlay) as AttachDialog;
dialog.anchors.centerIn = QQC2.Overlay.overlay;
dialog.chosen.connect(path => root.contentModel.addAttachment(path));
dialog.open();
}
function fileDialog(): void {
let dialog = Qt.createComponent('org.kde.neochat.libneochat', 'OpenFileDialog').createObject(QQC2.Overlay.overlay, {
parentWindow: Window.window,
currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
});
dialog.chosen.connect(path => root.contentModel.addAttachment(path));
dialog.open();
}
function openVoiceDialog(): void {
let dialog = Qt.createComponent('org.kde.neochat.chatbar', 'VoiceMessageDialog').createObject(root, {
room: root.currentRoom
}) as VoiceMessageDialog;
dialog.open();
}
Kirigami.Separator {
Layout.fillHeight: true
}
QQC2.ToolButton {
id: compressedExtraSendButton
property QQC2.Menu overflowMenu
visible: root.maxAvailableWidth < root.overflowWidth && (root.contentModel?.type ?? true) === LibNeoChat.ChatBarType.Room
icon.name: "list-add-symbolic"
text: i18nc("@action:button", "Add to message")
display: QQC2.AbstractButton.IconOnly
checkable: true
checked: overflowMenu !== null
Accessible.role: Accessible.ButtonMenu
onClicked: {
if (!checked) {
if (overflowMenu) {
overflowMenu.close();
}
return;
}
overflowMenu = compressedExtraSendMenu.createObject(compressedExtraSendButton)
overflowMenu.onClosed.connect(() => {
overflowMenu = null;
});
overflowMenu.open();
}
Component {
id: compressedExtraSendMenu
QQC2.Menu {
y: -implicitHeight
QQC2.MenuItem {
visible: !root.contentModel.hasAttachment
icon.name: "mail-attachment"
text: i18nc("@action:button", "Attach an image or file")
onTriggered: root.addAttachment()
}
QQC2.MenuItem {
icon.name: "globe"
text: i18nc("@action:button", "Send a Location")
onTriggered: root.openLocationChooser()
}
QQC2.MenuItem {
icon.name: "amarok_playcount"
text: i18nc("@action:button", "Create a Poll")
onTriggered: root.openNewPollDialog();
}
QQC2.MenuItem {
icon.name: "microphone"
text: i18nc("@action:button", "Send a Voice Message")
onTriggered: root.openVoiceDialog();
}
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
id: attachmentButton
visible: !root.contentModel.hasAttachment &&
((root.contentModel?.type ?? true) === LibNeoChat.ChatBarType.Room || (root.contentModel?.type ?? true) === LibNeoChat.ChatBarType.Thread) &&
root.maxAvailableWidth >= root.overflowWidth
icon.name: "mail-attachment"
text: i18nc("@action:button", "Attach an image or file")
display: QQC2.AbstractButton.IconOnly
onClicked: root.addAttachment()
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: text
}
QQC2.ToolButton {
id: mapButton
visible: (root.contentModel?.type ?? true) === LibNeoChat.ChatBarType.Room && root.maxAvailableWidth >= root.overflowWidth
icon.name: "globe"
text: i18nc("@action:button", "Send a Location")
display: QQC2.AbstractButton.IconOnly
onClicked: root.openLocationChooser();
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: text
}
QQC2.ToolButton {
id: pollButton
visible: (root.contentModel?.type ?? true) === LibNeoChat.ChatBarType.Room && root.maxAvailableWidth >= root.overflowWidth
icon.name: "amarok_playcount"
text: i18nc("@action:button", "Create a Poll")
display: QQC2.AbstractButton.IconOnly
onClicked: root.openNewPollDialog();
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: text
}
QQC2.ToolButton {
visible: (root.contentModel?.type ?? true) === LibNeoChat.ChatBarType.Room && root.maxAvailableWidth >= root.overflowWidth
icon.name: "microphone"
text: i18nc("@action:button", "Send a Voice Message")
display: QQC2.AbstractButton.IconOnly
onClicked: root.openVoiceDialog();
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: text
}
QQC2.ToolButton {
id: sendButton
icon.name: "document-send"
text: i18nc("@action:button", "Send message")
display: QQC2.AbstractButton.IconOnly
enabled: root.contentModel.hasAnyContent
onClicked: root.contentModel.postMessage();
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: text
}
QQC2.ToolButton {
id: cancelButton
visible: (root.contentModel?.type ?? true) === LibNeoChat.ChatBarType.Edit
display: QQC2.AbstractButton.IconOnly
text: i18nc("@action:button", "Cancel")
icon.name: "dialog-close"
onClicked: root.room.cacheForType(contentModel.type).clearRelations()
Kirigami.Action {
shortcut: "Escape"
onTriggered: cancelButton.clicked()
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}

View File

@@ -1,79 +0,0 @@
// SPDX-FileCopyrightText: 2026 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat.libneochat as LibNeoChat
QQC2.AbstractButton {
id: root
required property int style
required property bool inQuote
property bool open: false
property bool compressed: false
readonly property real uncompressedWidth: styleDelegate.implicitWidth + arrowIcon.implicitWidth + 1 + contentRow.spacing * 2 + padding * 2
padding: Kirigami.Units.smallSpacing
icon {
width: Kirigami.Units.iconSizes.smallMedium
height: Kirigami.Units.iconSizes.smallMedium
}
contentItem: RowLayout {
id: contentRow
StyleDelegate {
id: styleDelegate
Layout.fillWidth: true
visible: !root.compressed
style: root.style
inQuote: root.inQuote
sizeText: false
onPressed: root.clicked()
}
Kirigami.Icon {
id: styleIcon
visible: root.compressed
source: root.icon.name
implicitWidth: root.icon.width
implicitHeight: root.icon.height
}
Kirigami.Separator {
Layout.fillHeight: true
}
Kirigami.Icon {
id: arrowIcon
source: root.open ? "arrow-down" : "arrow-up"
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
radius: Kirigami.Units.cornerRadius
border {
width: root.hovered || root.open ? 1 : 0
color: Kirigami.Theme.highlightColor
}
}
}

View File

@@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: 2026 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat.libneochat as LibNeoChat
QQC2.TextArea {
id: root
required property int style
required property bool inQuote
property bool highlight: false
property bool sizeText: true
leftPadding: lineRow.visible ? lineRow.width + lineRow.anchors.leftMargin + Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
verticalAlignment: Text.AlignVCenter
readOnly: true
selectByMouse: false
RowLayout {
id: lineRow
anchors {
top: root.top
bottom: root.bottom
left: root.left
leftMargin: Kirigami.Units.smallSpacing
}
visible: root.style === LibNeoChat.RichFormat.Code
QQC2.Label {
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
text: "1"
color: Kirigami.Theme.disabledTextColor
font.family: "monospace"
}
Kirigami.Separator {
Layout.fillHeight: true
}
}
StyleDelegateHelper {
textItem: root
inQuote: root.inQuote
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.colorSet: root.style === LibNeoChat.RichFormat.Quote || (root.inQuote && !(root.style == LibNeoChat.RichFormat.Paragraph || root.style == LibNeoChat.RichFormat.Code)) ? Kirigami.Theme.Window : Kirigami.Theme.View
Kirigami.Theme.inherit: false
radius: Kirigami.Units.cornerRadius
border {
width: 1
color: root.highlight ?
Kirigami.Theme.highlightColor :
Kirigami.ColorUtils.linearInterpolation(
Kirigami.Theme.backgroundColor,
Kirigami.Theme.textColor,
Kirigami.Theme.frameContrast
)
}
}
}

View File

@@ -1,73 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat.libneochat as LibNeoChat
import org.kde.neochat.messagecontent as MessageContent
QQC2.Popup {
id: root
required property MessageContent.ChatBarMessageContentModel chatContentModel
required property ChatButtonHelper chatButtonHelper
required property bool inQuote
y: -implicitHeight
padding: Kirigami.Units.largeSpacing
contentItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
Repeater {
model: 9
delegate: StyleDelegate {
id: styleDelegate
required property int index
Layout.fillWidth: true
Layout.minimumWidth: Kirigami.Units.gridUnit * 8
Layout.minimumHeight: Kirigami.Units.gridUnit * 2
enabled: chatButtonHelper.richFormatEnabled ||
index == LibNeoChat.RichFormat.Paragraph ||
index == LibNeoChat.RichFormat.Code ||
index == LibNeoChat.RichFormat.Quote
style: index
inQuote: root.inQuote
highlight: root.chatButtonHelper.currentStyle === index || hovered
onPressed: (event) => {
if (index === LibNeoChat.RichFormat.Paragraph ||
index === LibNeoChat.RichFormat.Code ||
index === LibNeoChat.RichFormat.Quote
) {
root.chatContentModel.insertStyleAtCursor(styleDelegate.index);
} else {
root.chatButtonHelper.setFormat(styleDelegate.index);
}
root.close();
}
}
}
}
background: Kirigami.ShadowedRectangle {
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.View
radius: Kirigami.Units.cornerRadius
color: Kirigami.Theme.backgroundColor
border {
width: 1
color: Kirigami.ColorUtils.linearInterpolation(Kirigami.Theme.backgroundColor, Kirigami.Theme.textColor, Kirigami.Theme.frameContrast)
}
}
}

View File

@@ -1,32 +0,0 @@
// SPDX-FileCopyrightText: 2024 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtCore
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQuick.Dialogs
FormCard.FormCardDialog {
id: root
readonly property alias rows: rowsSpinBox.value
readonly property alias cols: colsSpinBox.value
title: i18nc("@title:window", "Insert Table")
standardButtons: QQC2.Dialog.Ok | QQC2.Dialog.Cancel
FormCard.FormSpinBoxDelegate {
id: rowsSpinBox
label: i18nc("@label:textbox", "Number of Rows:")
}
FormCard.FormDelegateSeparator {}
FormCard.FormSpinBoxDelegate {
id: colsSpinBox
label: i18nc("@label:textbox", "Number of Columns:")
}
}

View File

@@ -60,11 +60,9 @@ QQC2.Dialog {
QQC2.Button {
text: i18nc("@action:button Send the voice message", "Send")
icon.name: "document-send"
enabled: !voiceRecorder.recording && voiceRecorder.recorder.duration > 0 && voiceRecorder.isSupported
onClicked: voiceRecorder.send()
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
onClicked: voiceRecorder.send()
enabled: !voiceRecorder.recording && voiceRecorder.recorder.duration > 0 && voiceRecorder.isSupported
}
}
}

View File

@@ -1,355 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "chatbuttonhelper.h"
#include <Kirigami/Platform/PlatformTheme>
#include "chattextitemhelper.h"
#include "enums/richformat.h"
ChatButtonHelper::ChatButtonHelper(QObject *parent)
: QObject(parent)
{
}
ChatTextItemHelper *ChatButtonHelper::textItem() const
{
return m_textItem;
}
void ChatButtonHelper::setTextItem(ChatTextItemHelper *textItem)
{
if (textItem == m_textItem) {
return;
}
if (m_textItem) {
m_textItem->disconnect(this);
}
m_textItem = textItem;
if (m_textItem) {
connect(m_textItem, &ChatTextItemHelper::textFormatChanged, this, &ChatButtonHelper::richFormatEnabledChanged);
connect(m_textItem, &ChatTextItemHelper::textFormatChanged, this, &ChatButtonHelper::styleChanged);
connect(m_textItem, &ChatTextItemHelper::charFormatChanged, this, &ChatButtonHelper::charFormatChanged);
connect(m_textItem, &ChatTextItemHelper::styleChanged, this, &ChatButtonHelper::styleChanged);
connect(m_textItem, &ChatTextItemHelper::listChanged, this, &ChatButtonHelper::listChanged);
}
Q_EMIT textItemChanged();
Q_EMIT richFormatEnabledChanged();
Q_EMIT styleChanged();
}
bool ChatButtonHelper::inQuote() const
{
return m_inQuote;
}
void ChatButtonHelper::setInQuote(bool inQuote)
{
if (inQuote == m_inQuote) {
return;
}
m_inQuote = inQuote;
Q_EMIT inQuoteChanged();
Q_EMIT richFormatEnabledChanged();
Q_EMIT styleChanged();
}
bool ChatButtonHelper::hasAttachment() const
{
return m_hasAttachment;
}
void ChatButtonHelper::setHasAttachment(bool hasAttachment)
{
if (hasAttachment == m_hasAttachment) {
return;
}
m_hasAttachment = hasAttachment;
Q_EMIT hasAttachmentChanged();
Q_EMIT richFormatEnabledChanged();
}
bool ChatButtonHelper::richFormatEnabled() const
{
if (!m_textItem) {
return false;
}
const auto styleAvailable = styleFormatEnabled();
if (!styleAvailable) {
return false;
}
const auto format = m_textItem->textFormat();
if (format) {
return format != Qt::PlainText;
}
return false;
}
bool ChatButtonHelper::styleFormatEnabled() const
{
if (!m_textItem || m_hasAttachment) {
return false;
}
return true;
}
bool ChatButtonHelper::bold() const
{
if (!m_textItem) {
return false;
}
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::Bold);
}
bool ChatButtonHelper::italic() const
{
if (!m_textItem) {
return false;
}
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::Italic);
}
bool ChatButtonHelper::underline() const
{
if (!m_textItem) {
return false;
}
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::Underline);
}
bool ChatButtonHelper::strikethrough() const
{
if (!m_textItem) {
return false;
}
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::Strikethrough);
}
bool ChatButtonHelper::unorderedList() const
{
if (!m_textItem) {
return false;
}
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::UnorderedList);
}
bool ChatButtonHelper::orderedList() const
{
if (!m_textItem) {
return false;
}
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::OrderedList);
}
RichFormat::Format ChatButtonHelper::currentStyle() const
{
if (!m_textItem) {
return RichFormat::Paragraph;
}
if (!richFormatEnabled()) {
return RichFormat::Code;
}
const auto currentHeadingLevel = m_textItem->textCursor().blockFormat().headingLevel();
if (currentHeadingLevel == 0 && m_inQuote) {
return RichFormat::Quote;
}
return static_cast<RichFormat::Format>(currentHeadingLevel);
}
void ChatButtonHelper::setFormat(RichFormat::Format format)
{
if (!m_textItem) {
return;
}
m_textItem->mergeFormatOnCursor(format);
}
bool ChatButtonHelper::canIndentListMore() const
{
if (!m_textItem) {
return false;
}
return m_textItem->canIndentListMoreAtCursor();
}
bool ChatButtonHelper::canIndentListLess() const
{
if (!m_textItem) {
return false;
}
return m_textItem->canIndentListLessAtCursor();
}
void ChatButtonHelper::indentListMore()
{
if (!m_textItem) {
return;
}
m_textItem->indentListMoreAtCursor();
}
void ChatButtonHelper::indentListLess()
{
if (!m_textItem) {
return;
}
m_textItem->indentListLessAtCursor();
}
void ChatButtonHelper::insertText(const QString &text)
{
if (!m_textItem) {
return;
}
m_textItem->textCursor().insertText(text);
}
QString ChatButtonHelper::currentLinkUrl() const
{
if (!m_textItem) {
return {};
}
return m_textItem->textCursor().charFormat().anchorHref();
}
void ChatButtonHelper::selectLinkText(QTextCursor &cursor) const
{
// If the cursor is on a link, select the text of the link.
if (cursor.charFormat().isAnchor()) {
const QString aHref = cursor.charFormat().anchorHref();
// Move cursor to start of link
while (cursor.charFormat().anchorHref() == aHref) {
if (cursor.atStart()) {
break;
}
cursor.setPosition(cursor.position() - 1);
}
if (cursor.charFormat().anchorHref() != aHref) {
cursor.setPosition(cursor.position() + 1, QTextCursor::KeepAnchor);
}
// Move selection to the end of the link
while (cursor.charFormat().anchorHref() == aHref) {
if (cursor.atEnd()) {
break;
}
const int oldPosition = cursor.position();
cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor);
// Wordaround Qt Bug. when we have a table.
// FIXME selection url
if (oldPosition == cursor.position()) {
break;
}
}
if (cursor.charFormat().anchorHref() != aHref) {
cursor.setPosition(cursor.position() - 1, QTextCursor::KeepAnchor);
}
} else if (cursor.hasSelection()) {
// Nothing to do. Using the currently selected text as the link text.
} else {
// Select current word
cursor.movePosition(QTextCursor::StartOfWord);
cursor.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor);
}
}
QString ChatButtonHelper::currentLinkText() const
{
if (!m_textItem) {
return {};
}
QTextCursor cursor = m_textItem->textCursor();
selectLinkText(cursor);
return cursor.selectedText();
}
void ChatButtonHelper::updateLink(const QString &linkUrl, const QString &linkText)
{
if (!m_textItem) {
return;
}
auto cursor = m_textItem->textCursor();
selectLinkText(cursor);
cursor.beginEditBlock();
if (!cursor.hasSelection()) {
cursor.select(QTextCursor::WordUnderCursor);
}
const auto originalFormat = cursor.charFormat();
auto format = cursor.charFormat();
// Save original format to create an extra space with the existing char
// format for the block
if (!linkUrl.isEmpty()) {
// Add link details
format.setAnchor(true);
format.setAnchorHref(linkUrl);
// Workaround for QTBUG-1814:
// Link formatting does not get applied immediately when setAnchor(true)
// is called. So the formatting needs to be applied manually.
format.setUnderlineStyle(QTextCharFormat::SingleUnderline);
const auto theme = static_cast<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
format.setUnderlineColor(theme->linkColor());
format.setForeground(theme->linkColor());
} else {
// Remove link details
format.setAnchor(false);
format.setAnchorHref(QString());
// Workaround for QTBUG-1814:
// Link formatting does not get removed immediately when setAnchor(false)
// is called. So the formatting needs to be applied manually.
QTextDocument defaultTextDocument;
QTextCharFormat defaultCharFormat = defaultTextDocument.begin().charFormat();
format.setUnderlineStyle(defaultCharFormat.underlineStyle());
format.setUnderlineColor(defaultCharFormat.underlineColor());
format.setForeground(defaultCharFormat.foreground());
}
// Insert link text specified in dialog, otherwise write out url.
QString _linkText;
if (!linkText.isEmpty()) {
_linkText = linkText;
} else {
_linkText = linkUrl;
}
cursor.insertText(_linkText, format);
if (cursor.atBlockEnd()) {
cursor.insertText(u" "_s, originalFormat);
}
cursor.endEditBlock();
}
#include "moc_chatbuttonhelper.cpp"

View File

@@ -1,172 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
#include "chattextitemhelper.h"
class ChatButtonHelper : public QObject
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The text item that the helper is interfacing with.
*
* This is a QQuickItem that is a TextEdit (or inherited from) wrapped in a ChatTextItemHelper
* to provide easy access to properties and basic QTextDocument manipulation.
*
* @sa TextEdit, QTextDocument, ChatTextItemHelper
*/
Q_PROPERTY(ChatTextItemHelper *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged)
/**
* @brief Whether the current block is a Quote block.
*/
Q_PROPERTY(bool inQuote READ inQuote WRITE setInQuote NOTIFY inQuoteChanged)
/**
* @brief Whether the model has an attachment..
*/
Q_PROPERTY(bool hasAttachment READ hasAttachment WRITE setHasAttachment NOTIFY hasAttachmentChanged)
/**
* @brief Whether rich formating is enabled at the current cursor location.
*/
Q_PROPERTY(bool richFormatEnabled READ richFormatEnabled NOTIFY richFormatEnabledChanged)
/**
* @brief Whether the text format at the current cursor is bold.
*/
Q_PROPERTY(bool styleFormatEnabled READ styleFormatEnabled NOTIFY richFormatEnabledChanged)
/**
* @brief Whether the text format at the current cursor is bold.
*/
Q_PROPERTY(bool bold READ bold NOTIFY charFormatChanged)
/**
* @brief Whether the text format at the current cursor is italic.
*/
Q_PROPERTY(bool italic READ italic NOTIFY charFormatChanged)
/**
* @brief Whether the text format at the current cursor is underlined.
*/
Q_PROPERTY(bool underline READ underline NOTIFY charFormatChanged)
/**
* @brief Whether the text format at the current cursor is struckthrough.
*/
Q_PROPERTY(bool strikethrough READ strikethrough NOTIFY charFormatChanged)
/**
* @brief Whether the format at the current cursor includes RichFormat::UnorderedList.
*/
Q_PROPERTY(bool unorderedList READ unorderedList NOTIFY listChanged)
/**
* @brief Whether the format at the current cursor includes RichFormat::OrderedList.
*/
Q_PROPERTY(bool orderedList READ orderedList NOTIFY listChanged)
/**
* @brief The current style at the cursor.
*/
Q_PROPERTY(RichFormat::Format currentStyle READ currentStyle NOTIFY styleChanged)
/**
* @brief Whether the list at the current cursor can be indented one level more.
*/
Q_PROPERTY(bool canIndentListMore READ canIndentListMore NOTIFY listChanged)
/**
* @brief Whether the list at the current cursor can be indented one level less.
*/
Q_PROPERTY(bool canIndentListLess READ canIndentListLess NOTIFY listChanged)
/**
* @brief The link url at the current cursor position.
*/
Q_PROPERTY(QString currentLinkUrl READ currentLinkUrl NOTIFY charFormatChanged)
/**
* @brief The link url at the current cursor position.
*/
Q_PROPERTY(QString currentLinkText READ currentLinkText NOTIFY charFormatChanged)
public:
explicit ChatButtonHelper(QObject *parent = nullptr);
ChatTextItemHelper *textItem() const;
void setTextItem(ChatTextItemHelper *textItem);
bool inQuote() const;
void setInQuote(bool inQuote);
bool hasAttachment() const;
void setHasAttachment(bool hasAttachment);
bool richFormatEnabled() const;
bool styleFormatEnabled() const;
bool bold() const;
bool italic() const;
bool underline() const;
bool strikethrough() const;
bool unorderedList() const;
bool orderedList() const;
RichFormat::Format currentStyle() const;
/**
* @brief Apply the given format at the current cursor position.
*/
Q_INVOKABLE void setFormat(RichFormat::Format format);
bool canIndentListMore() const;
bool canIndentListLess() const;
/**
* @brief Indent the list at the current cursor one level more.
*/
Q_INVOKABLE void indentListMore();
/**
* @brief Indent the list at the current cursor one level less.
*/
Q_INVOKABLE void indentListLess();
/**
* @brief Insert text at the current cursor position.
*/
Q_INVOKABLE void insertText(const QString &text);
QString currentLinkUrl() const;
QString currentLinkText() const;
/**
* @brief Update the link at the current cursor position.
*
* This will replace any selected text of the word next to the cursor with the
* given text and will link to the given url.
*/
Q_INVOKABLE void updateLink(const QString &linkUrl, const QString &linkText);
Q_SIGNALS:
void textItemChanged();
void inQuoteChanged();
void hasAttachmentChanged();
void richFormatEnabledChanged();
void charFormatChanged();
void styleChanged();
void listChanged();
private:
QPointer<ChatTextItemHelper> m_textItem;
bool m_inQuote = false;
bool m_hasAttachment = false;
void selectLinkText(QTextCursor &cursor) const;
};

View File

@@ -1,106 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "styledelegatehelper.h"
#include <QQuickTextDocument>
#include <QTextCursor>
#include <QTextDocument>
#include "enums/richformat.h"
StyleDelegateHelper::StyleDelegateHelper(QObject *parent)
: QObject(parent)
{
}
QQuickItem *StyleDelegateHelper::textItem() const
{
return m_textItem;
}
void StyleDelegateHelper::setTextItem(QQuickItem *textItem)
{
if (textItem == m_textItem) {
return;
}
m_textItem = textItem;
if (m_textItem) {
connect(m_textItem, SIGNAL(styleChanged()), this, SLOT(formatDocument()));
connect(m_textItem, SIGNAL(styleChanged()), this, SLOT(formatDocument()));
if (document()) {
formatDocument();
}
}
Q_EMIT textItemChanged();
}
QTextDocument *StyleDelegateHelper::document() const
{
if (!m_textItem) {
return nullptr;
}
const auto quickDocument = qvariant_cast<QQuickTextDocument *>(m_textItem->property("textDocument"));
return quickDocument ? quickDocument->textDocument() : nullptr;
}
bool StyleDelegateHelper::inQuote() const
{
return m_inQuote;
}
void StyleDelegateHelper::setInQuote(bool inQuote)
{
if (inQuote == m_inQuote) {
return;
}
m_inQuote = inQuote;
formatDocument();
Q_EMIT inQuoteChanged();
}
void StyleDelegateHelper::formatDocument()
{
if (!document()) {
return;
}
auto cursor = QTextCursor(document());
if (cursor.isNull()) {
return;
}
cursor.beginEditBlock();
cursor.select(QTextCursor::Document);
cursor.removeSelectedText();
const auto style = static_cast<RichFormat::Format>(m_textItem->property("style").toInt());
const auto string = RichFormat::styleString(style, m_inQuote);
const auto sizeText = static_cast<RichFormat::Format>(m_textItem->property("sizeText").toBool());
const int headingLevel = style <= 6 && sizeText ? style : 0;
// Apparently, 5 is maximum for FontSizeAdjustment; otherwise level=1 and
// level=2 look the same
const int sizeAdjustment = headingLevel > 0 ? 5 - headingLevel : 0;
QTextBlockFormat blkfmt;
blkfmt.setHeadingLevel(headingLevel);
cursor.mergeBlockFormat(blkfmt);
QTextCharFormat chrfmt;
chrfmt.setFontWeight(headingLevel > 0 ? QFont::Bold : QFont::Normal);
chrfmt.setProperty(QTextFormat::FontSizeAdjustment, sizeAdjustment / 2);
if (style == RichFormat::Code) {
chrfmt.setFontFamilies({u"monospace"_s});
} else if (style == RichFormat::Quote) {
chrfmt.setFontItalic(true);
}
cursor.setBlockCharFormat(chrfmt);
cursor.insertText(string);
cursor.endEditBlock();
}
#include "moc_styledelegatehelper.cpp"

View File

@@ -1,48 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QQuickItem>
class QTextDocument;
class StyleDelegateHelper : public QObject
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The QML text Item the StyleDelegateHelper is handling.
*/
Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged)
/**
* @brief Whether the current block is a Quote block.
*/
Q_PROPERTY(bool inQuote READ inQuote WRITE setInQuote NOTIFY inQuoteChanged)
public:
explicit StyleDelegateHelper(QObject *parent = nullptr);
QQuickItem *textItem() const;
void setTextItem(QQuickItem *textItem);
bool inQuote() const;
void setInQuote(bool inQuote);
Q_SIGNALS:
void textItemChanged();
void inQuoteChanged();
private:
QPointer<QQuickItem> m_textItem;
QTextDocument *document() const;
bool m_inQuote = false;
private Q_SLOTS:
void formatDocument();
};

View File

@@ -14,6 +14,13 @@ FormCard.FormCard {
Layout.topMargin: Kirigami.Units.largeSpacing
FormCard.FormCheckDelegate {
id: roomAccountDataVisibleCheck
text: i18nc("@option:check Enable the matrix 'threads' feature", "Threads")
checked: NeoChatConfig.threads
onToggled: NeoChatConfig.threads = checked
}
FormCard.FormCheckDelegate {
text: i18nc("@option:check Enable the matrix feature to add a phone number as a third party ID", "Add phone numbers as 3PIDs")
checked: NeoChatConfig.phone3PId

View File

@@ -9,10 +9,7 @@ target_sources(LibNeoChat PRIVATE
neochatroommember.cpp
accountmanager.cpp
chatbarcache.cpp
chatbarsyntaxhighlighter.cpp
chatkeyhelper.cpp
chatmarkdownhelper.cpp
chattextitemhelper.cpp
chatdocumenthandler.cpp
clipboard.cpp
delegatesizehelper.cpp
emojitones.cpp
@@ -21,8 +18,6 @@ target_sources(LibNeoChat PRIVATE
filetype.cpp
linkpreviewer.cpp
neochatdatetime.cpp
nestedlisthelper_p.h
nestedlisthelper.cpp
roomlastmessageprovider.cpp
spacehierarchycache.cpp
texthandler.cpp
@@ -30,11 +25,10 @@ target_sources(LibNeoChat PRIVATE
utils.cpp
voicerecorder.cpp
enums/chatbartype.h
enums/messagecomponenttype.cpp
enums/messagecomponenttype.h
enums/messagetype.h
enums/powerlevel.cpp
enums/pushrule.h
enums/richformat.cpp
enums/roomsortparameter.cpp
enums/roomsortorder.h
enums/timelinemarkreadcondition.h
@@ -74,7 +68,6 @@ ecm_add_qml_module(LibNeoChat GENERATE_PLUGIN_SOURCE
qml/SearchPage.qml
qml/CreateRoomDialog.qml
qml/CreateSpaceDialog.qml
qml/OpenFileDialog.qml
DEPENDENCIES
io.github.quotient_im.libquotient
)
@@ -93,6 +86,13 @@ ecm_qt_declare_logging_category(LibNeoChat
DEFAULT_SEVERITY Info
)
ecm_qt_declare_logging_category(LibNeoChat
HEADER "chatdocumenthandler_logging.h"
IDENTIFIER "ChatDocumentHandling"
CATEGORY_NAME "org.kde.neochat.chatdocumenthandler"
DEFAULT_SEVERITY Info
)
generate_export_header(LibNeoChat BASE_NAME LibNeoChat)
target_include_directories(LibNeoChat PRIVATE ${CMAKE_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/enums ${CMAKE_CURRENT_SOURCE_DIR}/events ${CMAKE_CURRENT_SOURCE_DIR}/models)
target_link_libraries(LibNeoChat PUBLIC
@@ -100,7 +100,6 @@ target_link_libraries(LibNeoChat PUBLIC
Qt::Multimedia
Qt::Quick
Qt::QuickControls2
KF6::ColorScheme
KF6::ConfigCore
KF6::CoreAddons
KF6::I18n

View File

@@ -30,7 +30,7 @@ struct Mention {
* A class to cache data from a chat bar.
*
* A chat bar can be anything that allows users to compose or edit message, it doesn't
* necessarily have to use the ChatBar component.
* necessarily have to use the ChatBar component, e.g. ChatBarComponent.
*
* This object is intended to allow the current contents of a chat bar to be cached
* between different rooms, i.e. there is an expectation that each NeoChatRoom could
@@ -40,7 +40,7 @@ struct Mention {
* as it's parent. This is necessary for certain functions which need to get
* relevant room information.
*
* @sa ChatBar, NeoChatRoom
* @sa ChatBar, ChatBarComponent, NeoChatRoom
*/
class ChatBarCache : public QObject
{
@@ -205,7 +205,7 @@ Q_SIGNALS:
void relationIdChanged(const QString &oldEventId, const QString &newEventId);
void threadIdChanged(const QString &oldThreadId, const QString &newThreadId);
void attachmentPathChanged();
void mentionAdded(const QString &text, const QString &hRef);
void mentionAdded(const QString &mention);
void relationAuthorIsPresentChanged();
private:

View File

@@ -1,83 +0,0 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carlschwan@kde.org>
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "chatbarsyntaxhighlighter.h"
#include "chatbarcache.h"
#include "chattextitemhelper.h"
#include "enums/chatbartype.h"
ChatBarSyntaxHighlighter::ChatBarSyntaxHighlighter(QObject *parent)
: QSyntaxHighlighter(parent)
{
m_theme = static_cast<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
connect(m_theme, &Kirigami::Platform::PlatformTheme::colorsChanged, this, [this]() {
m_mentionFormat.setForeground(m_theme->linkColor());
m_errorFormat.setForeground(m_theme->negativeTextColor());
});
m_mentionFormat.setFontWeight(QFont::Bold);
m_mentionFormat.setForeground(m_theme->linkColor());
m_errorFormat.setForeground(m_theme->negativeTextColor());
m_errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
connect(m_checker, &Sonnet::BackgroundChecker::misspelling, this, [this](const QString &word, int start) {
m_errors += {start, word};
m_checker->continueChecking();
});
connect(m_checker, &Sonnet::BackgroundChecker::done, this, [this]() {
m_rehighlightTimer.start();
});
m_rehighlightTimer.setInterval(100);
m_rehighlightTimer.setSingleShot(true);
m_rehighlightTimer.callOnTimeout(this, &QSyntaxHighlighter::rehighlight);
}
void ChatBarSyntaxHighlighter::highlightBlock(const QString &text)
{
if (m_settings.checkerEnabledByDefault()) {
if (text != m_previousText) {
m_previousText = text;
m_checker->stop();
m_errors.clear();
m_checker->setText(text);
}
for (const auto &error : m_errors) {
setFormat(error.first, error.second.size(), m_errorFormat);
}
}
if (!room || type == ChatBarType::None) {
return;
}
auto mentions = room->cacheForType(type)->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(), m_mentionFormat);
}
return false;
}),
mentions->end());
}

View File

@@ -1,45 +0,0 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carlschwan@kde.org>
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QQuickTextDocument>
#include <QSyntaxHighlighter>
#include <QTimer>
#include <Kirigami/Platform/PlatformTheme>
#include <Sonnet/BackgroundChecker>
#include <Sonnet/Settings>
#include "chattextitemhelper.h"
#include "neochatroom.h"
class ChatBarSyntaxHighlighter : public QSyntaxHighlighter
{
Q_OBJECT
public:
explicit ChatBarSyntaxHighlighter(QObject *parent = nullptr);
QPointer<NeoChatRoom> room;
ChatBarType::Type type = ChatBarType::None;
ChatTextItemHelper *textItem() const;
void setTextItem(ChatTextItemHelper *textItem);
void highlightBlock(const QString &text) override;
private:
Kirigami::Platform::PlatformTheme *m_theme = nullptr;
QTextCharFormat m_mentionFormat;
QTextCharFormat m_errorFormat;
Sonnet::BackgroundChecker *m_checker = new Sonnet::BackgroundChecker(this);
Sonnet::Settings m_settings;
QString m_previousText;
QList<QPair<int, QString>> m_errors;
QTimer m_rehighlightTimer;
};

View File

@@ -0,0 +1,382 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carlschwan@kde.org>
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "chatdocumenthandler.h"
#include <QQmlFile>
#include <QQmlFileSelector>
#include <QQuickTextDocument>
#include <QStringBuilder>
#include <QSyntaxHighlighter>
#include <QTextBlock>
#include <QTextDocument>
#include <QTimer>
#include <Kirigami/Platform/PlatformTheme>
#include <Sonnet/BackgroundChecker>
#include <Sonnet/Settings>
#include "chatbartype.h"
#include "chatdocumenthandler_logging.h"
#include "eventhandler.h"
using namespace Qt::StringLiterals;
class SyntaxHighlighter : public QSyntaxHighlighter
{
public:
QTextCharFormat mentionFormat;
QTextCharFormat errorFormat;
Sonnet::BackgroundChecker checker;
Sonnet::Settings settings;
QList<QPair<int, QString>> errors;
QString previousText;
QTimer rehighlightTimer;
SyntaxHighlighter(QObject *parent)
: QSyntaxHighlighter(parent)
{
m_theme = static_cast<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
connect(m_theme, &Kirigami::Platform::PlatformTheme::colorsChanged, this, [this]() {
mentionFormat.setForeground(m_theme->linkColor());
errorFormat.setForeground(m_theme->negativeTextColor());
});
mentionFormat.setFontWeight(QFont::Bold);
mentionFormat.setForeground(m_theme->linkColor());
errorFormat.setForeground(m_theme->negativeTextColor());
errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
connect(&checker, &Sonnet::BackgroundChecker::misspelling, this, [this](const QString &word, int start) {
errors += {start, word};
checker.continueChecking();
});
connect(&checker, &Sonnet::BackgroundChecker::done, this, [this]() {
rehighlightTimer.start();
});
rehighlightTimer.setInterval(100);
rehighlightTimer.setSingleShot(true);
rehighlightTimer.callOnTimeout(this, &QSyntaxHighlighter::rehighlight);
}
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 handler = dynamic_cast<ChatDocumentHandler *>(parent());
auto room = handler->room();
if (!room) {
return;
}
const auto chatchache = handler->chatBarCache();
if (!chatchache) {
return;
}
auto mentions = chatchache->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());
}
private:
Kirigami::Platform::PlatformTheme *m_theme = nullptr;
};
ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
: QObject(parent)
, m_highlighter(new SyntaxHighlighter(this))
, m_completionModel(new CompletionModel(this))
{
}
void ChatDocumentHandler::updateCompletion() const
{
int start = completionStartIndex();
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
}
int ChatDocumentHandler::completionStartIndex() const
{
if (!m_room) {
return 0;
}
const qsizetype cursor = cursorPosition();
const auto &text = getText();
auto start = std::min(cursor, text.size()) - 1;
while (start > -1) {
if (text.at(start) == QLatin1Char(' ')) {
start++;
break;
}
start--;
}
return start;
}
ChatBarType::Type ChatDocumentHandler::type() const
{
return m_type;
}
void ChatDocumentHandler::setType(ChatBarType::Type type)
{
if (type == m_type) {
return;
}
m_type = type;
Q_EMIT typeChanged();
}
QQuickItem *ChatDocumentHandler::textItem() const
{
return m_textItem;
}
void ChatDocumentHandler::setTextItem(QQuickItem *textItem)
{
if (textItem == m_textItem) {
return;
}
if (m_textItem) {
m_textItem->disconnect(this);
if (const auto textDoc = document()) {
textDoc->disconnect(this);
}
}
m_textItem = textItem;
m_highlighter->setDocument(document());
if (m_textItem) {
connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(updateCompletion()));
if (document()) {
connect(document(), &QTextDocument::contentsChanged, this, [this]() {
if (m_room) {
m_room->cacheForType(m_type)->setText(getText());
int start = completionStartIndex();
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
}
});
}
}
Q_EMIT textItemChanged();
}
QTextDocument *ChatDocumentHandler::document() const
{
if (!m_textItem) {
return nullptr;
}
const auto quickDocument = qvariant_cast<QQuickTextDocument *>(m_textItem->property("textDocument"));
return quickDocument ? quickDocument->textDocument() : nullptr;
}
int ChatDocumentHandler::cursorPosition() const
{
if (!m_textItem) {
return -1;
}
return m_textItem->property("cursorPosition").toInt();
}
NeoChatRoom *ChatDocumentHandler::room() const
{
return m_room;
}
void ChatDocumentHandler::setRoom(NeoChatRoom *room)
{
if (m_room == room) {
return;
}
if (m_room && m_type != ChatBarType::None) {
m_room->cacheForType(m_type)->disconnect(this);
if (!m_room->isSpace() && document() && m_type == ChatBarType::Room) {
m_room->mainCache()->setSavedText(document()->toPlainText());
}
}
m_room = room;
m_completionModel->setRoom(m_room);
if (m_room && m_type != ChatBarType::None) {
connect(m_room->cacheForType(m_type), &ChatBarCache::textChanged, this, [this]() {
int start = completionStartIndex();
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
});
if (!m_room->isSpace() && document() && m_type == ChatBarType::Room) {
document()->setPlainText(room->mainCache()->savedText());
m_room->mainCache()->setText(room->mainCache()->savedText());
}
}
Q_EMIT roomChanged();
}
ChatBarCache *ChatDocumentHandler::chatBarCache() const
{
if (!m_room || m_type == ChatBarType::None) {
return nullptr;
}
return m_room->cacheForType(m_type);
}
void ChatDocumentHandler::complete(int index)
{
if (document() == nullptr) {
qCWarning(ChatDocumentHandling) << "complete called with m_document set to nullptr.";
return;
}
if (m_completionModel->autoCompletionType() == CompletionModel::None) {
qCWarning(ChatDocumentHandling) << "complete called with m_completionModel->autoCompletionType() == CompletionModel::None.";
return;
}
// Ensure we only search for the beginning of the current completion identifier
const auto fromIndex = qMax(completionStartIndex(), 0);
if (m_completionModel->autoCompletionType() == CompletionModel::User) {
auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::DisplayNameRole).toString();
auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::SubtitleRole).toString();
auto text = getText();
auto at = text.indexOf(QLatin1Char('@'), fromIndex);
QTextCursor cursor(document());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(name + u" "_s);
cursor.setPosition(at);
cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
pushMention({cursor, name, 0, 0, id});
m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == CompletionModel::Command) {
auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedTextRole).toString();
auto text = getText();
auto at = text.indexOf(QLatin1Char('/'), fromIndex);
QTextCursor cursor(document());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(u"/%1 "_s.arg(command));
} else if (m_completionModel->autoCompletionType() == CompletionModel::Room) {
auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::SubtitleRole).toString();
auto text = getText();
auto at = text.indexOf(QLatin1Char('#'), fromIndex);
QTextCursor cursor(document());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(alias + u" "_s);
cursor.setPosition(at);
cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
pushMention({cursor, alias, 0, 0, alias});
m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == CompletionModel::Emoji) {
auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedTextRole).toString();
auto text = getText();
auto at = text.indexOf(QLatin1Char(':'), fromIndex);
QTextCursor cursor(document());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(shortcode);
}
}
CompletionModel *ChatDocumentHandler::completionModel() const
{
return m_completionModel;
}
QString ChatDocumentHandler::getText() const
{
if (!document()) {
qCWarning(ChatDocumentHandling) << "getText called with no QQuickTextDocument available.";
return {};
}
return document()->toPlainText();
}
void ChatDocumentHandler::pushMention(const Mention mention) const
{
if (!m_room || m_type == ChatBarType::None) {
qCWarning(ChatDocumentHandling) << "pushMention called with no ChatBarCache available. ChatBarType: " << m_type << " Room: " << m_room;
return;
}
m_room->cacheForType(m_type)->mentions()->push_back(mention);
}
void ChatDocumentHandler::updateMentions(const QString &editId)
{
if (editId.isEmpty() || m_type == ChatBarType::None || !m_room) {
return;
}
if (auto event = m_room->findInTimeline(editId); event != m_room->historyEdge()) {
if (const auto &roomMessageEvent = &*event->viewAs<Quotient::RoomMessageEvent>()) {
// Replaces the mentions that are baked into the HTML but plaintext in the original markdown
const QRegularExpression re(uR"lit(<a\shref="https:\/\/matrix.to\/#\/([\S]*)"\s?>([\S]*)<\/a>)lit"_s);
m_room->cacheForType(m_type)->mentions()->clear();
int linkSize = 0;
auto matches = re.globalMatch(EventHandler::rawMessageBody(*roomMessageEvent));
while (matches.hasNext()) {
const QRegularExpressionMatch match = matches.next();
if (match.hasMatch()) {
const QString id = match.captured(1);
const QString name = match.captured(2);
const int position = match.capturedStart(0) - linkSize;
const int end = position + name.length();
linkSize += match.capturedLength(0) - name.length();
QTextCursor cursor(document());
cursor.setPosition(position);
cursor.setPosition(end, QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
pushMention(Mention{.cursor = cursor, .text = name, .start = position, .position = end, .id = id});
}
}
}
}
}
#include "moc_chatdocumenthandler.cpp"

View File

@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QTextCursor>
#include "chatbarcache.h"
#include "enums/chatbartype.h"
#include "models/completionmodel.h"
#include "neochatroom.h"
class QTextDocument;
class NeoChatRoom;
class SyntaxHighlighter;
/**
* @class ChatDocumentHandler
*
* Handle the QQuickTextDocument of a qml text item.
*
* The class provides functionality to highlight text in the text document as well
* as providing completion functionality via a CompletionModel.
*
* The ChatDocumentHandler is also linked to a NeoChatRoom to provide functionality
* to save the chat document text when switching between rooms.
*
* To get the full functionality the cursor position and text selection information
* need to be passed in. For example:
*
* @code{.qml}
* import QtQuick 2.0
* import QtQuick.Controls 2.15 as QQC2
*
* import org.kde.kirigami 2.12 as Kirigami
* import org.kde.neochat 1.0
*
* QQC2.TextArea {
* id: textField
*
* // Set this to a NeoChatRoom object.
* property var room
*
* ChatDocumentHandler {
* id: documentHandler
* document: textField.textDocument
* cursorPosition: textField.cursorPosition
* selectionStart: textField.selectionStart
* selectionEnd: textField.selectionEnd
* mentionColor: Kirigami.Theme.linkColor
* errorColor: Kirigami.Theme.negativeTextColor
* room: textField.room
* }
* }
* @endcode
*
* @sa QQuickTextDocument, CompletionModel, NeoChatRoom
*/
class ChatDocumentHandler : public QObject
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The QQuickTextDocument that is being handled.
*/
Q_PROPERTY(ChatBarType::Type type READ type WRITE setType NOTIFY typeChanged)
/**
* @brief The QML text Item the ChatDocumentHandler is handling.
*/
Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged)
/**
* @brief The current CompletionModel.
*
* This is typically provided to a qml component to visualise the current
* completion results.
*/
Q_PROPERTY(CompletionModel *completionModel READ completionModel CONSTANT)
/**
* @brief The current room that the text document is being handled for.
*/
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
public:
explicit ChatDocumentHandler(QObject *parent = nullptr);
ChatBarType::Type type() const;
void setType(ChatBarType::Type type);
QQuickItem *textItem() const;
void setTextItem(QQuickItem *textItem);
[[nodiscard]] NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
ChatBarCache *chatBarCache() const;
Q_INVOKABLE void complete(int index);
CompletionModel *completionModel() const;
/**
* @brief Update the mentions in @p document when editing a message.
*/
Q_INVOKABLE void updateMentions(const QString &editId);
Q_SIGNALS:
void typeChanged();
void textItemChanged();
void roomChanged();
public Q_SLOTS:
void updateCompletion() const;
private:
ChatBarType::Type m_type = ChatBarType::None;
QPointer<QQuickItem> m_textItem;
QTextDocument *document() const;
int completionStartIndex() const;
QPointer<NeoChatRoom> m_room;
int cursorPosition() const;
QString getText() const;
void pushMention(const Mention mention) const;
SyntaxHighlighter *m_highlighter = nullptr;
CompletionModel *m_completionModel = nullptr;
};

View File

@@ -1,225 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "chatkeyhelper.h"
#include "chattextitemhelper.h"
#include "clipboard.h"
#include "neochatroom.h"
#include "richformat.h"
ChatKeyHelper::ChatKeyHelper(QObject *parent)
: QObject(parent)
{
}
bool ChatKeyHelper::handleKey(Qt::Key key, Qt::KeyboardModifiers modifiers)
{
switch (key) {
case Qt::Key_V:
return vKey(modifiers);
case Qt::Key_Up:
return up(modifiers);
case Qt::Key_Down:
return down();
case Qt::Key_Tab:
return tab();
case Qt::Key_Delete:
return deleteChar();
case Qt::Key_Backspace:
return backspace();
case Qt::Key_Enter:
case Qt::Key_Return:
return insertReturn(modifiers);
case Qt::Key_Escape:
case Qt::Key_Cancel:
return cancel();
default:
return false;
}
}
bool ChatKeyHelper::vKey(Qt::KeyboardModifiers modifiers)
{
if (!textItem) {
return false;
}
if (modifiers.testFlag(Qt::ControlModifier)) {
return pasteImage();
}
return false;
}
bool ChatKeyHelper::up(Qt::KeyboardModifiers modifiers)
{
if (!textItem) {
return false;
}
if (modifiers.testFlag(Qt::ControlModifier)) {
const auto room = textItem->room();
if (!room) {
return false;
}
room->replyLastMessage();
return true;
}
if (textItem->isCompleting) {
Q_EMIT unhandledUp(true);
return true;
}
QTextCursor cursor = textItem->textCursor();
if (cursor.isNull()) {
return false;
}
if (cursor.blockNumber() == 0 && cursor.block().layout()->lineForTextPosition(cursor.positionInBlock()).lineNumber() == 0) {
Q_EMIT unhandledUp(false);
return true;
}
return false;
}
bool ChatKeyHelper::down()
{
if (!textItem) {
return false;
}
if (textItem->isCompleting) {
Q_EMIT unhandledDown(true);
return true;
}
QTextCursor cursor = textItem->textCursor();
if (cursor.isNull()) {
return false;
}
if (cursor.blockNumber() == cursor.document()->blockCount() - 1
&& cursor.block().layout()->lineForTextPosition(cursor.positionInBlock()).lineNumber() == (cursor.block().layout()->lineCount() - 1)) {
Q_EMIT unhandledDown(false);
return true;
}
return false;
}
bool ChatKeyHelper::tab()
{
if (!textItem) {
return false;
}
if (textItem->isCompleting) {
Q_EMIT unhandledTab(true);
return true;
}
QTextCursor cursor = textItem->textCursor();
if (cursor.isNull()) {
return false;
}
if (cursor.currentList() && textItem->canIndentListMoreAtCursor()) {
textItem->indentListMoreAtCursor();
return true;
}
return false;
}
bool ChatKeyHelper::deleteChar()
{
if (!textItem) {
return false;
}
QTextCursor cursor = textItem->textCursor();
if (cursor.isNull() || cursor.hasSelection()) {
return false;
}
if (cursor.position() >= textItem->document()->characterCount() - textItem->fixedEndChars().length() - 1) {
Q_EMIT unhandledDelete();
return true;
}
return false;
}
bool ChatKeyHelper::backspace()
{
if (!textItem) {
return false;
}
QTextCursor cursor = textItem->textCursor();
if (cursor.isNull()) {
return false;
}
if (cursor.position() <= textItem->fixedStartChars().length()) {
if (cursor.currentList() && textItem->canIndentListLessAtCursor()) {
textItem->indentListLessAtCursor();
return true;
}
Q_EMIT unhandledBackspace();
return true;
}
return false;
}
bool ChatKeyHelper::insertReturn(Qt::KeyboardModifiers modifiers)
{
if (!textItem) {
return false;
}
bool shiftPressed = modifiers.testFlag(Qt::ShiftModifier);
if (shiftPressed && !sendMessageWithEnter) {
Q_EMIT unhandledReturn(false);
return true;
}
if (!shiftPressed && textItem->isCompleting) {
Q_EMIT unhandledReturn(true);
return true;
}
if (!shiftPressed && sendMessageWithEnter) {
Q_EMIT unhandledReturn(false);
return true;
}
QTextCursor cursor = textItem->textCursor();
if (cursor.isNull()) {
return false;
}
// If there was a heading we always want to revert to Paragraph format.
auto newBlockFormat = RichFormat::blockFormatForFormat(RichFormat::Paragraph);
auto newCharFormat = cursor.charFormat();
newCharFormat.merge(RichFormat::charFormatForFormat(static_cast<RichFormat::Format>(cursor.blockFormat().headingLevel()), true));
cursor.insertBlock(newBlockFormat, newCharFormat);
return true;
}
bool ChatKeyHelper::cancel()
{
if (!textItem) {
return false;
}
if (textItem->isCompleting) {
Q_EMIT closeCompletion();
return true;
}
return false;
}
bool ChatKeyHelper::pasteImage()
{
if (!textItem) {
return false;
}
const auto savePath = Clipboard().saveImage();
if (!savePath.isEmpty()) {
Q_EMIT imagePasted(savePath);
}
return false;
}
#include "moc_chatkeyhelper.cpp"

View File

@@ -1,133 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
class ChatTextItemHelper;
/**
* @class ChatKeyHelper
*
* A class to handle some key presses on behalf of a ChatTextItemHelper.
*
* This is used to manage complex rich text interactions.
*
* @sa ChatTextItemHelper
*/
class ChatKeyHelper : public QObject
{
Q_OBJECT
QML_ELEMENT
public:
explicit ChatKeyHelper(QObject *parent = nullptr);
/**
* @brief The ChatTextItemHelper that ChatKeyHelper is handling key presses for.
*
* @sa ChatTextItemHelper
*/
QPointer<ChatTextItemHelper> textItem;
/**
* @brief handle the given key and modifiers.
*/
Q_INVOKABLE bool handleKey(Qt::Key key, Qt::KeyboardModifiers modifiers);
/**
* @brief Whether the enter/return should send message.
*
* If false, return/enter adds a new line.
*
* shift + return/enter does the opposite to return/enter.
*/
bool sendMessageWithEnter = true;
Q_SIGNALS:
/**
* @brief There is an unhandled up key press.
*
* Current trigger conditions:
* - Up is pressed on the first line of the first block of the text item.
* - Return clicked when a completion has been started.
*/
void unhandledUp(bool isCompleting);
/**
* @brief There is an unhandled down key press.
*
* Current trigger conditions:
* - Down is pressed on the last line of the last block of the text item.
* - Return clicked when a completion has been started.
*/
void unhandledDown(bool isCompleting);
/**
* @brief There is an unhandled tab key press.
*
* Current trigger conditions:
* - Tab clicked when a completion has been started.
*/
void unhandledTab(bool isCompleting);
/**
* @brief There is an unhandled delete key press.
*
* Current trigger conditions:
* - Delete is pressed at the end of the last line of the last block of the
* text item.
*/
void unhandledDelete();
/**
* @brief There is an unhandled backspace key press.
*
* Current trigger conditions:
* - Backspace is pressed at the beginning of the first line of the first
* block of the text item.
*/
void unhandledBackspace();
/**
* @brief There is an unhandled return key press.
*
* Current trigger conditions:
* - Return clicked when a completion has been started.
*/
void unhandledReturn(bool isCompleting);
/**
* @brief The completion dialog should be closed if open.
*
* Current trigger conditions:
* - Return clicked when a completion has been started.
*/
void closeCompletion();
/**
* @brief An image has been pasted.
*/
void imagePasted(const QString &filePath);
private:
bool vKey(Qt::KeyboardModifiers modifiers);
bool up(Qt::KeyboardModifiers modifiers);
bool down();
bool tab();
bool deleteChar();
bool backspace();
bool insertReturn(Qt::KeyboardModifiers modifiers);
bool cancel();
bool pasteImage();
};

View File

@@ -1,289 +0,0 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "chatmarkdownhelper.h"
#include <QTextCursor>
#include <QTextDocument>
#include <qtextcursor.h>
#include "chattextitemhelper.h"
#include "richformat.h"
namespace
{
struct MarkdownSyntax {
QLatin1String sequence;
bool closable = false;
bool lineStart = false;
RichFormat::Format format;
};
const QList<MarkdownSyntax> syntax = {
MarkdownSyntax{.sequence = "*"_L1, .closable = true, .format = RichFormat::Italic},
MarkdownSyntax{.sequence = "**"_L1, .closable = true, .format = RichFormat::Bold},
MarkdownSyntax{.sequence = "# "_L1, .lineStart = true, .format = RichFormat::Heading1},
MarkdownSyntax{.sequence = " # "_L1, .lineStart = true, .format = RichFormat::Heading1},
MarkdownSyntax{.sequence = " # "_L1, .lineStart = true, .format = RichFormat::Heading1},
MarkdownSyntax{.sequence = " # "_L1, .lineStart = true, .format = RichFormat::Heading1},
MarkdownSyntax{.sequence = "## "_L1, .lineStart = true, .format = RichFormat::Heading2},
MarkdownSyntax{.sequence = " ## "_L1, .lineStart = true, .format = RichFormat::Heading2},
MarkdownSyntax{.sequence = " ## "_L1, .lineStart = true, .format = RichFormat::Heading2},
MarkdownSyntax{.sequence = " ## "_L1, .lineStart = true, .format = RichFormat::Heading2},
MarkdownSyntax{.sequence = "### "_L1, .lineStart = true, .format = RichFormat::Heading3},
MarkdownSyntax{.sequence = " ### "_L1, .lineStart = true, .format = RichFormat::Heading3},
MarkdownSyntax{.sequence = " ### "_L1, .lineStart = true, .format = RichFormat::Heading3},
MarkdownSyntax{.sequence = " ### "_L1, .lineStart = true, .format = RichFormat::Heading3},
MarkdownSyntax{.sequence = "#### "_L1, .lineStart = true, .format = RichFormat::Heading4},
MarkdownSyntax{.sequence = " #### "_L1, .lineStart = true, .format = RichFormat::Heading4},
MarkdownSyntax{.sequence = " #### "_L1, .lineStart = true, .format = RichFormat::Heading4},
MarkdownSyntax{.sequence = " #### "_L1, .lineStart = true, .format = RichFormat::Heading4},
MarkdownSyntax{.sequence = "##### "_L1, .lineStart = true, .format = RichFormat::Heading5},
MarkdownSyntax{.sequence = " ##### "_L1, .lineStart = true, .format = RichFormat::Heading5},
MarkdownSyntax{.sequence = " ##### "_L1, .lineStart = true, .format = RichFormat::Heading5},
MarkdownSyntax{.sequence = " ##### "_L1, .lineStart = true, .format = RichFormat::Heading5},
MarkdownSyntax{.sequence = "###### "_L1, .lineStart = true, .format = RichFormat::Heading6},
MarkdownSyntax{.sequence = " ###### "_L1, .lineStart = true, .format = RichFormat::Heading6},
MarkdownSyntax{.sequence = " ###### "_L1, .lineStart = true, .format = RichFormat::Heading6},
MarkdownSyntax{.sequence = " ###### "_L1, .lineStart = true, .format = RichFormat::Heading6},
MarkdownSyntax{.sequence = ">"_L1, .lineStart = true, .format = RichFormat::Quote},
MarkdownSyntax{.sequence = " >"_L1, .lineStart = true, .format = RichFormat::Quote},
MarkdownSyntax{.sequence = " >"_L1, .lineStart = true, .format = RichFormat::Quote},
MarkdownSyntax{.sequence = " >"_L1, .lineStart = true, .format = RichFormat::Quote},
MarkdownSyntax{.sequence = "> "_L1, .lineStart = true, .format = RichFormat::Quote},
MarkdownSyntax{.sequence = " > "_L1, .lineStart = true, .format = RichFormat::Quote},
MarkdownSyntax{.sequence = " > "_L1, .lineStart = true, .format = RichFormat::Quote},
MarkdownSyntax{.sequence = " > "_L1, .lineStart = true, .format = RichFormat::Quote},
MarkdownSyntax{.sequence = "* "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
MarkdownSyntax{.sequence = " * "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
MarkdownSyntax{.sequence = " * "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
MarkdownSyntax{.sequence = " * "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
MarkdownSyntax{.sequence = "- "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
MarkdownSyntax{.sequence = " - "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
MarkdownSyntax{.sequence = " - "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
MarkdownSyntax{.sequence = " - "_L1, .lineStart = true, .format = RichFormat::UnorderedList},
MarkdownSyntax{.sequence = "1. "_L1, .lineStart = true, .format = RichFormat::OrderedList},
MarkdownSyntax{.sequence = " 1. "_L1, .lineStart = true, .format = RichFormat::OrderedList},
MarkdownSyntax{.sequence = " 1. "_L1, .lineStart = true, .format = RichFormat::OrderedList},
MarkdownSyntax{.sequence = " 1. "_L1, .lineStart = true, .format = RichFormat::OrderedList},
MarkdownSyntax{.sequence = "1) "_L1, .lineStart = true, .format = RichFormat::OrderedList},
MarkdownSyntax{.sequence = " 1) "_L1, .lineStart = true, .format = RichFormat::OrderedList},
MarkdownSyntax{.sequence = " 1) "_L1, .lineStart = true, .format = RichFormat::OrderedList},
MarkdownSyntax{.sequence = " 1) "_L1, .lineStart = true, .format = RichFormat::OrderedList},
MarkdownSyntax{.sequence = "`"_L1, .closable = true, .format = RichFormat::InlineCode},
MarkdownSyntax{.sequence = "```"_L1, .lineStart = true, .format = RichFormat::Code},
MarkdownSyntax{.sequence = " ```"_L1, .lineStart = true, .format = RichFormat::Code},
MarkdownSyntax{.sequence = " ```"_L1, .lineStart = true, .format = RichFormat::Code},
MarkdownSyntax{.sequence = " ```"_L1, .lineStart = true, .format = RichFormat::Code},
MarkdownSyntax{.sequence = "~~"_L1, .closable = true, .format = RichFormat::Strikethrough},
MarkdownSyntax{.sequence = "_"_L1, .closable = true, .format = RichFormat::Underline},
};
std::optional<bool> checkSequence(const QString &currentString, const QString &nextChar, bool lineStart = false)
{
QList<MarkdownSyntax> partialMatches;
std::optional<MarkdownSyntax> fullMatch = std::nullopt;
auto it = syntax.cbegin();
while ((it = std::find_if(it,
syntax.cend(),
[currentString, nextChar](const MarkdownSyntax &syntax) {
return syntax.sequence == currentString || syntax.sequence.startsWith(QString(currentString + nextChar));
}))
!= syntax.cend()) {
if (it->lineStart ? lineStart : true) {
if (it->sequence == currentString) {
fullMatch = *it;
} else {
partialMatches += *it;
}
}
++it;
}
if (partialMatches.length() > 0) {
return false;
}
if (fullMatch) {
return true;
}
return std::nullopt;
}
bool checkSequenceBackwards(const QString &currentString)
{
auto it = syntax.cbegin();
while ((it = std::find_if(it,
syntax.cend(),
[currentString](const MarkdownSyntax &syntax) {
return syntax.sequence.endsWith(currentString);
}))
!= syntax.cend()) {
return true;
}
return false;
}
std::optional<MarkdownSyntax> syntaxForSequence(const QString &sequence)
{
const auto it = std::find_if(syntax.cbegin(), syntax.cend(), [sequence](const MarkdownSyntax &syntax) {
return syntax.sequence == sequence;
});
if (it == syntax.cend()) {
return std::nullopt;
}
return *it;
}
}
ChatMarkdownHelper::ChatMarkdownHelper(QObject *parent)
: QObject(parent)
{
}
ChatTextItemHelper *ChatMarkdownHelper::textItem() const
{
return m_textItem;
}
void ChatMarkdownHelper::setTextItem(ChatTextItemHelper *textItem)
{
if (textItem == m_textItem) {
return;
}
if (m_textItem) {
m_textItem->disconnect(this);
}
m_textItem = textItem;
if (m_textItem) {
connect(m_textItem, &ChatTextItemHelper::textItemChanged, this, &ChatMarkdownHelper::updateStart);
connect(m_textItem, &ChatTextItemHelper::cursorPositionChanged, this, [this](bool fromContentsChange) {
if (!fromContentsChange) {
updateStart();
}
});
connect(m_textItem, &ChatTextItemHelper::contentsChange, this, &ChatMarkdownHelper::checkMarkdown);
}
Q_EMIT textItemChanged();
}
void ChatMarkdownHelper::updateStart()
{
if (!m_textItem) {
return;
}
const auto newCursorPosition = m_textItem->cursorPosition();
if (newCursorPosition) {
updatePosition(*newCursorPosition);
}
}
void ChatMarkdownHelper::checkMarkdown(int position, int charsRemoved, int charsAdded)
{
updatePosition(position);
// This can happen when formatting is applied.
if (charsAdded == charsRemoved) {
return;
} else if (charsRemoved > charsAdded || charsAdded - charsRemoved > 1) {
updatePosition(std::max(0, position - charsRemoved + charsAdded));
return;
}
checkMarkdownForward();
}
void ChatMarkdownHelper::updatePosition(int position)
{
if (position == m_endPos) {
return;
}
m_startPos = position;
m_endPos = position;
if (m_startPos <= 0) {
return;
}
auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return;
}
cursor.setPosition(m_startPos);
cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor);
while (checkSequenceBackwards(cursor.selectedText()) && m_startPos > 0) {
--m_startPos;
cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor);
}
}
void ChatMarkdownHelper::checkMarkdownForward()
{
auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return;
}
cursor.setPosition(m_startPos);
const auto atBlockStart = cursor.atBlockStart();
cursor.setPosition(m_endPos, QTextCursor::KeepAnchor);
const auto currentMarkdown = cursor.selectedText();
cursor.setPosition(m_endPos);
cursor.setPosition(m_endPos + 1, QTextCursor::KeepAnchor);
const auto nextChar = cursor.selectedText();
const auto result = checkSequence(currentMarkdown, nextChar, atBlockStart);
if (!result) {
++m_startPos;
m_endPos = m_startPos;
return;
}
if (!*result) {
++m_endPos;
return;
}
complete();
}
void ChatMarkdownHelper::complete()
{
auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return;
}
cursor.beginEditBlock();
cursor.setPosition(m_startPos);
cursor.setPosition(m_endPos, QTextCursor::KeepAnchor);
const auto syntax = syntaxForSequence(cursor.selectedText());
if (!syntax) {
return;
}
cursor.removeSelectedText();
cursor.setPosition(m_startPos);
cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor);
const auto nextChar = cursor.selectedText();
const auto result = checkSequence({}, nextChar, cursor.atBlockStart());
cursor.setPosition(m_startPos);
cursor.movePosition(QTextCursor::NextWord, QTextCursor::KeepAnchor);
const auto formatType = RichFormat::typeForFormat(syntax->format);
if (formatType == RichFormat::Block) {
Q_EMIT unhandledBlockFormat(syntax->format);
} else {
m_textItem->mergeFormatOnCursor(syntax->format, cursor);
}
m_startPos = result ? m_startPos : m_startPos + 1;
m_endPos = result ? m_startPos + 1 : m_startPos;
cursor.endEditBlock();
}
#include "moc_chatmarkdownhelper.cpp"

Some files were not shown because too many files have changed in this diff Show More