Compare commits

..

71 Commits

Author SHA1 Message Date
Carl Schwan
3748b6902c assert false 2024-03-03 19:09:49 +01:00
Carl Schwan
a4917d82e9 More unit tests 2024-03-03 18:57:17 +01:00
Carl Schwan
b9bf37089b More fixes
Need https://github.com/quotient-im/libQuotient/pull/723
2024-03-03 17:22:25 +01:00
Carl Schwan
1844a90fc0 I hate models 2024-03-03 16:26:16 +01:00
Carl Schwan
23099046a3 Fix compilation 2024-03-03 14:00:41 +01:00
Carl Schwan
bdf4ee43c8 Use TreeItem from qt tree model example in RoomTreeModel 2024-03-03 13:31:06 +01:00
Carl Schwan
c6300179d8 hack to fix crash in roomtreemodel 2024-03-03 13:29:05 +01:00
l10n daemon script
76e7eb7009 GIT_SILENT Sync po/docbooks with svn 2024-03-03 01:23:44 +00:00
l10n daemon script
fa99b8829d SVN_SILENT made messages (.desktop file) - always resolve ours
In case of conflict in i18n, keep the version of the branch "ours"
To resolve a particular conflict, "git checkout --ours path/to/file.desktop"
2024-03-03 01:15:35 +00:00
l10n daemon script
98b749ee9f GIT_SILENT made messages (after extraction) 2024-03-03 00:38:27 +00:00
Joshua Goins
cb9fd02dc7 Show link preview for links in room topics
This has an important security benefit, to prevent hiding fake links
behind real-looking ones.
2024-03-02 23:15:28 +00:00
Joshua Goins
1f4a271dd6 Remove unnecessary regex in room topic component
This makes Markdown links work again
2024-03-02 23:15:28 +00:00
Tobias Fella
bdf192df58 Use plaintext in devtools room selection combo 2024-03-02 19:36:46 +01:00
Tobias Fella
1249304907 Add devtools button to account menu 2024-03-02 19:07:13 +01:00
Tobias Fella
10e3ab1f78 Hide typing notifications for ignored users
Fixes #635
2024-03-02 18:43:08 +01:00
James Graham
59ea270b2f Fix the top section heading in the timeline. It was previously causing anchor loops 2024-03-02 10:28:04 +00:00
l10n daemon script
3b748e2e21 GIT_SILENT Sync po/docbooks with svn 2024-03-02 01:18:53 +00:00
l10n daemon script
432fae5ce2 GIT_SILENT made messages (after extraction) 2024-03-02 00:37:39 +00:00
James Graham
f557ceda19 Notification Consistency
Make sure that the new rules for counting notifications for muted, mention and low priority rooms is applied consistently to the room list, space drawer and the task manager notification badge

implements #644
2024-03-01 17:56:13 +00:00
Tobias Fella
943f6c762c Fix (un)ignoring unknown users 2024-03-01 09:32:45 +00:00
l10n daemon script
5a9293e293 GIT_SILENT Sync po/docbooks with svn 2024-03-01 01:32:00 +00:00
l10n daemon script
a1bc52c1a5 GIT_SILENT made messages (after extraction) 2024-03-01 00:38:01 +00:00
Carl Schwan
dcd752be8f Fix compilation error 2024-02-29 23:48:35 +01:00
Carl Schwan
deb2c61b28 Improve appstream metadata 2024-02-29 23:48:35 +01:00
Joshua Goins
5e346283a9 Add app identifier in donation url 2024-02-29 20:11:16 +00:00
Carl Schwan
dc1e4219a0 Add more appstream urls 2024-02-29 09:08:30 +00:00
l10n daemon script
a916f70698 GIT_SILENT Sync po/docbooks with svn 2024-02-29 01:19:12 +00:00
l10n daemon script
77da4d2d53 SVN_SILENT made messages (.desktop file) - always resolve ours
In case of conflict in i18n, keep the version of the branch "ours"
To resolve a particular conflict, "git checkout --ours path/to/file.desktop"
2024-02-29 01:13:28 +00:00
James Graham
1ae3fc86da Updated room sorting
Change the LastActivity sort order to Activity and update to a more flexible way of sorting based on an order from model roles.
Add options to actually switch between Alphabetical and Activity

Based on some old work by @tdfischer 

implements #103
2024-02-28 17:57:32 +00:00
l10n daemon script
0c24996b44 GIT_SILENT Sync po/docbooks with svn 2024-02-28 01:18:27 +00:00
l10n daemon script
1cbb4a93eb SVN_SILENT made messages (.desktop file) - always resolve ours
In case of conflict in i18n, keep the version of the branch "ours"
To resolve a particular conflict, "git checkout --ours path/to/file.desktop"
2024-02-28 01:13:32 +00:00
James Graham
1fac6ca34a Split text section into blocks
The aim is to be able to use separate delegate for things like codeblocks and quotes so that they can be styled differently.

![image](/uploads/c813c0d1767f45df14cebe632e2ee10a/image.png)
2024-02-27 18:52:06 +00:00
l10n daemon script
ca439b0f86 GIT_SILENT Sync po/docbooks with svn 2024-02-27 01:19:14 +00:00
l10n daemon script
f70febc8d3 SVN_SILENT made messages (.desktop file) - always resolve ours
In case of conflict in i18n, keep the version of the branch "ours"
To resolve a particular conflict, "git checkout --ours path/to/file.desktop"
2024-02-27 01:13:57 +00:00
Tobias Fella
628de56087 Devtools: Split state keys into a separate list 2024-02-26 17:05:28 +01:00
Tobias Fella
bc67033c00 Adapt to QTP0001 2024-02-26 16:49:17 +01:00
l10n daemon script
e0ba2a179c GIT_SILENT Sync po/docbooks with svn 2024-02-26 01:20:46 +00:00
Tobias Fella
e0ce5d9544 Show AccountData in Devtools 2024-02-25 21:05:12 +01:00
Tobias Fella
38acfe04b9 Fix sharing 2024-02-25 20:13:39 +01:00
Tobias Fella
5b0068d9e4 Cache space hierarchy to disk
This significantly improves the startup experiences by eliminating
the changes in the space drawer while neochat loads.

Implements #640
Replaces !1563
2024-02-25 20:12:07 +01:00
James Graham
38edad2ac5 Make sure that only text messages can be edited 2024-02-25 17:20:49 +00:00
Tobias Fella
e95f191dc6 Add view of ignored users and allow unignoring them 2024-02-25 18:03:10 +01:00
Tobias Fella
8ae92ff4d4 Port room upgrade dialog to Kirigami.Dialog 2024-02-25 17:55:05 +01:00
Tobias Fella
40e15d456a Favourites -> Favorites
BUG: 481814
2024-02-25 16:35:10 +01:00
l10n daemon script
a1b3308cd7 GIT_SILENT Sync po/docbooks with svn 2024-02-25 01:23:35 +00:00
l10n daemon script
48ef19e4be GIT_SILENT Sync po/docbooks with svn 2024-02-24 01:17:25 +00:00
l10n daemon script
d3f00b9246 GIT_SILENT Sync po/docbooks with svn 2024-02-23 01:18:21 +00:00
Tobias Fella
89de676982 Don't build kirigami-addons master 2024-02-22 19:06:14 +01:00
Tobias Fella
6dec624d22 Pin kirigami-addons 2024-02-22 19:05:00 +01:00
Tobias Fella
d4eb9ea320 Move ShowAuthorRole to MessageFilterModel
Only there can it be calculated correctly.
Fixes #632
2024-02-22 18:42:04 +01:00
l10n daemon script
1cfaa88989 GIT_SILENT Sync po/docbooks with svn 2024-02-22 01:19:42 +00:00
James Graham
82ae0891a8 Create a feature flag page in devtools. 2024-02-21 23:53:51 +00:00
James Graham
58c43b0cd4 Make sure that the room isn't already in the model before appending.
Make sure that the room isn't already in the model before appending. Move it if it's in the wrong catgeory
2024-02-21 23:52:10 +00:00
Tobias Fella
1c373031bd Html-escape display name in user detail sheet 2024-02-21 18:10:32 +01:00
Tobias Fella
e93842a95f Fix saving images from maximize component
Fixes #643
2024-02-21 16:51:23 +01:00
l10n daemon script
9cdb34cb1d GIT_SILENT Sync po/docbooks with svn 2024-02-21 01:19:26 +00:00
James Graham
864f9b8f74 Add feature flag for reply in thread
Currently the ability to reply in threads was added but not the ability to actually view threads so this doesn't currently make much sense to just have enabled int he main build.

Note: I want to cherrypick this so it's just the flag. I'll add a feature flag page to dev tools for master soon.
2024-02-20 20:09:49 +00:00
Tobias Fella
d99fae333a Introduce MediaManager
Currently, this is only to pause playback of other media when something new starts playing.
In the future, it will also be used for other things, like call ringing, etc.
2024-02-20 21:08:47 +01:00
Tobias Fella
b27bec3e16 Leave predecessor rooms when leaving room
Implements #621
2024-02-20 20:59:04 +01:00
Tobias Fella
5fb4838734 Improve push notification string 2024-02-20 20:50:17 +01:00
Tobias Fella
1296255a86 Remove room from RoomTreeModel before leaving it
This makes sure that the room disappears from the tree view
2024-02-20 20:42:02 +01:00
Tobias Fella
e3ed2fcaa9 Fix crash in ImagePacksModel 2024-02-20 20:36:33 +01:00
Tobias Fella
69d6b90a12 Fix email 2024-02-20 18:43:23 +01:00
Tobias Fella
6937a4b2fe Quotient -> libQuotient 2024-02-20 18:42:13 +01:00
Tobias Fella
621c36f821 Fix disconnect warning 2024-02-20 18:40:00 +01:00
Tobias Fella
940929d044 Fix qml warning 2024-02-20 18:22:42 +01:00
Tobias Fella
bac5fa8c14 Don't try setting up push notifications when there's no endpoint 2024-02-20 18:19:49 +01:00
Tobias Fella
a10da64378 Run qmlformat 2024-02-20 18:11:23 +01:00
Tobias Fella
f2c12c582e Remove duplicated actions 2024-02-20 18:06:09 +01:00
l10n daemon script
a686c44555 GIT_SILENT Sync po/docbooks with svn 2024-02-20 01:21:08 +00:00
Joshua Goins
8e96217ae6 Separate MessageDelegateContextMenu to its own base component
This is the first step in separation, so we can focus more on the
actions in this menu, and it's not tangled up in how the context menu is
shown and displayed.
2024-02-19 22:07:00 +01:00
160 changed files with 21900 additions and 15288 deletions

View File

@@ -3,5 +3,4 @@
[BlueprintSettings]
kde/frameworks/extra-cmake-modules.version=master
kde/unreleased/kirigami-addons.version=master
libs/qt.qtMajorVersion=6

View File

@@ -27,7 +27,7 @@
"name": "kirigamiaddons",
"config-opts": [ "-DBUILD_TESTING=OFF" ],
"buildsystem": "cmake-ninja",
"sources": [ { "type": "git", "url": "https://invent.kde.org/libraries/kirigami-addons.git" } ]
"sources": [ { "type": "git", "url": "https://invent.kde.org/libraries/kirigami-addons.git", "commit": "34d311219e8b7209746a98b3a29b91ded05ff936" } ]
},
{
"name": "kquickimageeditor",

View File

@@ -31,6 +31,7 @@ Dependencies:
- 'on': ['Linux', 'FreeBSD']
'require':
'frameworks/kdbusaddons': '@latest-kf6'
'frameworks/purpose': '@latest-kf6'
- 'on': ['Linux']
'require':

View File

@@ -58,6 +58,9 @@ set_package_properties(Qt6 PROPERTIES
TYPE REQUIRED
PURPOSE "Basic application components"
)
qt_policy(SET QTP0001 NEW)
find_package(KF6 ${KF_MIN_VERSION} COMPONENTS Kirigami I18n Notifications Config CoreAddons Sonnet ItemModels ColorScheme)
set_package_properties(KF6 PROPERTIES
TYPE REQUIRED

View File

@@ -23,6 +23,11 @@ ecm_add_test(
TEST_NAME delegatesizehelpertest
)
ecm_add_test(
roomtreemodeltest.cpp
LINK_LIBRARIES neochat Qt::Test
)
ecm_add_test(
mediasizehelpertest.cpp
LINK_LIBRARIES neochat Qt::Test

View File

@@ -5,9 +5,7 @@
#include <QSignalSpy>
#include <QTest>
#include <Quotient/connection.h>
#include <Quotient/quotient_common.h>
#include <Quotient/syncdata.h>
#include "neochatconnection.h"
#include "testutils.h"
@@ -27,7 +25,8 @@ private Q_SLOTS:
void NeoChatRoomTest::initTestCase()
{
connection = Connection::makeMockConnection(QStringLiteral("@bob:kde.org"));
auto connection = new NeoChatConnection;
Connection::makeMockConnection(connection, QStringLiteral("@bob:kde.org"));
room = new TestUtils::TestRoom(connection, QStringLiteral("#myroom:kde.org"), "test-min-sync.json"_ls);
}

View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2024 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include <QAbstractItemModelTester>
#include <QTest>
#include "enums/neochatroomtype.h"
#include "models/roomtreemodel.h"
#include "models/sortfilterroomtreemodel.h"
#include "neochatconnection.h"
#include "testutils.h"
using namespace Quotient;
class RoomTreeModelTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void testTreeModel();
};
void RoomTreeModelTest::testTreeModel()
{
auto connection = new NeoChatConnection;
Connection::makeMockConnection(connection, QStringLiteral("@bob:kde.org"));
auto room = dynamic_cast<NeoChatRoom *>(new TestUtils::TestRoom(connection, QStringLiteral("#myroom:kde.org"), QStringLiteral("test-min-sync.json")));
QVERIFY(room);
connection->addRoom(room);
RoomTreeModel model;
model.setConnection(connection);
SortFilterRoomTreeModel filterModel;
filterModel.setSourceModel(&model);
QAbstractItemModelTester tester(&model);
QAbstractItemModelTester testerFilter(&filterModel);
QCOMPARE(model.rowCount(), static_cast<int>(NeoChatRoomType::TypesCount));
// Check data category
auto category = static_cast<int>(NeoChatRoomType::typeForRoom(room));
QCOMPARE(category, NeoChatRoomType::Normal);
auto normalCategoryIdx = model.index(category, 0);
QCOMPARE(model.data(normalCategoryIdx, RoomTreeModel::DisplayNameRole).toString(), QStringLiteral("Normal"));
QCOMPARE(model.data(normalCategoryIdx, RoomTreeModel::DelegateTypeRole).toString(), QStringLiteral("section"));
QCOMPARE(model.data(normalCategoryIdx, RoomTreeModel::IconRole).toString(), QStringLiteral("group"));
QCOMPARE(model.data(normalCategoryIdx, RoomTreeModel::CategoryRole).toInt(), category);
QCOMPARE(model.rowCount(normalCategoryIdx), 1);
// Check data room
auto roomIdx = model.index(0, 0, normalCategoryIdx);
QCOMPARE(model.data(roomIdx, RoomTreeModel::CurrentRoomRole).value<NeoChatRoom *>(), room);
QCOMPARE(model.data(roomIdx, RoomTreeModel::CategoryRole).toInt(), category);
// Move room
room->setProperty("isFavorite", true);
model.moveRoom(room);
auto newCategory = static_cast<int>(NeoChatRoomType::typeForRoom(room));
QCOMPARE(newCategory, NeoChatRoomType::Favorite);
auto newCategoryIdx = model.index(newCategory, 0);
QVERIFY(newCategoryIdx != normalCategoryIdx);
}
QTEST_MAIN(RoomTreeModelTest)
#include "roomtreemodeltest.moc"

View File

@@ -10,7 +10,9 @@
#include <Quotient/syncdata.h>
#include <qnamespace.h>
#include "enums/messagecomponenttype.h"
#include "models/customemojimodel.h"
#include "models/messagecontentmodel.h"
#include "neochatconnection.h"
#include "utils.h"
@@ -33,7 +35,6 @@ private Q_SLOTS:
void stripDisallowedTags();
void stripDisallowedAttributes();
void emptyCodeTags();
void formatBlockQuote();
void sendSimpleStringCase();
void sendSingleParaMarkup();
@@ -59,11 +60,13 @@ private Q_SLOTS:
void receiveRichtextIn();
void receiveRichMxcUrl();
void receiveRichPlainUrl();
void receiveRichEmote();
void receiveRichEdited_data();
void receiveRichEdited();
void receiveLineSeparator();
void receiveRichCodeUrl();
void componentOutput_data();
void componentOutput();
};
void TextHandlerTest::initTestCase()
@@ -139,16 +142,6 @@ void TextHandlerTest::emptyCodeTags()
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
}
void TextHandlerTest::formatBlockQuote()
{
auto input = QStringLiteral("<blockquote>\n<p>Lorem Ispum</p>\n</blockquote>");
auto expectedOutput = QStringLiteral("<blockquote><table><tr><td>\u201CLorem Ispum\u201D</td></tr></table></blockquote>");
TextHandler testTextHandler;
testTextHandler.setData(input);
QCOMPARE(testTextHandler.handleRecieveRichText(), expectedOutput);
}
void TextHandlerTest::sendSimpleStringCase()
{
const QString testInputString = QStringLiteral("This data should just be put in a paragraph.");
@@ -470,22 +463,6 @@ void TextHandlerTest::receiveRichPlainUrl()
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringMxId);
}
// Test that user pill is add to an emote message.
// N.B. The second message in the test timeline is marked as an emote.
void TextHandlerTest::receiveRichEmote()
{
auto event = room->messageEvents().at(1).get();
auto author = room->user(event->senderId());
const QString testInputString = QStringLiteral("This is an emote.");
const QString testOutputString = QStringLiteral("* <a href=\"https://matrix.to/#/@example:example.org\" style=\"color:")
+ Utils::getUserColor(author->hueF()).name() + QStringLiteral("\">@example:example.org</a> This is an emote.");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, event), testOutputString);
}
void TextHandlerTest::receiveRichEdited_data()
{
QTest::addColumn<QString>("testInputString");
@@ -494,9 +471,6 @@ void TextHandlerTest::receiveRichEdited_data()
QTest::newRow("basic") << QStringLiteral("Edited") << QStringLiteral("Edited <span style=\"color:#000000\">(edited)</span>");
QTest::newRow("multiple paragraphs") << QStringLiteral("<p>Edited</p>\n<p>Edited</p>")
<< QStringLiteral("<p>Edited</p>\n<p>Edited <span style=\"color:#000000\">(edited)</span></p>");
QTest::newRow("blockquote")
<< QStringLiteral("<blockquote>Edited</blockquote>")
<< QStringLiteral("<blockquote><table><tr><td>\u201CEdited\u201D</td></tr></table></blockquote><p> <span style=\"color:#000000\">(edited)</span></p>");
}
void TextHandlerTest::receiveRichEdited()
@@ -507,7 +481,8 @@ void TextHandlerTest::receiveRichEdited()
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, room->messageEvents().at(2).get()), testOutputString);
const auto event = eventCast<const Quotient::RoomMessageEvent>(room->messageEvents().at(2).get());
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, event, false, event->isReplaced()), testOutputString);
}
void TextHandlerTest::receiveLineSeparator()
@@ -526,5 +501,44 @@ void TextHandlerTest::receiveRichCodeUrl()
QCOMPARE(testTextHandler.handleRecieveRichText(), input);
}
void TextHandlerTest::componentOutput_data()
{
QTest::addColumn<QString>("testInputString");
QTest::addColumn<QList<MessageComponent>>("testOutputComponents");
QTest::newRow("multiple paragraphs") << QStringLiteral("<p>Text</p>\n<p>Text</p>")
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
QTest::newRow("code") << QStringLiteral("<p>Text</p>\n<pre><code class=\"language-html\">Some code\n</code></pre>")
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
MessageComponent{MessageComponentType::Code,
QStringLiteral("Some code"),
QVariantMap{{QStringLiteral("class"), QStringLiteral("HTML")}}}};
QTest::newRow("quote") << QStringLiteral("<p>Text</p>\n<blockquote>\n<p>blockquote</p>\n</blockquote>")
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
MessageComponent{MessageComponentType::Quote, QStringLiteral("\"blockquote\""), {}}};
QTest::newRow("no tag first paragraph") << QStringLiteral("Text\n<p>Text</p>")
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
QTest::newRow("no tag last paragraph") << QStringLiteral("<p>Text</p>\nText")
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
QTest::newRow("inline code") << QStringLiteral("<p><code>https://kde.org</code></p>\n<p>Text</p>")
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("<code>https://kde.org</code>"), {}},
MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
QTest::newRow("inline code single block") << QStringLiteral("<code>https://kde.org</code>")
<< QList<MessageComponent>{
MessageComponent{MessageComponentType::Text, QStringLiteral("<code>https://kde.org</code>"), {}}};
}
void TextHandlerTest::componentOutput()
{
QFETCH(QString, testInputString);
QFETCH(QList<MessageComponent>, testOutputComponents);
TextHandler testTextHandler;
QCOMPARE(testTextHandler.textComponents(testInputString), testOutputComponents);
}
QTEST_MAIN(TextHandlerTest)
#include "texthandlertest.moc"

View File

@@ -59,7 +59,7 @@
<summary xml:lang="fi">Keskustelu ystäviesi kanssa Matrixissa</summary>
<summary xml:lang="fr">Discuter avec vos ami(e)s sur le réseau Matrix</summary>
<summary xml:lang="gl">Charle coas súas amizades en Matrix.</summary>
<summary xml:lang="ia">Starta Conversation conntu amicos sur matrix</summary>
<summary xml:lang="ia">Starta Conversation con tu amicos sur matrix</summary>
<summary xml:lang="it">Conversa con i tuoi contatti su matrix</summary>
<summary xml:lang="ka">ესაუბრეთ მეგობრებს Matrix-ზე</summary>
<summary xml:lang="ko">Matrix를 사용하여 친구들과 대화하기</summary>
@@ -69,39 +69,26 @@
<summary xml:lang="sl">Klepet z vašimi prijatelji na matrixu</summary>
<summary xml:lang="sv">Chatta med dina vänner på Matrix</summary>
<summary xml:lang="ta">மேட்ரிக்ஸு மூலம் உங்கள் நண்பர்களிடம் பேசலாம்</summary>
<summary xml:lang="tr">Matrix'te arkadaşlarınızla sohbet edin</summary>
<summary xml:lang="tr">Matrixte arkadaşlarınızla sohbet edin</summary>
<summary xml:lang="uk">Спілкуйтеся з вашими друзями у matrix</summary>
<summary xml:lang="x-test">xxChat with your friends on matrixxx</summary>
<summary xml:lang="zh-CN">在 Matrix 上与朋友聊天</summary>
<summary xml:lang="zh-TW">在 Matrix 上與您的朋友聊天</summary>
<description>
<p>NeoChat is a client for Matrix, the decentralized communication protocol for instant messaging. It allows you to send text messages, videos and audio files to your family, colleagues and friends. It uses KDE frameworks and most notably Kirigami
to provide a convergent experience across multiple platforms.</p>
<p xml:lang="ar">نيوتشات هو عميل ماتركس Matrix، (ميفاق الاتصال اللامركزي للمراسلة الفورية). يتيح لك نيوتشات إرسال رسائل نصية ومقاطع فيديو وملفات صوتية إلى عائلتك وزملائك وأصدقائك. يستخدم أطر عمل كيدي وأبرزها Kirigami لتوفير تجربة متقاربة عبر منصات متعددة.</p>
<p xml:lang="ca">El NeoChat és un client de Matrix, el protocol descentralitzat de comunicacions de missatgeria instantània. Permet enviar missatges de text, fitxers de vídeo i d'àudio a la família, col·legues i amics. Fa servir els Frameworks de KDE i, sobretot, el Kirigami per a proporcionar una experiència convergent a través de diverses plataformes.</p>
<p xml:lang="ca-valencia">NeoChat és un client de Matrix, el protocol descentralitzat de comunicacions de missatgeria instantània. Permet enviar missatges de text, fitxers de vídeo i d'àudio a la família, col·legues i amics. Utilitza els Frameworks de KDE i, sobretot, Kirigami per a proporcionar una experiència convergent a través de diverses plataformes.</p>
<p xml:lang="en-GB">NeoChat is a client for Matrix, the decentralised communication protocol for instant messaging. It allows you to send text messages, videos and audio files to your family, colleagues and friends. It uses KDE frameworks and most notably Kirigami to provide a convergent experience across multiple platforms.</p>
<p xml:lang="eo">NeoChat estas kliento por Matrix, la malcentra komunikoprotokolo por tuja mesaĝado. Ĝi ebligas al vi sendi tekstmesaĝojn, filmetojn kaj sondosierojn al via familio, kolegoj kaj amikoj. Ĝi uzas KDE-framojn kaj precipe Kirigami por disponigi konverĝan sperton tra pluraj platformoj.</p>
<p xml:lang="es">NeoChat es un cliente para Matrix, el protocolo de comunicaciones descentralizado para mensajería instantánea. Le permite enviar mensajes de texto, vídeos y archivos de sonido a su familia, compañeros de trabajo y amigos. Usa la infraestructura de KDE y, en particular, Kirigami para proporcionar una experiencia convergente en muchas plataformas.</p>
<p xml:lang="eu">NeoChat «Matrix»erako, bat-bateko mezularitzarako komunikazio deszentralizatuko protokolorako, bezero bat da. Zure sendiari, kide eta lagunei testu mezuak, bideo eta audio fitxategiak bidaltzeko aukera ematen dizu. «KDE Frameworks» eta bereziki «Kirigami» erabiltzen ditu plataforma anitzen artean esperientzia konbergente bat eskaintzeko.</p>
<p xml:lang="fi">NeoChat on asiakassovellus Matrixille, hajautetulle pikaviestinyhteyskäytännölle. Sillä voi lähettää teksti-, video- ja ääniviestejä perheelle, tutuille ja ystäville. Se käyttää KDE-kehystä ja erityisesti Kirigamia tuottaakseen mukautuvan monialustaisen käyttökokemuksen.</p>
<p xml:lang="fr">NeoChat est un client pour le protocole Matrix, un protocole décentralisé de communications pour messagerie instantané. Il vous permet d'envoyer des messages de texte, des vidéos et des fichiers audio à votre famille, vos collègues et vos amis. Il utilise les environnements de développement et plus précisément Kirigami pour fournir une expérience convergente sur plusieurs plate-formes. </p>
<p xml:lang="gl">NeoChat é un cliente para Matrix, o protocolo de comunicación descentralizada para mensaxaría instantánea. Podes enviar mensaxes de texto, vídeos e ficheiros de son á túa familia, colegas e amizades. Usas infraestruturas de KDE e principalmente Kirigami para proporcionar unha experiencia de uso converxente para varias plataformas.</p>
<p xml:lang="hu">A NeoChat egy kliens a Matrixhoz, az azonnali üzenetküldés decentralizált komunikációs protokolljához.. Szöveges üzeneteket, videókat és hangfájlokat küldhet családjának, kollégáinak és barátainak. A KDE keretrendszert használja, a Kirigaminak köszönhetően konvergens élményt nyújt több platformon is.</p>
<p xml:lang="ia">NeoChat es un cliente per Matrix, le protocollo de communication decentralisate per messager instantanee. Illo te permitte inviar messager de texto, files de video e audio a tu familia, collegas e amicos usante. Illo usa KDE frameworks e super toto Kirigamii forni un experientia convergente trans platteforme multiple.</p>
<p xml:lang="it">NeoChat è un client per Matrix, il protocollo di comunicazione decentralizzato per la messaggistica istantanea. Ti consente di inviare messaggi di testo, video e file audio a familiari, colleghi e amici. Utilizza i framework KDE e in particolare Kirigami per fornire un'esperienza convergente su più piattaforme.</p>
<p xml:lang="ka">NeoChat არის Matrix კლიენტი. ის საშუალებას გაძლევთ გაგზავნოთ ტექსტური შეტყობინებები, ვიდეოები და აუდიო ფაილები თქვენს ოჯახს, კოლეგებსა და მეგობრებს მატრიქსის პროტოკოლის გამოყენებით.</p>
<p xml:lang="ko">NeoChat은 분산형 인스턴트 메시징 통신 프로토콜인 Matrix 클라이언트입니다. 가족, 동료, 친구에게 텍스트 메시지, 동영상, 오디오 파일을 전송할 수 있습니다. KDE 프레임워크와 Kirigami를 사용하여 다양한 플랫폼에서 일관적인 사용자 경험을 제공합니다.</p>
<p xml:lang="nl">NeoChat is een client voor Matrix, het gedecentraliseerde communicatieprotocol voor instant messages. Het biedt u het verzenden van tekstberichten, video's en geluidsbestanden naar uw familie, collega's en vrienden. Het gebruik KDE frameworks en het meest opmerkelijk Kirigami om een convergente ervaring te leveren op meerdere platforms.</p>
<p xml:lang="nn">NeoChat er ein klient for Matrix, ein protokoll for desentralisert kommunikasjon. Du kan utveksla tekst, lyd og videoar med kollegaar, vennar og familie. Programmet brukar KDE Frameworks og Kirigami for å gje ei brukarflate tilpassa ulike plattformer.</p>
<p xml:lang="pl">NeoChat jest programem do Matriksa, protokołu rozproszonego porozumiewania się w czasie rzeczywistym. Umożliwia wysyłanie wiadomości tekstowych, filmów oraz dźwięku do twojej rodziny, znajomych oraz przyjaciół. Używa szkieletów KDE i głównie Kirigami, aby zapewnić spójne wrażenia na wielu platformach</p>
<p xml:lang="pt">O NeoChat é um cliente do Matrix. O mesmo permite-lhe enviar mensagens de texto, ficheiros de vídeo e áudio para a sua família, colegas e amigos com o protocolo Matrix. Usa as plataformas do KDE, e principalmente o Kirigami, para oferecer uma experiência convergente entre várias plataformas.</p>
<p xml:lang="sl">Neochat je odjemalec za Matrix, decentralizirani komunikacijski protokol za takojšnje sporočanje. Omogoča vam pošiljanje besedilnih sporočil, videoposnetkov in zvočnih datotek svoji družini, sodelavcem in prijateljem. Uporablja okvire ogrodje KDE frameworks in predvsem Kirigami za zagotavljanje konvergentne izkušnje na več platformah.</p>
<p xml:lang="sv">NeoChat är en klient för Matrix, det decentraliserade kommunikationsprotokollet för direktmeddelanden. Den låter dig skicka textmeddelanden, videor och ljudfiler till din familj, kollegor och vänner. Den använder KDE Ramverk, i synnerhet Kirigami, för att tillhandahålla en konvergent upplevelse på flera plattformar.</p>
<p xml:lang="tr">NeoChat, anlık iletileşme için merkezi olmayan iletişim protokolü olan Matrix için bir istemcidir. Ailenize, iş arkadaşlarınıza ve arkadaşlarınıza metin iletiler, videolar ve ses dosyaları göndermenize olanak tanır. Birden çok platformda yakınsak bir deneyim sağlamak için KDE Frameworks ve en önemlilerinden Kirigami'yi kullanır.</p>
<p xml:lang="uk">NeoChat — клієнт Matrix, децентралізованого протоколу спілкування для миттєвого обміну повідомленнями. За його допомогою ви можете надсилати текстові повідомлення, відео та звукові файли вашій родин, колегами та друзям. У програмі використано бібліотеки KDE, зокрема Kirigami, для надання однорідного середовища на декількох програмних та апаратних платформах.</p>
<p xml:lang="x-test">xxNeoChat is a client for Matrix, the decentralized communication protocol for instant messaging. It allows you to send text messages, videos and audio files to your family, colleagues and friends. It uses KDE frameworks and most notably Kirigami to provide a convergent experience across multiple platforms.xx</p>
<p xml:lang="zh-TW">NeoChat 是去中心化即時通訊協定 Matrix 的一個用戶端。它讓您可以傳送文字訊息、影片、音訊檔案給您的家人、同事或朋友。NeoChat 使用 KDE frameworks尤其是 Kirigami來提供跨平台的響應式體驗。</p>
<p>NeoChat is a chat app that lets you take full advantage of the Matrix network. It provides you with a secure way to send text messages, videos and audio files to your family, colleagues and friends.</p>
<p xml:lang="ca">El NeoChat és una aplicació de xat que us permet aprofitar plenament la xarxa Matrix. Proporciona una manera segura d'enviar missatges de text, vídeos i arxius d'àudio a la vostra família, companys i amics.</p>
<p xml:lang="ca-valencia">NeoChat és una aplicació de xat que us permet aprofitar plenament la xarxa Matrix. Proporciona una manera segura d'enviar missatges de text, vídeos i arxius d'àudio a la vostra família, companys i amics.</p>
<p xml:lang="es">NeoChat es una aplicación de chat que le permite aprovechar al máximo la red Matrix. Le proporciona un modo seguro de enviar mensajes de texto, vídeos y archivos de sonido a su familia, colegas y amigos.</p>
<p xml:lang="eu">NeoChat, Matrix sarearen abantaila guztiei probetsua ateratzeko aukera ematen dizun berriketa aplikaizo bat da. Zure familiari, kideei eta lagunei testu mezuak, bideoak eta audio fitxategiak era seguruan bidaltzeko aukera ematen dizu.</p>
<p xml:lang="ia">NeoChat es un app de conversation que te permitte prender avantage plen del rete Matrix. Il te forni un modo secur de inviar messages de texto, videos e files audio a tui familia, collegas e amicos.</p>
<p xml:lang="it">NeoChat è un'applicazione di chat che ti consente di sfruttare appieno la rete Matrix. Ti fornisce un modo sicuro per inviare messaggi di testo, video e file audio a familiari, colleghi e amici.</p>
<p xml:lang="ka">NeoChat ჩატის აპია, რომელიც საშუალება გაძლევთ, Matrix-ის ქსელის საშუალებები ბოლომდე გამოიყენოთ. ის გაძლევთ უსაფრთხო გზას, გააგზავნოთ ტექსტური შეტყობინებები, ვიდეოებ და აუდიოფაილები თქვენს ოჯახთან, კოლეგებთან და მეგობრებთან.</p>
<p xml:lang="nl">NeoChat is een chat-toepassing die u het volledige voordeel van het Matrix-netwerk laat genieten. Het levert u op een veilige manier tekstberichten, video's en geluidsbestanden naar uw familie, collega's en vrienden te verzenden.</p>
<p xml:lang="pl">NoeChat to aplikacja do rozmów, która umożliwia wykorzystanie wszystkich możliwości Matriksa. Umożliwia wysyłanie wiadomości tekstowych, filmów i dźwięków w bezpieczny sposób do twojej rodziny, kolegów i przyjaciół.</p>
<p xml:lang="sl">NeoChat je aplikacija za klepet, ki vam omogoča, da v celoti izkoristite omrežje Matrix. Zagotavlja vam varen način za pošiljanje besedilnih sporočil, videoposnetkov in zvočnih datotek vaši družini, sodelavcem in prijateljem.</p>
<p xml:lang="tr">NeoChat, Matrix ağının tüm özelliklerini kullanan bir sohbet uygulamasıdır. Ailenize, arkadaşlarınıza ve iş arkadaşlarınıza metin iletileri, ses ve video dosyaları göndermenin kolay bir yolunu sunar.</p>
<p xml:lang="uk">NeoChat є програмою для спілкування, за допомогою якої ви можете скористатися усіма перевагами мережі Matrix. За її допомогою ви можете безпечно надсилати текстові повідомлення, відео та звукові файли вашим родичам, колегам та друзям.</p>
<p xml:lang="x-test">xxNeoChat is a chat app that lets you take full advantage of the Matrix network. It provides you with a secure way to send text messages, videos and audio files to your family, colleagues and friends.xx</p>
<p>NeoChat aims to be a fully featured application for the Matrix specification. As such everything in the current stable specification with the notable exceptions of VoIP, threads and some aspects of End-to-End Encryption are supported. There are a few other smaller omissions due to the fact that the Matrix spec is constantly evolving but the aim remains to provide eventual support for the entire spec.</p>
<p xml:lang="ar">يهدف نيوتشات إلى أن يكون تطبيقًا كامل الميزات لمواصفات ماتركس. على هذا النحو يتم دعم كل شيء في المواصفات المستقرة الحالية مع الاستثناءات الملحوظة لـ VoIP والخيوط وبعض جوانب التشفير من طرف إلى طرف. هناك عدد قليل من الإغفالات الصغيرة الأخرى بسبب حقيقة أن مواصفات ماتركس تتطور باستمرار ، ولكن يبقى الهدف توفير الدعم النهائي للمواصفات بأكملها.</p>
<p xml:lang="ca">NeoChat pretén ser una aplicació amb totes les característiques per a l'especificació de Matrix. Com a tal, s'ha implementat tota l'especificació actual estable amb les notables excepcions de la VoIP, fils i alguns aspectes de l'encriptatge d'extrem a extrem. Hi ha algunes altres omissions més petites a causa del fet que l'especificació de Matrix està evolucionant constantment, però l'objectiu segueix sent proporcionar suport eventual per a tota l'especificació.</p>
@@ -115,7 +102,7 @@ to provide a convergent experience across multiple platforms.</p>
<p xml:lang="gl">NeoChat pretende ser unha aplicación completa para a especificación de Matrix. Coas excepcións de VoIP, conversas fiadas e algúns aspectos da cifraxe de extremo a extremo, a versión estábel segue as especificacións. Existen algunhas outras pequenas omisións debido ao feito de que Matrix está en continua evolución pero a intención é implementar a especificación completa.</p>
<p xml:lang="ia">NeoChat aspira a esser un application plenemente eminente per le specification de Matrix. Tal como omne cosas in le specification currentemente stabile con le exceptiones notabile de VOIP, threads e alcun aspectos del cryptation End-to-End es supportate. Il ha ltere pauc omissiones, debite al facto que le specification de Matrix es in evolution constante ma le aspiration remane a fornir supporto eventual per le integre specification.</p>
<p xml:lang="it">NeoChat mira ad essere un'applicazione completa per le specifiche Matrix. Pertanto, sono supportati tutti gli elementi dell'attuale specifica stabile con le notevoli eccezioni di VoIP, conversazioni e alcuni aspetti della cifratura end-to-end. Ci sono alcune altre piccole omissioni dovute al fatto che le specifiche Matrix sono in continua evoluzione, ma l'obiettivo rimane quello di fornire un eventuale supporto per l'intera specifica.</p>
<p xml:lang="ka">NeoChat-ი მიზნად ისახავს Matrix სპეციფიკაციის სრული განხორციელება ჰქონდეს. როგორც ასეთი, ყველაფერი მიმდინარე სპეციფიკაციიდან, VoIP-ის, ძაფებისა და გამჭოლი დაშიფვრის ზოგიერთი ასპექტის გარდა, მხარდაჭერილია. შეძლება ასევე იყოს მცირე ლაფსუსებიც იმის გამო, რომ Matrix-ის სპეციფიკაცია მუდმივად ვითარდება, მაგრამ ჩვენი მიზანი მისი სრული მხარდაჭერაა.</p>
<p xml:lang="ka">NeoChat მიზნად ისახავს Matrix სპეციფიკაციის სრული განხორციელება ჰქონდეს. როგორც ასეთი, ყველაფერი მიმდინარე სპეციფიკაციიდან, VoIP-ის, ძაფებისა და გამჭოლი დაშიფვრის ზოგიერთი ასპექტის გარდა, მხარდაჭერილია. შეძლება ასევე იყოს მცირე ლაფსუსებიც იმის გამო, რომ Matrix-ის სპეციფიკაცია მუდმივად ვითარდება, მაგრამ ჩვენი მიზანი მისი სრული მხარდაჭერაა.</p>
<p xml:lang="ko">NeoChat은 Matrix 표준을 따르는 프로그램을 목표로 합니다. 현재 안정 버전의 표준에서 제공하는 기능의 대부분을 지원하며, VoIP, 스레드, 일부 종단간 암호화와 같은 기능은 아직 지원하지 않습니다. Matrix 표준은 계속하여 진화 중이기 때문에 일부 기능이 빠져 있을 수도 있지만 장기적으로는 전체 표준을 지원하는 것이 목표입니다.</p>
<p xml:lang="nl">NeoChat richt zich op het volledig bieden van alle mogelijkheden van de Matrix-specificatie. Alles in de huidige stabiele specificatie met merkbare uitzondering van VoIP, gekoppelde discussies en sommige aspecten van eind-tot-eind versleuteling worden ondersteund. Er zijn een paar andere kleinere omissies vanwege het feit dat de Matrix specificatie constant evolueert maar het doel blijft het eventueel bieden van ondersteuning van de gehele specificatie.</p>
<p xml:lang="nn">NeoChat har som mål å støtta all funksjonalitet i Matrix-spesifikasjonen. Førebels er alt i den gjeldande stabile spesifikasjonen støtta, med unntak av VoIP, trådar og nokre delar av ende-til-kryptering. Det finst òg andre småting som ikkje er støtta, sidan Matrix-spesifikasjon er i stadig endring, men målet er altså støtte for alt.</p>
@@ -123,7 +110,7 @@ to provide a convergent experience across multiple platforms.</p>
<p xml:lang="pt">O NeoChat pretende ser uma aplicação completa para a especificação do Matrix. Como tal, tudo o que existe na especificação estável actual, com as notáveis excepções do VoIP, tópicos e alguns aspectos da Encriptação Ponto-a-Ponto, são suportados. Existem mais algumas omissões, devido ao facto que a norma do Matrix está em constante evolução, mas o objectivo continua a ser oferecer o suporte eventual para a norma por inteiro.</p>
<p xml:lang="sl">Neochat cilja, da bi bila popolna aplikacija po specifikaciji Matrixa. Kot takšna vsebuje vse v trenutni stabilni specifikaciji z pomembnimi izjemami pri VoIP, nitih in nekaterih vidikov šifriranja od konca do konca. Obstaja nekaj drugih manjših opustitev zaradi dejstva, da se specifikacija Matrix nenehno razvija, vendar cilj ostaja zagotoviti morebitno podporo celotni specifikaciji.</p>
<p xml:lang="sv">NeoChat har som mål att vara ett fullständigt program enligt Matrix-specifikationen. Som sådant stöds allt i den nuvarande stabila specifikationen, med de nämnvärda undantagen VoIP, trådar och några aspekter av kryptering hela vägen. Det finns några ytterligare utelämnanden på grund av att Matrix-specifikationen hela tiden utvecklas, men målet förblir att till slut erbjuda stöd för hela specifikationen.</p>
<p xml:lang="tr">NeoChat, Matrix belirtimi için tam özellikli bir uygulama olmayı hedefler. Bu nedenle; VoIP, ileti zincirleri ve Uçtan Uca Şifreleme'nin bazı yönleri gibi dikkate değer istisnalar dışında var olan kararlı belirtimdeki her şey desteklenir. Matrix belirtiminin sürekli gelişmesi nedeniyle birkaç küçük eksiklik daha var; ancak amaç tüm belirtim için nihai destek sağlamak olmayı sürdürüyor.</p>
<p xml:lang="tr">NeoChat, Matrix belirtimi için tam özellikli bir uygulama olmayı hedefler. Bu nedenle; VoIP, ileti zincirleri ve Uçtan Uca Şifrelemenin bazı yönleri gibi dikkate değer istisnalar dışında var olan kararlı belirtimdeki her şey desteklenir. Matrix belirtiminin sürekli gelişmesi nedeniyle birkaç küçük eksiklik daha var; ancak amaç tüm belirtim için nihai destek sağlamak olmayı sürdürüyor.</p>
<p xml:lang="uk">Метою створення NeoChat є повноцінна реалізація програми для специфікації Matrix. Як наслідок, реалізовано усе у поточній стабільній специфікації, окрім голосового інтернет-зв'язку, потоків та деяких аспектів міжвузлового шифрування. Є також декілька інших незначних прогалин через те, що специфікація Matrix постійно змінюється, але метою лишається повна підтримка специфікації.</p>
<p xml:lang="x-test">xxNeoChat aims to be a fully featured application for the Matrix specification. As such everything in the current stable specification with the notable exceptions of VoIP, threads and some aspects of End-to-End Encryption are supported. There are a few other smaller omissions due to the fact that the Matrix spec is constantly evolving but the aim remains to provide eventual support for the entire spec.xx</p>
<p xml:lang="zh-TW">NeoChat 以完整支援 Matrix 標準為目標,因此目前穩定版標準除了 VoIP、對話串與端對端加密的某些部分以外的所有部分都有支援。其他部分還有一些較小的不支援的部分這是因為 Matrix 標準隨時都在改進,但目標仍然時最終提供整個標準的完整支援。</p>
@@ -176,7 +163,7 @@ to provide a convergent experience across multiple platforms.</p>
<li xml:lang="sl">Polls - MSC3381</li>
<li xml:lang="sv">Polls - MSC3381</li>
<li xml:lang="ta">வாக்கெடுப்புகள் - MSC3381</li>
<li xml:lang="tr">Anketler - MSC3381</li>
<li xml:lang="tr">Anketler MSC3381</li>
<li xml:lang="uk">Опитування - MSC3381</li>
<li xml:lang="x-test">xxPolls - MSC3381xx</li>
<li xml:lang="zh-TW">投票 - MSC3381</li>
@@ -202,7 +189,7 @@ to provide a convergent experience across multiple platforms.</p>
<li xml:lang="sl">Sticker Packs - MSC2545</li>
<li xml:lang="sv">Sticker Packs - MSC2545</li>
<li xml:lang="ta">ஒட்டி தொகுப்புகள் - MSC2545</li>
<li xml:lang="tr">Yapışkan Paketleri - MSC2545</li>
<li xml:lang="tr">Yapışkan Paketleri MSC2545</li>
<li xml:lang="uk">Пакунки наліпок - MSC2545</li>
<li xml:lang="x-test">xxSticker Packs - MSC2545xx</li>
<li xml:lang="zh-TW">貼圖包 - MSC2545</li>
@@ -228,7 +215,7 @@ to provide a convergent experience across multiple platforms.</p>
<li xml:lang="sl">Location Events - MSC3488</li>
<li xml:lang="sv">Location Events - MSC3488</li>
<li xml:lang="ta">இட நிகழ்வுகள் - MSC3488</li>
<li xml:lang="tr">Konum Etkinlikleri - MSC3488</li>
<li xml:lang="tr">Konum Etkinlikleri MSC3488</li>
<li xml:lang="uk">Місцеві зустрічі - MSC3488</li>
<li xml:lang="x-test">xxLocation Events - MSC3488xx</li>
<li xml:lang="zh-TW">位置事件 - MSC3488</li>
@@ -236,9 +223,17 @@ to provide a convergent experience across multiple platforms.</p>
</description>
<url type="homepage">https://apps.kde.org/neochat</url>
<url type="bugtracker">https://bugs.kde.org/enter_bug.cgi?product=NeoChat</url>
<url type="vcs-browser">https://invent.kde.org/network/neochat</url>
<url type="contact">https://go.kde.org/matrix/#/#neochat:kde.org</url>
<url type="donation">https://kde.org/community/donations/?app=neochat</url>
<url type="contribute">https://community.kde.org/Get_Involved/</url>
<categories>
<category>Network</category>
</categories>
<keywords>
<keyword>Matrix</keyword>
<keyword>Kirigami</keyword>
</keywords>
<developer>
<id>kde.org</id>
<name>The KDE Community</name>
@@ -258,11 +253,57 @@ to provide a convergent experience across multiple platforms.</p>
<launchable type="desktop-id">org.kde.neochat.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://cdn.kde.org/screenshots/neochat/application-mobile.png</image>
<image>https://cdn.kde.org/screenshots/neochat/application.png</image>
<caption>Main view with room list, chat, and room information</caption>
<caption xml:lang="ar">العرض الرئيسة مع قائمة الغرف والدردشات و معلومات الغرفة</caption>
<caption xml:lang="ca">Vista principal amb la llista de sales, xats i informació de les sales</caption>
<caption xml:lang="ca-valencia">Vista principal amb la llista de sales, xats i informació de les sales</caption>
<caption xml:lang="eo">Ĉefa vido kun ĉambra listo, babilejo kaj ĉambra informo</caption>
<caption xml:lang="es">Vista principal con la lista de salas, chat e información de la sala</caption>
<caption xml:lang="eu">Ikuspegi nagusia gela-zerrenda, berriketa, eta gelako informazioarekin</caption>
<caption xml:lang="fi">Päänäkymä, jossa huoneluettelo, keskustelu ja huoneen tiedot</caption>
<caption xml:lang="fr">Vue principale avec la liste des salons ainsi que des informations sur les salons et forums de discussions</caption>
<caption xml:lang="gl">Vista principal coa lista de salas, a charla, e información da sala.</caption>
<caption xml:lang="ia">Vista principal con lista de sala, chat e information de sala</caption>
<caption xml:lang="it">Vista principale con elenco delle stanze, chat e informazioni sulla stanza</caption>
<caption xml:lang="ka">მთავარი ხედი სურათების სიით, ჩატით და ოთახის ინფორმაციით</caption>
<caption xml:lang="ko">대화방 목록, 채팅, 대화방 정보가 표시된 주 보기</caption>
<caption xml:lang="nl">Hoofdweergave met lijst met rooms, chat en roominformatie</caption>
<caption xml:lang="nn">Hovudvising med romliste, pratevindauge og rominformasjon</caption>
<caption xml:lang="pl">Główny widok z wykazem pokojów, rozmowami i szczegółami pokojów</caption>
<caption xml:lang="pt">A área principal com a lista de salas e com informações sobre a conversa e a sala</caption>
<caption xml:lang="sl">Glavni pogled s seznamom sob, klepetom in informacijami o sobah</caption>
<caption xml:lang="sv">Huvudvy med rumslista, chatt, och rumsinformation</caption>
<caption xml:lang="ta">அரங்குப்பட்டியல், உரையாடல், மற்றும் அரங்குவிவரங்களைக் கொண்டுள்ள பிரதான காட்சி</caption>
<caption xml:lang="tr">Oda listesini, sohbet penceresini ve oda bilgisini gösteren ana görünüm</caption>
<caption xml:lang="uk">Головна панель із списком кімнат, спілкуванням та даними щодо кімнати</caption>
<caption xml:lang="x-test">xxMain view with room list, chat, and room informationxx</caption>
<caption xml:lang="zh-TW">主頁面,包含聊天室列表、聊天內容,與聊天室資訊</caption>
</screenshot>
<screenshot type="default">
<image>https://cdn.kde.org/screenshots/neochat/application.png</image>
<image>https://cdn.kde.org/screenshots/neochat/spaces.png</image>
<caption>Discover new communities with Matrix Spaces</caption>
<caption xml:lang="ca">Descobriu comunitats noves amb els espais de Matrix</caption>
<caption xml:lang="ca-valencia">Descobriu comunitats noves amb els espais de Matrix</caption>
<caption xml:lang="es">Descubra nuevas comunidades con los espacios de Matrix</caption>
<caption xml:lang="eu">Ezagutu komunitate berriak Matrixeko Tokiak erabiliz</caption>
<caption xml:lang="ia">Discoperi nove communitate con Matrix Spaces (Spatios de Matrix)</caption>
<caption xml:lang="it">Scopri nuove comunità con Matrix Spaces</caption>
<caption xml:lang="ka">აღმოაჩინეთ ახალი საზოგადოებები Matrix Spaces-თან ერთად</caption>
<caption xml:lang="nl">Ontdek nieuwe gemeenschappen met Matrix-ruimten</caption>
<caption xml:lang="pl">Odkrywaj nowe społeczności w Przestrzeniach Matriksa</caption>
<caption xml:lang="sl">Odkrijte nove skupnosti z Matrix Spaces</caption>
<caption xml:lang="tr">Matrix Alanlar ile yeni topluluklar keşfedin</caption>
<caption xml:lang="uk">Пошук нових спільнот за допомогою Matrix Spaces</caption>
<caption xml:lang="x-test">xxDiscover new communities with Matrix Spacesxx</caption>
</screenshot>
<!--
Currently invalid. See https://github.com/ximion/appstream/issues/611
<screenshot type="default" environment="plasma-mobile">
<image>https://cdn.kde.org/screenshots/neochat/neochat-1.png</image>
<caption>List of chats on mobile</caption>
</screenshot>
-->
<screenshot environment="windows">
<image>https://cdn.kde.org/screenshots/neochat/NeoChat-Windows-Timeline.png</image>
<caption>Main view with room list, chat, and room information</caption>
@@ -490,4 +531,8 @@ to provide a convergent experience across multiple platforms.</p>
<url>https://carlschwan.eu/2020/12/23/announcing-neochat-1.0-the-kde-matrix-client/</url>
</release>
</releases>
<branding>
<color type="primary" scheme_preference="light">#a6e4f3</color>
<color type="primary" scheme_preference="dark">#235670</color>
</branding>
</component>

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

@@ -161,6 +161,10 @@ add_library(neochat STATIC
enums/neochatroomtype.h
models/sortfilterroomtreemodel.cpp
models/sortfilterroomtreemodel.h
mediamanager.cpp
mediamanager.h
models/statekeysmodel.cpp
models/statekeysmodel.h
)
qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
@@ -291,7 +295,6 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
qml/RoomInformation.qml
qml/RoomMedia.qml
qml/ChooseRoomDialog.qml
qml/ShareAction.qml
qml/SpaceHomePage.qml
qml/SpaceHierarchyDelegate.qml
qml/RemoveChildDialog.qml
@@ -321,11 +324,29 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
qml/LoadComponent.qml
qml/RecommendedSpaceDialog.qml
qml/RoomTreeSection.qml
qml/DelegateContextMenu.qml
qml/ShareDialog.qml
qml/FeatureFlagPage.qml
qml/IgnoredUsersDialog.qml
qml/AccountData.qml
qml/StateKeys.qml
qml/CodeComponent.qml
qml/QuoteComponent.qml
RESOURCES
qml/confetti.png
qml/glowdot.png
)
if(UNIX)
qt_target_qml_sources(neochat QML_FILES qml/ShareAction.qml)
else()
set_source_files_properties(qml/ShareActionStub.qml PROPERTIES
QT_RESOURCE_ALIAS qml/ShareAction.qml
)
qt_target_qml_sources(neochat QML_FILES qml/ShareActionStub.qml)
endif()
configure_file(config-neochat.h.in ${CMAKE_CURRENT_BINARY_DIR}/config-neochat.h)
if(WIN32)

View File

@@ -26,6 +26,7 @@
#include <Quotient/qt_connection_util.h>
#include "neochatconfig.h"
#include "neochatconnection.h"
#include "neochatroom.h"
#include "notificationsmanager.h"
#include "proxycontroller.h"
@@ -37,6 +38,14 @@
#include "trayicon_sni.h"
#endif
#if QT_VERSION < QT_VERSION_CHECK(6, 6, 0)
#ifndef Q_OS_ANDROID
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusMessage>
#endif
#endif
bool testMode = false;
using namespace Quotient;
@@ -102,7 +111,9 @@ Controller::Controller(QObject *parent)
NotificationsManager::instance().handleNotifications(connection);
});
connectSingleShot(connection, &NeoChatConnection::syncDone, this, [this, connection] {
connection->setupPushNotifications(m_endpoint);
if (!m_endpoint.isEmpty()) {
connection->setupPushNotifications(m_endpoint);
}
});
}
oldAccountCount = m_accountRegistry.size();
@@ -118,7 +129,9 @@ Controller::Controller(QObject *parent)
}
});
connector->registerClient(i18n("Receiving push notifications"));
connector->registerClient(
i18nc("The reason for using push notifications, as in: '[Push notifications are used for] Receiving notifications for new messages'",
"Receiving notifications for new messages"));
m_endpoint = connector->endpoint();
#endif
@@ -145,6 +158,7 @@ void Controller::addConnection(NeoChatConnection *c)
connect(c, &NeoChatConnection::loggedOut, this, [this, c] {
dropConnection(c);
});
connect(c, &NeoChatConnection::badgeNotificationCountChanged, this, &Controller::updateBadgeNotificationCount);
c->sync();
@@ -287,7 +301,14 @@ void Controller::setActiveConnection(NeoChatConnection *connection)
if (connection == m_connection) {
return;
}
m_connection = connection;
if (m_connection != nullptr) {
m_connection->refreshBadgeNotificationCount();
updateBadgeNotificationCount(m_connection, m_connection->badgeNotificationCount());
}
Q_EMIT activeConnectionChanged();
}
@@ -312,6 +333,36 @@ void Controller::listenForNotifications()
#endif
}
void Controller::updateBadgeNotificationCount(NeoChatConnection *connection, int count)
{
if (connection == m_connection) {
#if QT_VERSION < QT_VERSION_CHECK(6, 6, 0)
#ifndef Q_OS_ANDROID
// copied from Telegram desktop
const auto launcherUrl = "application://org.kde.neochat.desktop"_ls;
// Gnome requires that count is a 64bit integer
const qint64 counterSlice = std::min(count, 9999);
QVariantMap dbusUnityProperties;
if (counterSlice > 0) {
dbusUnityProperties["count"_ls] = counterSlice;
dbusUnityProperties["count-visible"_ls] = true;
} else {
dbusUnityProperties["count-visible"_ls] = false;
}
auto signal = QDBusMessage::createSignal("/com/canonical/unity/launcherentry/neochat"_ls, "com.canonical.Unity.LauncherEntry"_ls, "Update"_ls);
signal.setArguments({launcherUrl, dbusUnityProperties});
QDBusConnection::sessionBus().send(signal);
#endif // Q_OS_ANDROID
#else
qGuiApp->setBadgeNumber(count);
#endif // QT_VERSION_CHECK(6, 6, 0)
}
}
bool Controller::isFlatpak() const
{
#ifdef NEOCHAT_FLATPAK

View File

@@ -117,6 +117,7 @@ private:
private Q_SLOTS:
void invokeLogin();
void setQuitOnLastWindowClosed();
void updateBadgeNotificationCount(NeoChatConnection *connection, int count);
Q_SIGNALS:
void errorOccured(const QString &error, const QString &detail);

View File

@@ -37,6 +37,8 @@ public:
Image, /**< A message that is an image. */
Audio, /**< A message that is an audio recording. */
Video, /**< A message that is a video. */
Code, /**< A code section. */
Quote, /**< A quote section. */
File, /**< A message that is a file. */
Poll, /**< The initial event for a poll. */
Location, /**< A location event. */
@@ -104,4 +106,22 @@ public:
return MessageComponentType::Other;
}
/**
* @brief Return MessageComponentType for the given html tag.
*
* @param tag the tag name to return a type for.
*
* @sa Type
*/
static Type typeForTag(const QString &tag)
{
if (tag == QLatin1String("pre") || tag == QLatin1String("pre")) {
return Code;
}
if (tag == QLatin1String("blockquote")) {
return Quote;
}
return Text;
}
};

View File

@@ -29,6 +29,7 @@ public:
Deprioritized, /**< The room is set as low priority. */
Space, /**< The room is a space. */
AddDirect, /**< So we can show the add friend delegate. */
TypesCount, /**< Number of different types. */
};
Q_ENUM(Types);
@@ -40,7 +41,8 @@ public:
if (room->joinState() == Quotient::JoinState::Invite) {
return NeoChatRoomType::Invited;
}
if (room->isFavourite()) {
// HACK for the unit tests
if (room->isFavourite() || room->property("isFavorite").toBool()) {
return NeoChatRoomType::Favorite;
}
if (room->isLowPriority()) {

View File

@@ -232,6 +232,36 @@ bool EventHandler::isHidden()
return false;
}
Qt::TextFormat EventHandler::messageBodyInputFormat(const Quotient::RoomMessageEvent &event)
{
if (event.mimeType().name() == "text/plain"_ls) {
return Qt::PlainText;
} else {
return Qt::RichText;
}
}
QString EventHandler::rawMessageBody(const Quotient::RoomMessageEvent &event)
{
if (event.hasFileContent()) {
auto fileCaption = event.content()->fileInfo()->originalName;
if (fileCaption.isEmpty()) {
fileCaption = event.plainBody();
} else if (event.content()->fileInfo()->originalName != event.plainBody()) {
fileCaption = event.plainBody() + " | "_ls + fileCaption;
}
return fileCaption;
}
QString body;
if (event.hasTextContent() && event.content()) {
body = static_cast<const MessageEventContent::TextContent *>(event.content())->body;
} else {
body = event.plainBody();
}
return body;
}
QString EventHandler::getRichBody(bool stripNewlines) const
{
if (m_event == nullptr) {
@@ -445,7 +475,7 @@ QString EventHandler::getMessageBody(const RoomMessageEvent &event, Qt::TextForm
}
if (format == Qt::RichText) {
return textHandler.handleRecieveRichText(inputFormat, m_room, &event, stripNewlines);
return textHandler.handleRecieveRichText(inputFormat, m_room, &event, stripNewlines, event.isReplaced());
} else {
return textHandler.handleRecievePlainText(inputFormat, stripNewlines);
}

View File

@@ -137,6 +137,22 @@ public:
*/
bool isHidden();
/**
* @brief The input format of the body in the message.
*
* I.e. if the message has only a body the format will be Qt::PlainText, if it
* has a formatted body it will be Qt::RichText.
*/
static Qt::TextFormat messageBodyInputFormat(const Quotient::RoomMessageEvent &event);
/**
* @brief Output a string for the room message content without any formatting.
*
* This is the content of the formatted_body key if present or the body key if
* not.
*/
static QString rawMessageBody(const Quotient::RoomMessageEvent &event);
/**
* @brief Output a string for the message content ready for display in a rich text field.
*

View File

@@ -139,7 +139,7 @@ int main(int argc, char *argv[])
about.addAuthor(i18n("Tobias Fella"), i18n("Maintainer"), QStringLiteral("tobias.fella@kde.org"), QStringLiteral("https://tobiasfella.de"));
about.addAuthor(i18n("James Graham"), i18n("Maintainer"), QStringLiteral("james.h.graham@protonmail.com"));
about.addCredit(i18n("Black Hat"), i18n("Original author of Spectral"), QStringLiteral("bhat@encom.eu.org"));
about.addCredit(i18n("Alexey Rusakov"), i18n("Maintainer of Quotient"), QStringLiteral("Kitsune-Ral@users.sf.net"));
about.addCredit(i18n("Alexey Rusakov"), i18n("Maintainer of libQuotient"), QStringLiteral("Kitsune-Ral@users.sf.net"));
about.setTranslator(i18nc("NAME OF TRANSLATORS", "Your names"), i18nc("EMAIL OF TRANSLATORS", "Your emails"));
about.setOrganizationDomain("kde.org");
@@ -250,7 +250,7 @@ int main(int argc, char *argv[])
engine.addImageProvider(QLatin1String("mxc"), MatrixImageProvider::create(&engine, &engine));
engine.addImageProvider(QLatin1String("blurhash"), new BlurhashImageProvider);
engine.load(QUrl(QStringLiteral("qrc:/org/kde/neochat/qml/main.qml")));
engine.load(QUrl(QStringLiteral("qrc:/qt/qml/org/kde/neochat/qml/main.qml")));
if (engine.rootObjects().isEmpty()) {
return -1;
}

9
src/mediamanager.cpp Normal file
View File

@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "mediamanager.h"
void MediaManager::startPlayback()
{
Q_EMIT playbackStarted();
}

42
src/mediamanager.h Normal file
View File

@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QObject>
#include <QQmlEngine>
/**
* @class MediaManager
*
* Manages media playback, like voice/audio messages, videos, etc.
*/
class MediaManager : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
public:
static MediaManager &instance()
{
static MediaManager _instance;
return _instance;
}
static MediaManager *create(QQmlEngine *, QJSEngine *)
{
QQmlEngine::setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}
/**
* @brief Notify other objects that media playback has started.
*/
Q_INVOKABLE void startPlayback();
Q_SIGNALS:
/**
* @brief Emitted when any media player starts playing. Other objects should stop / pause playback.
*/
void playbackStarted();
};

View File

@@ -354,17 +354,12 @@ QList<ActionsModel::Action> actions{
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
auto user = room->connection()->users()[text];
if (room->connection()->ignoredUsers().contains(user->id())) {
if (room->connection()->ignoredUsers().contains(text)) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<username> is already ignored.", "%1 is already ignored.", text));
return QString();
}
if (user) {
room->connection()->addToIgnoredUsers(user);
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> is now ignored", "%1 is now ignored.", text));
} else {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("<username> is not a known user", "%1 is not a known user.", text));
}
room->connection()->addToIgnoredUsers(room->connection()->user(text));
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> is now ignored", "%1 is now ignored.", text));
return QString();
},
false,
@@ -382,17 +377,12 @@ QList<ActionsModel::Action> actions{
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
auto user = room->connection()->users()[text];
if (user) {
if (!room->connection()->ignoredUsers().contains(user->id())) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<username> is not ignored.", "%1 is not ignored.", text));
return QString();
}
room->connection()->removeFromIgnoredUsers(user);
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> is no longer ignored.", "%1 is no longer ignored.", text));
} else {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("<username> is not a known user", "%1 is not a known user.", text));
if (!room->connection()->ignoredUsers().contains(text)) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<username> is not ignored.", "%1 is not ignored.", text));
return QString();
}
room->connection()->removeFromIgnoredUsers(room->connection()->user(text));
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> is no longer ignored.", "%1 is no longer ignored.", text));
return QString();
},
false,

View File

@@ -64,11 +64,13 @@ void ImagePacksModel::setRoom(NeoChatRoom *room)
}
m_room = room;
connect(m_room->connection(), &Connection::accountDataChanged, this, [this](const QString &type) {
if (type == "im.ponies.user_emotes"_ls) {
reloadImages();
}
});
if (m_room) {
connect(m_room->connection(), &Connection::accountDataChanged, this, [this](const QString &type) {
if (type == "im.ponies.user_emotes"_ls) {
reloadImages();
}
});
}
// TODO listen to packs changing
reloadImages();
Q_EMIT roomChanged();
@@ -76,6 +78,9 @@ void ImagePacksModel::setRoom(NeoChatRoom *room)
void ImagePacksModel::reloadImages()
{
if (!m_room) {
return;
}
beginResetModel();
m_events.clear();

View File

@@ -4,6 +4,7 @@
#include "messagecontentmodel.h"
#include <Quotient/events/redactionevent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#include <KLocalizedString>
@@ -13,6 +14,7 @@
#include "eventhandler.h"
#include "linkpreviewer.h"
#include "neochatroom.h"
#include "texthandler.h"
MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoChatRoom *room)
: QAbstractListModel(nullptr)
@@ -45,7 +47,7 @@ MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoCh
if (replyId == eventHandler.getReplyId()) {
// HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate.
beginResetModel();
m_components[0] = MessageComponentType::Reply;
m_components[0].type = MessageComponentType::Reply;
endResetModel();
}
}
@@ -74,6 +76,7 @@ MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoCh
if (m_event != nullptr && (oldEventId == m_event->id() || newEventId == m_event->id())) {
// HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate.
beginResetModel();
updateComponents(newEventId == m_event->id());
endResetModel();
}
});
@@ -87,7 +90,7 @@ MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoCh
if (m_linkPreviewer->loaded()) {
// HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate.
beginResetModel();
m_components[m_components.size() - 1] = MessageComponentType::LinkPreview;
m_components[m_components.size() - 1].type = MessageComponentType::LinkPreview;
endResetModel();
}
});
@@ -111,6 +114,7 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
}
EventHandler eventHandler(m_room, m_event);
const auto component = m_components[index.row()];
if (role == DisplayRole) {
if (m_event->isRedacted()) {
@@ -118,14 +122,16 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
return (reason.isEmpty()) ? i18n("<i>[This message was deleted]</i>")
: i18n("<i>[This message was deleted: %1]</i>", m_event->redactedBecause()->reason());
}
if (!component.content.isEmpty()) {
return component.content;
}
return eventHandler.getRichBody();
}
if (role == ComponentTypeRole) {
const auto component = m_components[index.row()];
if (component == MessageComponentType::Text && !m_event->id().isEmpty() && m_room->editCache()->editId() == m_event->id()) {
return MessageComponentType::Edit;
}
return component;
return component.type;
}
if (role == ComponentAttributesRole) {
return component.attributes;
}
if (role == EventIdRole) {
return eventHandler.getId();
@@ -198,6 +204,7 @@ QHash<int, QByteArray> MessageContentModel::roleNames() const
QHash<int, QByteArray> roles = QAbstractItemModel::roleNames();
roles[DisplayRole] = "display";
roles[ComponentTypeRole] = "componentType";
roles[ComponentAttributesRole] = "componentAttributes";
roles[EventIdRole] = "eventId";
roles[AuthorRole] = "author";
roles[MediaInfoRole] = "mediaInfo";
@@ -216,7 +223,7 @@ QHash<int, QByteArray> MessageContentModel::roleNames() const
return roles;
}
void MessageContentModel::updateComponents()
void MessageContentModel::updateComponents(bool isEditing)
{
beginResetModel();
m_components.clear();
@@ -224,20 +231,30 @@ void MessageContentModel::updateComponents()
EventHandler eventHandler(m_room, m_event);
if (eventHandler.hasReply()) {
if (m_room->findInTimeline(eventHandler.getReplyId()) == m_room->historyEdge()) {
m_components += MessageComponentType::ReplyLoad;
m_components += MessageComponent{MessageComponentType::ReplyLoad, QString(), {}};
m_room->loadReply(m_event->id(), eventHandler.getReplyId());
} else {
m_components += MessageComponentType::Reply;
m_components += MessageComponent{MessageComponentType::Reply, QString(), {}};
}
}
m_components += eventHandler.messageComponentType();
if (isEditing) {
m_components += MessageComponent{MessageComponentType::Edit, QString(), {}};
} else {
if (eventHandler.messageComponentType() == MessageComponentType::Text) {
const auto event = eventCast<const Quotient::RoomMessageEvent>(m_event);
auto body = EventHandler::rawMessageBody(*event);
m_components.append(TextHandler().textComponents(body, EventHandler::messageBodyInputFormat(*event), m_room, event, event->isReplaced()));
} else {
m_components += MessageComponent{eventHandler.messageComponentType(), QString(), {}};
}
}
if (m_linkPreviewer != nullptr) {
if (m_linkPreviewer->loaded()) {
m_components += MessageComponentType::LinkPreview;
m_components += MessageComponent{MessageComponentType::LinkPreview, QString(), {}};
} else {
m_components += MessageComponentType::LinkPreviewLoad;
m_components += MessageComponent{MessageComponentType::LinkPreviewLoad, QString(), {}};
}
}

View File

@@ -6,11 +6,22 @@
#include <QAbstractListModel>
#include <QQmlEngine>
#include "enums/messagecomponenttype.h"
#include "eventhandler.h"
#include "linkpreviewer.h"
#include "messagecomponenttype.h"
#include "neochatroom.h"
struct MessageComponent {
MessageComponentType::Type type;
QString content;
QVariantMap attributes;
int operator==(const MessageComponent &right) const
{
return type == right.type && content == right.content && attributes == right.attributes;
}
};
/**
* @class MessageContentModel
*
@@ -29,6 +40,7 @@ public:
enum Roles {
DisplayRole = Qt::DisplayRole, /**< The display text for the message. */
ComponentTypeRole, /**< The type of component to visualise the message. */
ComponentAttributesRole, /**< The attributes of the component. */
EventIdRole, /**< The matrix event ID of the event. */
AuthorRole, /**< The author of the event. */
MediaInfoRole, /**< The media info for the event. */
@@ -76,8 +88,8 @@ private:
NeoChatRoom *m_room = nullptr;
const Quotient::RoomEvent *m_event = nullptr;
QVector<MessageComponentType::Type> m_components;
void updateComponents();
QList<MessageComponent> m_components;
void updateComponents(bool isEditing = false);
LinkPreviewer *m_linkPreviewer = nullptr;
};

View File

@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only
#include "messageeventmodel.h"
#include "messagecomponenttype.h"
#include "messageeventmodel_logging.h"
#include "neochatconfig.h"
@@ -24,6 +25,7 @@
#include "events/pollevent.h"
#include "linkpreviewer.h"
#include "messagecontentmodel.h"
#include "models/messagefiltermodel.h"
#include "models/reactionmodel.h"
#include "texthandler.h"
@@ -43,7 +45,6 @@ QHash<int, QByteArray> MessageEventModel::roleNames() const
roles[ProgressInfoRole] = "progressInfo";
roles[IsThreadedRole] = "isThreaded";
roles[ThreadRootRole] = "threadRoot";
roles[ShowAuthorRole] = "showAuthor";
roles[ShowSectionRole] = "showSection";
roles[ReadMarkersRole] = "readMarkers";
roles[ExcessReadMarkersRole] = "excessReadMarkers";
@@ -58,6 +59,7 @@ QHash<int, QByteArray> MessageEventModel::roleNames() const
roles[IsPendingRole] = "isPending";
roles[ContentModelRole] = "contentModel";
roles[MediaInfoRole] = "mediaInfo";
roles[IsEditableRole] = "isEditable";
return roles;
}
@@ -176,7 +178,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room)
}
if (biggest < m_currentRoom->maxTimelineIndex()) {
auto rowBelowInserted = m_currentRoom->maxTimelineIndex() - biggest + timelineBaseIndex() - 1;
refreshEventRoles(rowBelowInserted, {ShowAuthorRole});
refreshEventRoles(rowBelowInserted, {MessageFilterModel::ShowAuthorRole});
}
for (auto i = m_currentRoom->maxTimelineIndex() - biggest; i <= m_currentRoom->maxTimelineIndex() - lowest; ++i) {
refreshLastUserEvents(i);
@@ -206,7 +208,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room)
refreshRow(timelineBaseIndex()); // Refresh the looks
refreshLastUserEvents(0);
if (timelineBaseIndex() > 0) { // Refresh below, see #312
refreshEventRoles(timelineBaseIndex() - 1, {ShowAuthorRole});
refreshEventRoles(timelineBaseIndex() - 1, {MessageFilterModel::ShowAuthorRole});
}
});
connect(m_currentRoom, &Room::pendingEventChanged, this, &MessageEventModel::refreshRow);
@@ -549,25 +551,6 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
return eventHandler.threadRoot();
}
if (role == ShowAuthorRole) {
for (auto r = row + 1; r < rowCount(); ++r) {
auto i = index(r);
// Note !itemData(i).empty() is a check for instances where rows have been removed, e.g. when the read marker is moved.
// While the row is removed the subsequent row indexes are not changed so we need to skip over the removed index.
// See - https://doc.qt.io/qt-5/qabstractitemmodel.html#beginRemoveRows
if (data(i, SpecialMarksRole) != EventStatus::Hidden && !itemData(i).empty()) {
return data(i, AuthorRole) != data(idx, AuthorRole) || data(i, DelegateTypeRole) == DelegateType::State
|| data(i, TimeRole).toDateTime().msecsTo(data(idx, TimeRole).toDateTime()) > 600000
|| data(i, TimeRole).toDateTime().toLocalTime().date().day() != data(idx, TimeRole).toDateTime().toLocalTime().date().day()
// FIXME: This should not be necessary; the proper fix is to calculate this role in MessageFilterModel with the knowledge about the filtered
// events.
|| data(i, IsRedactedRole).toBool();
}
}
return true;
}
if (role == ShowSectionRole) {
for (auto r = row + 1; r < rowCount(); ++r) {
auto i = index(r);
@@ -637,6 +620,10 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
return eventHandler.getMediaInfo();
}
if (role == IsEditableRole) {
return eventHandler.messageComponentType() == MessageComponentType::Text && evt.senderId() == m_currentRoom->localUser()->id();
}
return {};
}

View File

@@ -56,7 +56,6 @@ public:
IsThreadedRole,
ThreadRootRole,
ShowAuthorRole, /**< Whether the author's name should be shown. */
ShowSectionRole, /**< Whether the section header should be shown. */
ReadMarkersRole, /**< The first 5 other users at the event for read marker tracking. */
@@ -70,6 +69,7 @@ public:
AuthorDisplayNameRole, /**< The displayname for the event's sender; for name change events, the old displayname. */
IsRedactedRole, /**< Whether an event has been deleted. */
IsPendingRole, /**< Whether an event is waiting to be accepted by the server. */
IsEditableRole, /**< Whether the event can be edited by the user. */
LastRole, // Keep this last
};
Q_ENUM(EventRoles)

View File

@@ -80,8 +80,24 @@ QVariant MessageFilterModel::data(const QModelIndex &index, int role) const
return authorList(mapToSource(index).row());
} else if (role == ExcessAuthorsRole) {
return excessAuthors(mapToSource(index).row());
} else if (role == ShowAuthorRole) {
for (auto r = index.row() + 1; r < rowCount(); ++r) {
auto i = this->index(r, 0);
// Note !itemData(i).empty() is a check for instances where rows have been removed, e.g. when the read marker is moved.
// While the row is removed the subsequent row indexes are not changed so we need to skip over the removed index.
// See - https://doc.qt.io/qt-5/qabstractitemmodel.html#beginRemoveRows
if (data(i, MessageEventModel::SpecialMarksRole) != EventStatus::Hidden && !itemData(i).empty()) {
return data(i, MessageEventModel::AuthorRole) != data(index, MessageEventModel::AuthorRole)
|| data(i, MessageEventModel::DelegateTypeRole) == DelegateType::State
|| data(i, MessageEventModel::TimeRole).toDateTime().msecsTo(data(index, MessageEventModel::TimeRole).toDateTime()) > 600000
|| data(i, MessageEventModel::TimeRole).toDateTime().toLocalTime().date().day()
!= data(index, MessageEventModel::TimeRole).toDateTime().toLocalTime().date().day();
}
}
return true;
}
return sourceModel()->data(mapToSource(index), role);
return QSortFilterProxyModel::data(index, role);
}
QHash<int, QByteArray> MessageFilterModel::roleNames() const
@@ -91,6 +107,7 @@ QHash<int, QByteArray> MessageFilterModel::roleNames() const
roles[StateEventsRole] = "stateEvents";
roles[AuthorListRole] = "authorList";
roles[ExcessAuthorsRole] = "excessAuthors";
roles[ShowAuthorRole] = "showAuthor";
return roles;
}

View File

@@ -34,6 +34,7 @@ public:
StateEventsRole, /**< List of state events in the aggregated state. */
AuthorListRole, /**< List of the first 5 unique authors of the aggregated state event. */
ExcessAuthorsRole, /**< The number of unique authors beyond the first 5. */
ShowAuthorRole, /**< Whether the author (name and avatar) should be shown at this message. */
LastRole, // Keep this last
};

View File

@@ -9,17 +9,7 @@
#include "roommanager.h"
#include "spacehierarchycache.h"
#include <QDebug>
#if QT_VERSION < QT_VERSION_CHECK(6, 6, 0)
#ifndef Q_OS_ANDROID
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusMessage>
#endif
#endif
#include <KLocalizedString>
#include <QGuiApplication>
using namespace Quotient;
@@ -28,32 +18,6 @@ Q_DECLARE_METATYPE(Quotient::JoinState)
RoomListModel::RoomListModel(QObject *parent)
: QAbstractListModel(parent)
{
connect(this, &RoomListModel::highlightCountChanged, this, [this]() {
#if QT_VERSION < QT_VERSION_CHECK(6, 6, 0)
#ifndef Q_OS_ANDROID
// copied from Telegram desktop
const auto launcherUrl = "application://org.kde.neochat.desktop"_ls;
// Gnome requires that count is a 64bit integer
const qint64 counterSlice = std::min(m_highlightCount, 9999);
QVariantMap dbusUnityProperties;
if (counterSlice > 0) {
dbusUnityProperties["count"_ls] = counterSlice;
dbusUnityProperties["count-visible"_ls] = true;
} else {
dbusUnityProperties["count-visible"_ls] = false;
}
auto signal = QDBusMessage::createSignal("/com/canonical/unity/launcherentry/neochat"_ls, "com.canonical.Unity.LauncherEntry"_ls, "Update"_ls);
signal.setArguments({launcherUrl, dbusUnityProperties});
QDBusConnection::sessionBus().send(signal);
#endif // Q_OS_ANDROID
#else
qGuiApp->setBadgeNumber(m_highlightCount);
#endif // QT_VERSION_CHECK(6, 6, 0)
});
connect(&SpaceHierarchyCache::instance(), &SpaceHierarchyCache::spaceHierarchyChanged, this, [this]() {
Q_EMIT dataChanged(index(0, 0), index(rowCount(), 0), {IsChildSpaceRole});
});
@@ -122,7 +86,6 @@ void RoomListModel::doResetModel()
doAddRoom(room);
}
endResetModel();
refreshNotificationCount();
}
NeoChatRoom *RoomListModel::roomAt(int row) const
@@ -148,7 +111,7 @@ void RoomListModel::connectRoomSignals(NeoChatRoom *room)
refresh(room, {DisplayNameRole});
});
connect(room, &Room::unreadStatsChanged, this, [this, room] {
refresh(room, {NotificationCountRole, HighlightCountRole});
refresh(room, {ContextNotificationCountRole, HasHighlightNotificationsRole});
});
connect(room, &Room::notificationCountChanged, this, [this, room] {
refresh(room);
@@ -171,44 +134,6 @@ void RoomListModel::connectRoomSignals(NeoChatRoom *room)
connect(room, &Room::pendingEventMerged, this, [this, room] {
refresh(room, {SubtitleTextRole});
});
connect(room, &Room::unreadStatsChanged, this, &RoomListModel::refreshNotificationCount);
connect(room, &Room::highlightCountChanged, this, &RoomListModel::refreshHighlightCount);
}
int RoomListModel::notificationCount() const
{
return m_notificationCount;
}
int RoomListModel::highlightCount() const
{
return m_highlightCount;
}
void RoomListModel::refreshNotificationCount()
{
int count = 0;
for (auto room : std::as_const(m_rooms)) {
count += room->notificationCount();
}
if (m_notificationCount == count) {
return;
}
m_notificationCount = count;
Q_EMIT notificationCountChanged();
}
void RoomListModel::refreshHighlightCount()
{
int count = 0;
for (auto room : std::as_const(m_rooms)) {
count += room->highlightCount();
}
if (m_highlightCount == count) {
return;
}
m_highlightCount = count;
Q_EMIT highlightCountChanged();
}
void RoomListModel::updateRoom(Room *room, Room *prev)
@@ -283,6 +208,9 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const
if (role == DisplayNameRole) {
return room->displayName();
}
if (role == EscapedDisplayNameRole) {
return room->displayName().toHtmlEscaped();
}
if (role == AvatarRole) {
return room->avatarMediaId();
}
@@ -295,11 +223,11 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const
if (role == CategoryRole) {
return NeoChatRoomType::typeForRoom(room);
}
if (role == NotificationCountRole) {
return room->notificationCount();
if (role == ContextNotificationCountRole) {
return room->contextAwareNotificationCount();
}
if (role == HighlightCountRole) {
return room->highlightCount();
if (role == HasHighlightNotificationsRole) {
return room->highlightCount() > 0 && room->contextAwareNotificationCount() > 0;
}
if (role == LastActiveTimeRole) {
return room->lastActiveTime();
@@ -357,12 +285,13 @@ QHash<int, QByteArray> RoomListModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[DisplayNameRole] = "displayName";
roles[EscapedDisplayNameRole] = "escapedDisplayName";
roles[AvatarRole] = "avatar";
roles[CanonicalAliasRole] = "canonicalAlias";
roles[TopicRole] = "topic";
roles[CategoryRole] = "category";
roles[NotificationCountRole] = "notificationCount";
roles[HighlightCountRole] = "highlightCount";
roles[ContextNotificationCountRole] = "contextNotificationCount";
roles[HasHighlightNotificationsRole] = "hasHighlightNotifications";
roles[LastActiveTimeRole] = "lastActiveTime";
roles[JoinStateRole] = "joinState";
roles[CurrentRoomRole] = "currentRoom";

View File

@@ -31,23 +31,19 @@ class RoomListModel : public QAbstractListModel
*/
Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
/**
* @brief The total number of notifications for all the rooms.
*/
Q_PROPERTY(int notificationCount READ notificationCount NOTIFY notificationCountChanged)
public:
/**
* @brief Defines the model roles.
*/
enum EventRoles {
DisplayNameRole = Qt::DisplayRole, /**< The display name of the room. */
EscapedDisplayNameRole, /**< HTML-Escaped display name of the room. */
AvatarRole, /**< The source URL for the room's avatar. */
CanonicalAliasRole, /**< The room canonical alias. */
TopicRole, /**< The room topic. */
CategoryRole, /**< The room category, e.g favourite. */
NotificationCountRole, /**< The number of notifications in the room. */
HighlightCountRole, /**< The number of highlighted messages in the room. */
ContextNotificationCountRole, /**< The context aware notification count for the room. */
HasHighlightNotificationsRole, /**< Whether there are any highlight notifications. */
LastActiveTimeRole, /**< The timestamp of the last event sent in the room. */
JoinStateRole, /**< The local user's join state in the room. */
CurrentRoomRole, /**< The room object for the room. */
@@ -67,9 +63,6 @@ public:
[[nodiscard]] Quotient::Connection *connection() const;
void setConnection(Quotient::Connection *connection);
[[nodiscard]] int notificationCount() const;
[[nodiscard]] int highlightCount() const;
/**
* @brief Get the given role value at the given index.
*
@@ -114,23 +107,16 @@ private Q_SLOTS:
void updateRoom(Quotient::Room *room, Quotient::Room *prev);
void deleteRoom(Quotient::Room *room);
void refresh(NeoChatRoom *room, const QList<int> &roles = {});
void refreshNotificationCount();
void refreshHighlightCount();
private:
Quotient::Connection *m_connection = nullptr;
QList<NeoChatRoom *> m_rooms;
int m_notificationCount = 0;
int m_highlightCount = 0;
QString m_activeSpaceId;
void connectRoomSignals(NeoChatRoom *room);
Q_SIGNALS:
void connectionChanged();
void notificationCountChanged();
void highlightCountChanged();
void roomAdded(NeoChatRoom *_t1);
};

View File

@@ -13,6 +13,177 @@
using namespace Quotient;
TreeItem::TreeItem(TreeData treeData, TreeItem *parent)
: m_treeData(treeData)
, m_parentItem(parent)
{
}
void TreeItem::appendChild(std::unique_ptr<TreeItem> &&child)
{
m_childItems.push_back(std::move(child));
}
bool TreeItem::insertChildren(int position, int count, TreeData treeData)
{
if (position < 0 || position > qsizetype(m_childItems.size()))
return false;
for (int row = 0; row < count; ++row) {
m_childItems.insert(m_childItems.cbegin() + position, std::make_unique<TreeItem>(treeData, this));
}
return true;
}
bool TreeItem::removeChildren(int position, int count)
{
if (position < 0 || position + count > qsizetype(m_childItems.size())) {
return false;
}
for (int row = 0; row < count; ++row) {
m_childItems.erase(m_childItems.cbegin() + position);
qWarning() << "removing" << position;
}
return true;
}
TreeItem *TreeItem::child(int row)
{
return row >= 0 && row < childCount() ? m_childItems.at(row).get() : nullptr;
}
int TreeItem::childCount() const
{
return int(m_childItems.size());
}
int TreeItem::row() const
{
if (m_parentItem == nullptr) {
return 0;
}
const auto it = std::find_if(m_parentItem->m_childItems.cbegin(), m_parentItem->m_childItems.cend(), [this](const std::unique_ptr<TreeItem> &treeItem) {
return treeItem.get() == this;
});
if (it != m_parentItem->m_childItems.cend())
return std::distance(m_parentItem->m_childItems.cbegin(), it);
Q_ASSERT(false); // should not happen
return -1;
}
QVariant TreeItem::data(int role) const
{
if (!m_parentItem) {
return {};
}
if (std::holds_alternative<NeoChatRoomType::Types>(m_treeData)) {
const auto row = this->row();
switch (role) {
case RoomTreeModel::IsCategoryRole:
return true;
case RoomTreeModel::DisplayNameRole:
return NeoChatRoomType::typeName(row);
case RoomTreeModel::DelegateTypeRole:
if (row == NeoChatRoomType::Search) {
return QStringLiteral("search");
}
if (row == NeoChatRoomType::AddDirect) {
return QStringLiteral("addDirect");
}
return QStringLiteral("section");
case RoomTreeModel::IconRole:
return NeoChatRoomType::typeIconName(row);
case RoomTreeModel::CategoryRole:
return row;
default:
return {};
}
}
const auto room = std::get<NeoChatRoom *>(m_treeData);
switch (role) {
case RoomTreeModel::IsCategoryRole:
return false;
case RoomTreeModel::DisplayNameRole:
return room->displayName();
case RoomTreeModel::AvatarRole:
return room->avatarMediaId();
case RoomTreeModel::CanonicalAliasRole:
return room->canonicalAlias();
case RoomTreeModel::TopicRole:
return room->topic();
case RoomTreeModel::CategoryRole:
return NeoChatRoomType::typeForRoom(room);
case RoomTreeModel::ContextNotificationCountRole:
return room->contextAwareNotificationCount();
case RoomTreeModel::HasHighlightNotificationsRole:
return room->highlightCount() > 0 && room->contextAwareNotificationCount() > 0;
case RoomTreeModel::LastActiveTimeRole:
return room->lastActiveTime();
case RoomTreeModel::JoinStateRole:
if (!room->successorId().isEmpty()) {
return QStringLiteral("upgraded");
}
return QVariant::fromValue(room->joinState());
case RoomTreeModel::CurrentRoomRole:
return QVariant::fromValue(room);
case RoomTreeModel::SubtitleTextRole: {
if (room->lastEvent() == nullptr || room->lastEventIsSpoiler()) {
return QString();
}
EventHandler eventHandler(room, room->lastEvent());
return eventHandler.subtitleText();
}
case RoomTreeModel::AvatarImageRole:
return room->avatar(128);
case RoomTreeModel::RoomIdRole:
return room->id();
case RoomTreeModel::IsSpaceRole:
return room->isSpace();
case RoomTreeModel::IsChildSpaceRole:
return SpaceHierarchyCache::instance().isChild(room->id());
case RoomTreeModel::ReplacementIdRole:
return room->successorId();
case RoomTreeModel::IsDirectChat:
return room->isDirectChat();
case RoomTreeModel::DelegateTypeRole:
return QStringLiteral("normal");
}
return {};
}
TreeItem *TreeItem::parentItem() const
{
return m_parentItem;
}
std::optional<int> TreeItem::position(Quotient::Room *room) const
{
Q_ASSERT_X(std::holds_alternative<NeoChatRoomType::Types>(m_treeData), __FUNCTION__, "containsRoom only works in category items");
int i = 0;
for (const auto &child : m_childItems) {
if (std::get<NeoChatRoom *>(child->treeData()) == room) {
return i;
}
i++;
}
return std::nullopt;
}
TreeItem::TreeData TreeItem::treeData() const
{
return m_treeData;
}
RoomTreeModel::RoomTreeModel(QObject *parent)
: QAbstractItemModel(parent)
{
@@ -21,15 +192,18 @@ RoomTreeModel::RoomTreeModel(QObject *parent)
void RoomTreeModel::initializeCategories()
{
for (const auto &key : m_rooms.keys()) {
for (const auto &room : m_rooms[key]) {
room->disconnect(this);
}
m_rootItem.reset(new TreeItem(nullptr, nullptr));
for (int i = 0; i < NeoChatRoomType::TypesCount; i++) {
m_rootItem->appendChild(std::make_unique<TreeItem>(NeoChatRoomType::Types(i), m_rootItem.get()));
}
m_rooms.clear();
for (int i = 0; i < 8; i++) {
m_rooms[NeoChatRoomType::Types(i)] = {};
}
TreeItem *RoomTreeModel::getItem(const QModelIndex &index) const
{
if (index.isValid()) {
return static_cast<TreeItem *>(index.internalPointer());
}
return m_rootItem.get();
}
void RoomTreeModel::setConnection(NeoChatConnection *connection)
@@ -37,13 +211,16 @@ void RoomTreeModel::setConnection(NeoChatConnection *connection)
if (m_connection == connection) {
return;
}
disconnect(m_connection.get(), nullptr, this, nullptr);
if (m_connection) {
disconnect(m_connection.get(), nullptr, this, nullptr);
}
m_connection = connection;
beginResetModel();
initializeCategories();
endResetModel();
connect(connection, &Connection::newRoom, this, &RoomTreeModel::newRoom);
connect(connection, &Connection::leftRoom, this, &RoomTreeModel::leftRoom);
connect(connection, &Connection::aboutToDeleteRoom, this, &RoomTreeModel::leftRoom);
for (const auto &room : m_connection->allRooms()) {
newRoom(dynamic_cast<NeoChatRoom *>(room));
@@ -55,24 +232,44 @@ void RoomTreeModel::newRoom(Room *r)
{
const auto room = dynamic_cast<NeoChatRoom *>(r);
const auto type = NeoChatRoomType::typeForRoom(room);
beginInsertRows(index(type, 0), m_rooms[type].size(), m_rooms[type].size());
m_rooms[type].append(room);
// Check if the room is already in the model.
const auto checkRoomIndex = indexForRoom(room);
if (checkRoomIndex.isValid()) {
// If the room is in the wrong type category for whatever reason, move it.
if (checkRoomIndex.parent().row() != type) {
moveRoom(room);
}
return;
}
auto categoryItem = m_rootItem->child(type);
beginInsertRows(index(type, 0), categoryItem->childCount(), categoryItem->childCount());
categoryItem->appendChild(std::make_unique<TreeItem>(room, categoryItem));
connectRoomSignals(room);
endInsertRows();
qWarning() << "adding room" << type << "new count" << categoryItem->childCount();
}
void RoomTreeModel::leftRoom(Room *r)
{
const auto room = dynamic_cast<NeoChatRoom *>(r);
const auto type = NeoChatRoomType::typeForRoom(room);
auto row = m_rooms[type].indexOf(room);
if (row == -1) {
auto idx = indexForRoom(room);
if (!idx.isValid()) {
return;
}
beginRemoveRows(index(type, 0), row, row);
m_rooms[type][row]->disconnect(this);
m_rooms[type].removeAt(row);
auto parentItem = getItem(idx.parent());
Q_ASSERT(parentItem);
beginRemoveRows(idx.parent(), idx.row(), idx.row());
const bool success = parentItem->removeChildren(idx.row(), 1);
room->disconnect(this);
endRemoveRows();
if (success) {
qWarning() << "Unable to remove room";
}
}
void RoomTreeModel::moveRoom(Quotient::Room *room)
@@ -81,31 +278,46 @@ void RoomTreeModel::moveRoom(Quotient::Room *room)
// NeoChatRoomType::typeForRoom doesn't match it's current location. So find the room.
NeoChatRoomType::Types oldType;
int oldRow = -1;
for (const auto &key : m_rooms.keys()) {
if (m_rooms[key].contains(room)) {
oldType = key;
oldRow = m_rooms[key].indexOf(room);
for (int i = 0; i < NeoChatRoomType::TypesCount; i++) {
auto categoryItem = m_rootItem->child(i);
auto position = categoryItem->position(room);
if (position) {
oldType = static_cast<NeoChatRoomType::Types>(i);
oldRow = *position;
}
}
if (oldRow == -1) {
return;
}
const auto newType = NeoChatRoomType::typeForRoom(dynamic_cast<NeoChatRoom *>(room));
auto neochatRoom = dynamic_cast<NeoChatRoom *>(room);
const auto newType = NeoChatRoomType::typeForRoom(neochatRoom);
if (newType == oldType) {
return;
}
const auto oldParent = index(oldType, 0, {});
auto oldParentItem = getItem(oldParent);
Q_ASSERT(oldParentItem);
const auto newParent = index(newType, 0, {});
auto newParentItem = getItem(newParent);
Q_ASSERT(newParentItem);
// HACK: We're doing this as a remove then insert because moving doesn't work
// properly with DelegateChooser for whatever reason.
Q_ASSERT(checkIndex(index(oldRow, 0, oldParent), QAbstractItemModel::CheckIndexOption::IndexIsValid));
beginRemoveRows(oldParent, oldRow, oldRow);
m_rooms[oldType].removeAt(oldRow);
const bool success = oldParentItem->removeChildren(oldRow, 1);
Q_ASSERT(success);
endRemoveRows();
beginInsertRows(newParent, m_rooms[newType].size(), m_rooms[newType].size());
m_rooms[newType].append(dynamic_cast<NeoChatRoom *>(room));
beginInsertRows(newParent, newParentItem->childCount(), newParentItem->childCount());
newParentItem->appendChild(std::make_unique<TreeItem>(neochatRoom, newParentItem));
endInsertRows();
// Q_ASSERT(checkIndex(index(newParentItem->childCount() - 1, 0, newParent), QAbstractItemModel::CheckIndexOption::IndexIsValid));
}
void RoomTreeModel::connectRoomSignals(NeoChatRoom *room)
@@ -114,7 +326,7 @@ void RoomTreeModel::connectRoomSignals(NeoChatRoom *room)
refreshRoomRoles(room, {DisplayNameRole});
});
connect(room, &Room::unreadStatsChanged, this, [this, room] {
refreshRoomRoles(room, {NotificationCountRole, HighlightCountRole});
refreshRoomRoles(room, {ContextNotificationCountRole, HasHighlightNotificationsRole});
});
connect(room, &Room::avatarChanged, this, [this, room] {
refreshRoomRoles(room, {AvatarRole});
@@ -131,18 +343,19 @@ void RoomTreeModel::connectRoomSignals(NeoChatRoom *room)
connect(room, &Room::pendingEventMerged, this, [this, room] {
refreshRoomRoles(room, {SubtitleTextRole});
});
connect(room, &NeoChatRoom::pushNotificationStateChanged, this, [this, room] {
refreshRoomRoles(room, {ContextNotificationCountRole, HasHighlightNotificationsRole});
});
}
void RoomTreeModel::refreshRoomRoles(NeoChatRoom *room, const QList<int> &roles)
{
const auto roomType = NeoChatRoomType::typeForRoom(room);
const auto it = std::find(m_rooms[roomType].begin(), m_rooms[roomType].end(), room);
if (it == m_rooms[roomType].end()) {
const auto idx = indexForRoom(room);
if (!idx.isValid()) {
qCritical() << "Room" << room->id() << "not found in the room list";
return;
}
const auto parentIndex = index(roomType, 0, {});
const auto idx = index(it - m_rooms[roomType].begin(), 0, parentIndex);
Q_EMIT dataChanged(idx, idx, roles);
}
@@ -153,38 +366,42 @@ NeoChatConnection *RoomTreeModel::connection() const
int RoomTreeModel::columnCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return 1;
const TreeItem *parentItem = getItem(parent);
return parentItem ? 1 : 0;
}
int RoomTreeModel::rowCount(const QModelIndex &parent) const
{
if (!parent.isValid()) {
return m_rooms.keys().size();
}
if (!parent.parent().isValid()) {
return m_rooms.values()[parent.row()].size();
}
return 0;
const TreeItem *parentItem = getItem(parent);
return parentItem ? parentItem->childCount() : 0;
}
QModelIndex RoomTreeModel::parent(const QModelIndex &index) const
{
if (!index.internalPointer()) {
if (!index.isValid()) {
return {};
}
return this->index(NeoChatRoomType::typeForRoom(static_cast<NeoChatRoom *>(index.internalPointer())), 0, QModelIndex());
TreeItem *childItem = getItem(index);
Q_ASSERT(childItem);
TreeItem *parentItem = childItem->parentItem();
return parentItem != m_rootItem.get() ? createIndex(parentItem->row(), 0, parentItem) : QModelIndex{};
}
QModelIndex RoomTreeModel::index(int row, int column, const QModelIndex &parent) const
{
if (!parent.isValid()) {
return createIndex(row, column, nullptr);
}
if (row >= rowCount(parent)) {
if (parent.isValid() && parent.column() != 0) {
return {};
}
return createIndex(row, column, m_rooms[NeoChatRoomType::Types(parent.row())][row]);
TreeItem *parentItem = getItem(parent);
Q_ASSERT(parentItem);
if (auto *childItem = parentItem->child(row)) {
return createIndex(row, column, childItem);
}
return {};
}
QHash<int, QByteArray> RoomTreeModel::roleNames() const
@@ -195,8 +412,8 @@ QHash<int, QByteArray> RoomTreeModel::roleNames() const
roles[CanonicalAliasRole] = "canonicalAlias";
roles[TopicRole] = "topic";
roles[CategoryRole] = "category";
roles[NotificationCountRole] = "notificationCount";
roles[HighlightCountRole] = "highlightCount";
roles[ContextNotificationCountRole] = "contextNotificationCount";
roles[HasHighlightNotificationsRole] = "hasHighlightNotifications";
roles[LastActiveTimeRole] = "lastActiveTime";
roles[JoinStateRole] = "joinState";
roles[CurrentRoomRole] = "currentRoom";
@@ -207,103 +424,21 @@ QHash<int, QByteArray> RoomTreeModel::roleNames() const
roles[IsDirectChat] = "isDirectChat";
roles[DelegateTypeRole] = "delegateType";
roles[IconRole] = "icon";
roles[AttentionRole] = "attention";
roles[FavouriteRole] = "favourite";
return roles;
}
// TODO room type changes
QVariant RoomTreeModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid()) {
return QVariant();
}
if (!index.parent().isValid()) {
if (role == DisplayNameRole) {
return NeoChatRoomType::typeName(index.row());
}
if (role == DelegateTypeRole) {
if (index.row() == NeoChatRoomType::Search) {
return QStringLiteral("search");
}
if (index.row() == NeoChatRoomType::AddDirect) {
return QStringLiteral("addDirect");
}
return QStringLiteral("section");
}
if (role == IconRole) {
return NeoChatRoomType::typeIconName(index.row());
}
if (role == CategoryRole) {
return index.row();
}
if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
qWarning() << index.row() << rowCount(index.parent());
Q_ASSERT(false);
return {};
}
const auto room = m_rooms.values()[index.parent().row()][index.row()].get();
Q_ASSERT(room);
if (role == DisplayNameRole) {
return room->displayName();
}
if (role == AvatarRole) {
return room->avatarMediaId();
}
if (role == CanonicalAliasRole) {
return room->canonicalAlias();
}
if (role == TopicRole) {
return room->topic();
}
if (role == CategoryRole) {
return NeoChatRoomType::typeForRoom(room);
}
if (role == NotificationCountRole) {
return room->notificationCount();
}
if (role == HighlightCountRole) {
return room->highlightCount();
}
if (role == LastActiveTimeRole) {
return room->lastActiveTime();
}
if (role == JoinStateRole) {
if (!room->successorId().isEmpty()) {
return QStringLiteral("upgraded");
}
return QVariant::fromValue(room->joinState());
}
if (role == CurrentRoomRole) {
return QVariant::fromValue(room);
}
if (role == SubtitleTextRole) {
if (room->lastEvent() == nullptr || room->lastEventIsSpoiler()) {
return QString();
}
EventHandler eventHandler(room, room->lastEvent());
return eventHandler.subtitleText();
}
if (role == AvatarImageRole) {
return room->avatar(128);
}
if (role == RoomIdRole) {
return room->id();
}
if (role == IsSpaceRole) {
return room->isSpace();
}
if (role == IsChildSpaceRole) {
return SpaceHierarchyCache::instance().isChild(room->id());
}
if (role == ReplacementIdRole) {
return room->successorId();
}
if (role == IsDirectChat) {
return room->isDirectChat();
}
if (role == DelegateTypeRole) {
return QStringLiteral("normal");
}
return {};
return getItem(index)->data(role);
}
QModelIndex RoomTreeModel::indexForRoom(NeoChatRoom *room) const
@@ -312,11 +447,18 @@ QModelIndex RoomTreeModel::indexForRoom(NeoChatRoom *room) const
return {};
}
const auto type = NeoChatRoomType::typeForRoom(room);
auto row = m_rooms[type].indexOf(room);
if (row >= 0) {
return index(row, 0, index(type, 0));
const auto roomType = NeoChatRoomType::typeForRoom(room);
const auto roomTypeItem = m_rootItem->child(roomType);
for (int i = 0, count = roomTypeItem->childCount(); i < count; i++) {
auto roomItem = roomTypeItem->child(i);
if (std::get<NeoChatRoom *>(roomItem->treeData()) == room) {
const auto parentIndex = index(roomType, 0, {});
const auto idx = index(i, 0, parentIndex);
return idx;
}
}
return {};
}

View File

@@ -15,6 +15,32 @@ class Room;
class NeoChatConnection;
class NeoChatRoom;
class RoomTreeModelTest;
class TreeItem
{
public:
using TreeData = std::variant<NeoChatRoom *, NeoChatRoomType::Types>;
explicit TreeItem(TreeData data, TreeItem *parentItem);
TreeItem *child(int row);
int childCount() const;
QVariant data(int role) const;
void appendChild(std::unique_ptr<TreeItem> &&child);
bool insertChildren(int position, int count, TreeData treeData);
TreeItem *parentItem() const;
bool removeChildren(int position, int count);
bool removeColumns(int position, int columns);
std::optional<int> position(Quotient::Room *room) const;
int row() const;
TreeData treeData() const;
private:
std::vector<std::unique_ptr<TreeItem>> m_childItems;
TreeData m_treeData;
TreeItem *m_parentItem;
};
class RoomTreeModel : public QAbstractItemModel
{
@@ -33,8 +59,8 @@ public:
CanonicalAliasRole, /**< The room canonical alias. */
TopicRole, /**< The room topic. */
CategoryRole, /**< The room category, e.g favourite. */
NotificationCountRole, /**< The number of notifications in the room. */
HighlightCountRole, /**< The number of highlighted messages in the room. */
ContextNotificationCountRole, /**< The context aware notification count for the room. */
HasHighlightNotificationsRole, /**< Whether there are any highlight notifications. */
LastActiveTimeRole, /**< The timestamp of the last event sent in the room. */
JoinStateRole, /**< The local user's join state in the room. */
CurrentRoomRole, /**< The room object for the room. */
@@ -47,6 +73,9 @@ public:
IsDirectChat, /**< Whether this room is a direct chat. */
DelegateTypeRole,
IconRole,
AttentionRole, /**< Whether there are any notifications. */
FavouriteRole, /**< Whether the room is favourited. */
IsCategoryRole, /**< Whether the item in the model is a category */
};
Q_ENUM(EventRoles)
explicit RoomTreeModel(QObject *parent = nullptr);
@@ -74,6 +103,8 @@ public:
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
TreeItem *getItem(const QModelIndex &index) const;
Q_INVOKABLE QModelIndex indexForRoom(NeoChatRoom *room) const;
Q_SIGNALS:
@@ -81,7 +112,6 @@ Q_SIGNALS:
private:
QPointer<NeoChatConnection> m_connection = nullptr;
QMap<NeoChatRoomType::Types, QList<QPointer<NeoChatRoom>>> m_rooms;
void initializeCategories();
void connectRoomSignals(NeoChatRoom *room);
@@ -91,4 +121,8 @@ private:
void moveRoom(Quotient::Room *room);
void refreshRoomRoles(NeoChatRoom *room, const QList<int> &roles = {});
std::unique_ptr<TreeItem> m_rootItem;
friend RoomTreeModelTest;
};

View File

@@ -13,6 +13,12 @@
SortFilterRoomTreeModel::SortFilterRoomTreeModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
setRoomSortOrder(static_cast<RoomSortOrder>(NeoChatConfig::sortOrder()));
connect(NeoChatConfig::self(), &NeoChatConfig::SortOrderChanged, this, [this]() {
setRoomSortOrder(static_cast<RoomSortOrder>(NeoChatConfig::sortOrder()));
invalidateFilter();
});
setRecursiveFilteringEnabled(true);
sort(0);
invalidateFilter();
@@ -29,48 +35,75 @@ SortFilterRoomTreeModel::SortFilterRoomTreeModel(QObject *parent)
void SortFilterRoomTreeModel::setRoomSortOrder(SortFilterRoomTreeModel::RoomSortOrder sortOrder)
{
m_sortOrder = sortOrder;
Q_EMIT roomSortOrderChanged();
if (sortOrder == SortFilterRoomTreeModel::Alphabetical) {
setSortRole(RoomTreeModel::DisplayNameRole);
} else if (sortOrder == SortFilterRoomTreeModel::LastActivity) {
} else if (sortOrder == SortFilterRoomTreeModel::Activity) {
setSortRole(RoomTreeModel::LastActiveTimeRole);
}
invalidate();
}
SortFilterRoomTreeModel::RoomSortOrder SortFilterRoomTreeModel::roomSortOrder() const
static const QVector<RoomTreeModel::EventRoles> alphabeticalSortPriorities{
// Does exactly what it says on the tin.
RoomTreeModel::DisplayNameRole,
};
static const QVector<RoomTreeModel::EventRoles> activitySortPriorities{
// Anything useful at the top, quiet rooms at the bottom
RoomTreeModel::AttentionRole,
// Organize by highlights, notifications, unread favorites, all other unread, in that order
RoomTreeModel::HasHighlightNotificationsRole,
RoomTreeModel::ContextNotificationCountRole,
RoomTreeModel::FavouriteRole,
// Finally sort by last activity time
RoomTreeModel::LastActiveTimeRole,
};
bool SortFilterRoomTreeModel::roleCmp(const QVariant &sortLeft, const QVariant &sortRight) const
{
return m_sortOrder;
switch (sortLeft.typeId()) {
case QMetaType::Bool:
return (sortLeft == sortRight) ? false : sortLeft.toBool();
case QMetaType::QString:
return sortLeft.toString() < sortRight.toString();
case QMetaType::Int:
return sortLeft.toInt() > sortRight.toInt();
case QMetaType::QDateTime:
return sortLeft.toDateTime() > sortRight.toDateTime();
default:
return false;
}
}
bool SortFilterRoomTreeModel::prioritiesCmp(const QVector<RoomTreeModel::EventRoles> &priorities,
const QModelIndex &source_left,
const QModelIndex &source_right) const
{
for (RoomTreeModel::EventRoles sortRole : priorities) {
const auto sortLeft = sourceModel()->data(source_left, sortRole);
const auto sortRight = sourceModel()->data(source_right, sortRole);
if (sortLeft != sortRight) {
return roleCmp(sortLeft, sortRight);
}
}
return QSortFilterProxyModel::lessThan(source_left, source_right);
}
bool SortFilterRoomTreeModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const
{
if (m_sortOrder == SortFilterRoomTreeModel::LastActivity) {
// display favorite rooms always on top
const auto categoryLeft = static_cast<NeoChatRoomType::Types>(sourceModel()->data(source_left, RoomTreeModel::CategoryRole).toInt());
const auto categoryRight = static_cast<NeoChatRoomType::Types>(sourceModel()->data(source_right, RoomTreeModel::CategoryRole).toInt());
// Don't sort the top level categories.
if (!source_left.parent().isValid() || !source_right.parent().isValid()) {
return false;
}
if (categoryLeft == NeoChatRoomType::Types::Favorite && categoryRight == NeoChatRoomType::Types::Favorite) {
return sourceModel()->data(source_left, RoomTreeModel::LastActiveTimeRole).toDateTime()
> sourceModel()->data(source_right, RoomTreeModel::LastActiveTimeRole).toDateTime();
}
if (categoryLeft == NeoChatRoomType::Types::Favorite) {
return true;
} else if (categoryRight == NeoChatRoomType::Types::Favorite) {
return false;
}
switch (m_sortOrder) {
case SortFilterRoomTreeModel::Alphabetical:
return prioritiesCmp(alphabeticalSortPriorities, source_left, source_right);
case SortFilterRoomTreeModel::Activity:
return prioritiesCmp(activitySortPriorities, source_left, source_right);
}
return sourceModel()->data(source_left, RoomTreeModel::LastActiveTimeRole).toDateTime()
> sourceModel()->data(source_right, RoomTreeModel::LastActiveTimeRole).toDateTime();
}
if (m_sortOrder != SortFilterRoomTreeModel::Categories) {
return QSortFilterProxyModel::lessThan(source_left, source_right);
}
if (sourceModel()->data(source_left, RoomTreeModel::CategoryRole) != sourceModel()->data(source_right, RoomTreeModel::CategoryRole)) {
return sourceModel()->data(source_left, RoomTreeModel::CategoryRole).toInt() < sourceModel()->data(source_right, RoomTreeModel::CategoryRole).toInt();
}
return sourceModel()->data(source_left, RoomTreeModel::LastActiveTimeRole).toDateTime()
> sourceModel()->data(source_right, RoomTreeModel::LastActiveTimeRole).toDateTime();
return QSortFilterProxyModel::lessThan(source_left, source_right);
}
void SortFilterRoomTreeModel::setFilterText(const QString &text)
@@ -86,20 +119,28 @@ QString SortFilterRoomTreeModel::filterText() const
bool SortFilterRoomTreeModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const
{
// root node
if (!source_parent.isValid()) {
if (sourceModel()->data(sourceModel()->index(source_row, 0), RoomTreeModel::CategoryRole).toInt() == NeoChatRoomType::Search
&& NeoChatConfig::collapsed()) {
return true;
}
const QModelIndex index = sourceModel()->index(source_row, 0, source_parent);
if (!index.isValid()) {
qWarning() << source_row << source_parent << sourceModel()->rowCount(source_parent);
Q_ASSERT(false);
return true;
}
if (sourceModel()->data(index, RoomTreeModel::IsCategoryRole).toBool()) {
if (sourceModel()->data(index, RoomTreeModel::CategoryRole).toInt() == NeoChatRoomType::Search && NeoChatConfig::collapsed()) {
return true;
}
if (sourceModel()->data(sourceModel()->index(source_row, 0), RoomTreeModel::CategoryRole).toInt() == NeoChatRoomType::AddDirect
&& m_mode == DirectChats) {
if (sourceModel()->data(index, RoomTreeModel::CategoryRole).toInt() == NeoChatRoomType::AddDirect && m_mode == DirectChats) {
return true;
}
return false;
}
QModelIndex index = sourceModel()->index(source_row, 0, source_parent);
bool acceptRoom = sourceModel()->data(index, RoomTreeModel::DisplayNameRole).toString().contains(m_filterText, Qt::CaseInsensitive)
&& sourceModel()->data(index, RoomTreeModel::IsSpaceRole).toBool() == false;

View File

@@ -7,6 +7,8 @@
#include <QQmlEngine>
#include <QSortFilterProxyModel>
#include "models/roomtreemodel.h"
/**
* @class SortFilterRoomTreeModel
*
@@ -31,13 +33,6 @@ class SortFilterRoomTreeModel : public QSortFilterProxyModel
Q_OBJECT
QML_ELEMENT
/**
* @brief The order by which the rooms will be sorted.
*
* @sa RoomSortOrder
*/
Q_PROPERTY(RoomSortOrder roomSortOrder READ roomSortOrder WRITE setRoomSortOrder NOTIFY roomSortOrderChanged)
/**
* @brief The text to use to filter room names.
*/
@@ -56,8 +51,7 @@ class SortFilterRoomTreeModel : public QSortFilterProxyModel
public:
enum RoomSortOrder {
Alphabetical,
LastActivity,
Categories,
Activity,
};
Q_ENUM(RoomSortOrder)
@@ -71,7 +65,6 @@ public:
explicit SortFilterRoomTreeModel(QObject *parent = nullptr);
void setRoomSortOrder(RoomSortOrder sortOrder);
[[nodiscard]] RoomSortOrder roomSortOrder() const;
void setFilterText(const QString &text);
[[nodiscard]] QString filterText() const;
@@ -98,14 +91,16 @@ protected:
[[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override;
Q_SIGNALS:
void roomSortOrderChanged();
void filterTextChanged();
void activeSpaceIdChanged();
void modeChanged();
private:
RoomSortOrder m_sortOrder = Categories;
RoomSortOrder m_sortOrder = Activity;
Mode m_mode = All;
QString m_filterText;
QString m_activeSpaceId;
bool roleCmp(const QVariant &left, const QVariant &right) const;
bool prioritiesCmp(const QVector<RoomTreeModel::EventRoles> &priorities, const QModelIndex &left, const QModelIndex &right) const;
};

View File

@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "statekeysmodel.h"
StateKeysModel::StateKeysModel(QObject *parent)
: QAbstractListModel(parent)
{
}
QHash<int, QByteArray> StateKeysModel::roleNames() const
{
return {
{StateKeyRole, "stateKey"},
};
}
QVariant StateKeysModel::data(const QModelIndex &index, int role) const
{
Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid));
const auto row = index.row();
switch (role) {
case StateKeyRole:
return m_stateKeys[row]->stateKey();
}
return {};
}
int StateKeysModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return m_stateKeys.count();
}
NeoChatRoom *StateKeysModel::room() const
{
return m_room;
}
void StateKeysModel::loadState()
{
if (!m_room || m_eventType.isEmpty()) {
return;
}
beginResetModel();
m_stateKeys = m_room->currentState().eventsOfType(m_eventType);
endResetModel();
}
void StateKeysModel::setRoom(NeoChatRoom *room)
{
if (m_room) {
disconnect(m_room, nullptr, this, nullptr);
}
m_room = room;
Q_EMIT roomChanged();
loadState();
connect(room, &NeoChatRoom::changed, this, [this] {
loadState();
});
}
QString StateKeysModel::eventType() const
{
return m_eventType;
}
void StateKeysModel::setEventType(const QString &eventType)
{
m_eventType = eventType;
Q_EMIT eventTypeChanged();
loadState();
}
QByteArray StateKeysModel::stateEventJson(const QModelIndex &index)
{
const auto row = index.row();
const auto event = m_stateKeys[row];
const auto json = event->fullJson();
return QJsonDocument(json).toJson();
}
#include "moc_statekeysmodel.cpp"

View File

@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QAbstractListModel>
#include <QQmlEngine>
#include "neochatroom.h"
/**
* @class StateKeysModel
*
* This class defines the model for visualising the state keys for a certain type in a room.
*/
class StateKeysModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The current room that the model is getting its state events from.
*/
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged REQUIRED)
/**
* @brief The event type to list the stateKeys for
*/
Q_PROPERTY(QString eventType READ eventType WRITE setEventType NOTIFY eventTypeChanged REQUIRED)
public:
/**
* @brief Defines the model roles.
*/
enum Roles {
StateKeyRole, /**< The state key of the state event. */
};
Q_ENUM(Roles)
explicit StateKeysModel(QObject *parent = nullptr);
NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
QString eventType() const;
void setEventType(const QString &eventType);
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
QVariant data(const QModelIndex &index, int role) const override;
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
int rowCount(const QModelIndex &parent) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
QHash<int, QByteArray> roleNames() const override;
/**
* @brief Get the full JSON for an event.
*/
Q_INVOKABLE QByteArray stateEventJson(const QModelIndex &index);
Q_SIGNALS:
void roomChanged();
void eventTypeChanged();
private:
NeoChatRoom *m_room = nullptr;
QString m_eventType;
QVector<const Quotient::StateEvent *> m_stateKeys;
void loadState();
};

View File

@@ -10,16 +10,16 @@ StateModel::StateModel(QObject *parent)
QHash<int, QByteArray> StateModel::roleNames() const
{
return {{TypeRole, "type"}, {StateKeyRole, "stateKey"}};
return {{TypeRole, "type"}, {EventCountRole, "eventCount"}};
}
QVariant StateModel::data(const QModelIndex &index, int role) const
{
auto row = index.row();
switch (role) {
case TypeRole:
return m_stateEvents[row].first;
case StateKeyRole:
return m_stateEvents[row].second;
return m_stateEvents.keys()[row];
case EventCountRole:
return m_stateEvents.values()[row].count();
}
return {};
}
@@ -27,7 +27,7 @@ QVariant StateModel::data(const QModelIndex &index, int role) const
int StateModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return m_room->currentState().events().size();
return m_stateEvents.count();
}
NeoChatRoom *StateModel::room() const
@@ -35,26 +35,42 @@ NeoChatRoom *StateModel::room() const
return m_room;
}
void StateModel::loadState()
{
beginResetModel();
m_stateEvents.clear();
if (!m_room) {
return;
}
const auto keys = m_room->currentState().events().keys();
for (const auto &[type, stateKey] : keys) {
if (!m_stateEvents.contains(type)) {
m_stateEvents[type] = {};
}
m_stateEvents[type] += stateKey;
}
endResetModel();
}
void StateModel::setRoom(NeoChatRoom *room)
{
m_room = room;
Q_EMIT roomChanged();
beginResetModel();
m_stateEvents.clear();
m_stateEvents = m_room->currentState().events().keys();
endResetModel();
loadState();
connect(room, &NeoChatRoom::changed, this, [this] {
beginResetModel();
m_stateEvents.clear();
m_stateEvents = m_room->currentState().events().keys();
endResetModel();
loadState();
});
}
QByteArray StateModel::stateEventJson(const QModelIndex &index)
{
auto row = index.row();
return QJsonDocument(m_room->currentState().events()[m_stateEvents[row]]->fullJson()).toJson();
const auto type = m_stateEvents.keys()[row];
const auto stateKey = m_stateEvents.values()[row][0];
const auto event = m_room->currentState().get(type, stateKey);
return QJsonDocument(event->fullJson()).toJson();
}
#include "moc_statemodel.cpp"

View File

@@ -29,7 +29,7 @@ public:
*/
enum Roles {
TypeRole = 0, /**< The type of the state event. */
StateKeyRole, /**< The state key of the state event. */
EventCountRole, /**< Number of events of this type. */
};
Q_ENUM(Roles)
@@ -58,11 +58,9 @@ public:
* @sa Roles, QAbstractItemModel::roleNames()
*/
QHash<int, QByteArray> roleNames() const override;
/**
* @brief Get the full JSON for an event.
*
* This is used to avoid having the model hold all the JSON data. The JSON for
* a single item is only ever shown, no need to access simultaneously.
*/
Q_INVOKABLE QByteArray stateEventJson(const QModelIndex &index);
@@ -70,13 +68,11 @@ Q_SIGNALS:
void roomChanged();
private:
NeoChatRoom *m_room = nullptr;
QPointer<NeoChatRoom> m_room;
/**
* @brief The room state events in a QList.
*
* This is done for performance, accessing all the data from the parent QHash
* was slower.
* @brief A map from state event type to number of events of that type
*/
QList<std::pair<QString, QString>> m_stateEvents;
QMap<QString, QList<QString>> m_stateEvents;
void loadState();
};

View File

@@ -243,3 +243,41 @@ Comment[x-test]=xxThere is a new invitation to a roomxx
Comment[zh_CN]=有新的聊天室邀请
Comment[zh_TW]=有新的加入聊天室邀請
Action=Popup
[Event/Share]
Name=Share
Name[ca]=Compartició
Name[ca@valencia]=Compartició
Name[cs]=Sdílet
Name[eo]=Kundividi
Name[es]=Compartir
Name[eu]=Partekatu
Name[fr]=Partager
Name[ia]=Comparti
Name[it]=Condivisione
Name[ka]=გაზიარება
Name[nl]=Gedeelde
Name[pl]=Udostępnij
Name[sl]=Deli
Name[tr]=Paylaş
Name[uk]=Оприлюднення
Name[x-test]=xxSharexx
Name[zh_TW]=分享
Comment=The result of sharing a piece of content
Comment[ca]=El resultat de compartir una peça de contingut
Comment[ca@valencia]=El resultat de compartir una peça de contingut
Comment[eo]=La rezulto el kundividado de enhavero
Comment[es]=El resultado de compartir una parte de contenido
Comment[eu]=Eduki pieza bat partekatzearen emaitza
Comment[fr]=Le résultat du partage d'une partie de contenu.
Comment[ia]=Le exito de compartir un pecietta de contento
Comment[it]=Il risultato della condivisione di un contenuto
Comment[ka]=შემცველობის ნაწილის გაზიარების შედეგი
Comment[nl]=Het resultaat van het delen van een stukje inhoud
Comment[pl]=Wynik udostępniania kawałka treści
Comment[sl]=Rezultat deljenega kosa vsebine
Comment[tr]=Bir parça içerik paylaşımının sonucu
Comment[uk]=Результат оприлюднення даних
Comment[x-test]=xxThe result of sharing a piece of contentxx
Comment[zh_TW]=分享一份內容之後的結果
Action=Popup

View File

@@ -118,6 +118,10 @@
<label>Save the collapsed state of the room list</label>
<default>false</default>
</entry>
<entry name="SortOrder" type="int">
<label>The sort order for the rooms in the list.</label>
<default>1</default>
</entry>
</group>
<group name="NetworkProxy">
<entry name="ProxyType" type="Enum">
@@ -152,5 +156,11 @@
<default></default>
</entry>
</group>
<group name="FeatureFlags">
<entry name="Threads" type="bool">
<label>Enable threads</label>
<default>false</default>
</entry>
</group>
</kcfg>

View File

@@ -4,6 +4,7 @@
#include "neochatconnection.h"
#include <QImageReader>
#include <QJsonDocument>
#include "controller.h"
#include "jobs/neochatchangepasswordjob.h"
@@ -76,7 +77,9 @@ void NeoChatConnection::connectSignals()
for (const auto &chatId : additions) {
if (const auto chat = room(chatId)) {
connect(chat, &Room::unreadStatsChanged, this, [this]() {
refreshBadgeNotificationCount();
Q_EMIT directChatNotificationsChanged();
Q_EMIT directChatsHaveHighlightNotificationsChanged();
});
}
}
@@ -90,29 +93,51 @@ void NeoChatConnection::connectSignals()
if (room->isDirectChat()) {
connect(room, &Room::unreadStatsChanged, this, [this]() {
Q_EMIT directChatNotificationsChanged();
Q_EMIT directChatsHaveHighlightNotificationsChanged();
});
}
connect(room, &Room::unreadStatsChanged, this, [this]() {
refreshBadgeNotificationCount();
Q_EMIT homeNotificationsChanged();
Q_EMIT homeHaveHighlightNotificationsChanged();
});
});
connect(this, &NeoChatConnection::leftRoom, this, [this](Room *room, Room *prev) {
Q_UNUSED(room)
if (prev && prev->isDirectChat()) {
Q_EMIT directChatInvitesChanged();
Q_EMIT directChatNotificationsChanged();
Q_EMIT directChatsHaveHighlightNotificationsChanged();
}
refreshBadgeNotificationCount();
Q_EMIT homeNotificationsChanged();
Q_EMIT homeHaveHighlightNotificationsChanged();
});
connect(&SpaceHierarchyCache::instance(), &SpaceHierarchyCache::spaceHierarchyChanged, this, [this]() {
refreshBadgeNotificationCount();
Q_EMIT homeNotificationsChanged();
Q_EMIT homeHaveHighlightNotificationsChanged();
});
for (const auto room : allRooms()) {
connect(room, &NeoChatRoom::unreadStatsChanged, this, [this, room]() {
if (room != nullptr) {
auto category = NeoChatRoomType::typeForRoom(static_cast<NeoChatRoom *>(room));
if (!SpaceHierarchyCache::instance().isChild(room->id()) && (category == NeoChatRoomType::Normal || category == NeoChatRoomType::Favorite)
&& room->successorId().isEmpty()) {
Q_EMIT homeNotificationsChanged();
}
}
});
}
int NeoChatConnection::badgeNotificationCount() const
{
return m_badgeNotificationCount;
}
void NeoChatConnection::refreshBadgeNotificationCount()
{
int count = 0;
for (const auto &r : allRooms()) {
if (const auto room = static_cast<NeoChatRoom *>(r)) {
count += room->contextAwareNotificationCount();
}
}
if (count != m_badgeNotificationCount) {
m_badgeNotificationCount = count;
Q_EMIT badgeNotificationCountChanged(this, m_badgeNotificationCount);
}
}
@@ -328,7 +353,7 @@ qsizetype NeoChatConnection::directChatNotifications() const
for (const auto &chatId : directChats()) {
if (!added.contains(chatId)) {
if (const auto chat = room(chatId)) {
notifications += chat->notificationCount();
notifications += dynamic_cast<NeoChatRoom *>(chat)->contextAwareNotificationCount();
added += chatId;
}
}
@@ -336,29 +361,47 @@ qsizetype NeoChatConnection::directChatNotifications() const
return notifications;
}
bool NeoChatConnection::directChatsHaveHighlightNotifications() const
{
for (const auto &childId : directChats()) {
if (const auto child = static_cast<NeoChatRoom *>(room(childId))) {
if (child->highlightCount() > 0) {
return true;
}
}
}
return false;
}
qsizetype NeoChatConnection::homeNotifications() const
{
qsizetype notifications = 0;
QStringList added;
const auto &spaceHierarchyCache = SpaceHierarchyCache::instance();
for (const auto &room : allRooms()) {
auto category = NeoChatRoomType::typeForRoom(static_cast<NeoChatRoom *>(room));
if (!added.contains(room->id()) && room->joinState() == JoinState::Join && !room->isDirectChat() && !spaceHierarchyCache.isChild(room->id())
&& room->successorId().isEmpty()) {
switch (category) {
case NeoChatRoomType::Normal:
case NeoChatRoomType::Favorite:
notifications += room->notificationCount();
break;
default:
notifications += room->highlightCount();
for (const auto &r : allRooms()) {
if (const auto room = static_cast<NeoChatRoom *>(r)) {
if (!added.contains(room->id()) && !room->isDirectChat() && !spaceHierarchyCache.isChild(room->id())) {
notifications += dynamic_cast<NeoChatRoom *>(room)->contextAwareNotificationCount();
added += room->id();
}
added += room->id();
}
}
return notifications;
}
bool NeoChatConnection::homeHaveHighlightNotifications() const
{
const auto &spaceHierarchyCache = SpaceHierarchyCache::instance();
for (const auto &r : allRooms()) {
if (const auto room = static_cast<NeoChatRoom *>(r)) {
if (!room->isDirectChat() && !spaceHierarchyCache.isChild(room->id()) && room->highlightCount() > 0) {
return true;
}
}
}
return false;
}
bool NeoChatConnection::directChatInvites() const
{
auto inviteRooms = rooms(JoinState::Invite);
@@ -434,4 +477,14 @@ void NeoChatConnection::setIsOnline(bool isOnline)
Q_EMIT isOnlineChanged();
}
QString NeoChatConnection::accountDataJsonString(const QString &type) const
{
return QString::fromUtf8(QJsonDocument(accountDataJson(type)).toJson());
}
void NeoChatConnection::addRoom(Quotient::Room *room)
{
Connection::addRoom(room, false);
}
#include "moc_neochatconnection.cpp"

View File

@@ -32,11 +32,21 @@ class NeoChatConnection : public Quotient::Connection
*/
Q_PROPERTY(qsizetype directChatNotifications READ directChatNotifications NOTIFY directChatNotificationsChanged)
/**
* @brief Whether any direct chats have highlight notifications.
*/
Q_PROPERTY(bool directChatsHaveHighlightNotifications READ directChatsHaveHighlightNotifications NOTIFY directChatsHaveHighlightNotificationsChanged)
/**
* @brief The total number of notifications for all rooms in the home tab.
*/
Q_PROPERTY(qsizetype homeNotifications READ homeNotifications NOTIFY homeNotificationsChanged)
/**
* @brief Whether any of the rooms in the home tab have highlight notifications.
*/
Q_PROPERTY(bool homeHaveHighlightNotifications READ homeHaveHighlightNotifications NOTIFY homeHaveHighlightNotificationsChanged)
/**
* @brief Whether there is at least one invite to a direct chat.
*/
@@ -113,8 +123,19 @@ public:
*/
Q_INVOKABLE void openOrCreateDirectChat(Quotient::User *user);
/**
* @brief Get the account data with \param type as a formatted JSON string.
*/
Q_INVOKABLE QString accountDataJsonString(const QString &type) const;
qsizetype directChatNotifications() const;
bool directChatsHaveHighlightNotifications() const;
qsizetype homeNotifications() const;
bool homeHaveHighlightNotifications() const;
int badgeNotificationCount() const;
void refreshBadgeNotificationCount();
bool directChatInvites() const;
// note: this is intentionally a copied QString because
@@ -126,18 +147,29 @@ public:
bool isOnline() const;
/**
* Add room directly in the connection.
* @internal for tests
*/
void addRoom(Quotient::Room *room);
Q_SIGNALS:
void labelChanged();
void directChatNotificationsChanged();
void directChatsHaveHighlightNotificationsChanged();
void homeNotificationsChanged();
void homeHaveHighlightNotificationsChanged();
void directChatInvitesChanged();
void isOnlineChanged();
void passwordStatus(NeoChatConnection::PasswordStatus status);
void userConsentRequired(QUrl url);
void badgeNotificationCountChanged(NeoChatConnection *connection, int count);
private:
bool m_isOnline = true;
void setIsOnline(bool isOnline);
void connectSignals();
int m_badgeNotificationCount = 0;
};

View File

@@ -10,6 +10,7 @@
#include <QTemporaryFile>
#include <Quotient/jobs/basejob.h>
#include <Quotient/quotient_common.h>
#include <Quotient/user.h>
#include <qcoro/qcorosignal.h>
@@ -129,15 +130,32 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS
connect(&SpaceHierarchyCache::instance(), &SpaceHierarchyCache::spaceHierarchyChanged, this, [this]() {
if (isSpace()) {
Q_EMIT childrenNotificationCountChanged();
Q_EMIT childrenHaveHighlightNotificationsChanged();
}
});
connect(&SpaceHierarchyCache::instance(), &SpaceHierarchyCache::spaceNotifcationCountChanged, this, [this](const QStringList &spaces) {
if (spaces.contains(id())) {
Q_EMIT childrenNotificationCountChanged();
Q_EMIT childrenHaveHighlightNotificationsChanged();
}
});
}
int NeoChatRoom::contextAwareNotificationCount() const
{
// DOn't include spaces, rooms that the user hasn't joined and rooms where the user has joined the successor.
if (isSpace() || joinState() != JoinState::Join || successor(JoinState::Join) != nullptr) {
return 0;
}
if (m_currentPushNotificationState == PushNotificationState::Mute) {
return 0;
}
if (m_currentPushNotificationState == PushNotificationState::MentionKeyword || isLowPriority()) {
return int(highlightCount());
}
return int(notificationCount());
}
bool NeoChatRoom::hasFileUploading() const
{
return m_hasFileUploading;
@@ -231,6 +249,9 @@ void NeoChatRoom::acceptInvitation()
void NeoChatRoom::forget()
{
if (const auto &predecessor = dynamic_cast<NeoChatRoom *>(this->predecessor(JoinState::Join))) {
predecessor->forget();
}
connection()->forgetRoom(id());
}
@@ -239,7 +260,10 @@ QVariantList NeoChatRoom::getUsersTyping() const
auto users = usersTyping();
users.removeAll(localUser());
QVariantList userVariants;
for (User *user : users) {
for (const auto &user : users) {
if (connection()->isIgnored(user->id())) {
continue;
}
userVariants.append(QVariantMap{
{"id"_ls, user->id()},
{"avatarMediaId"_ls, user->avatarMediaId(this)},
@@ -1297,6 +1321,14 @@ qsizetype NeoChatRoom::childrenNotificationCount()
return SpaceHierarchyCache::instance().notificationCountForSpace(id());
}
bool NeoChatRoom::childrenHaveHighlightNotifications() const
{
if (!isSpace()) {
return false;
}
return SpaceHierarchyCache::instance().spaceHasHighlightNotifications(id());
}
void NeoChatRoom::addChild(const QString &childId, bool setChildParent, bool canonical, bool suggested)
{
if (!isSpace()) {

View File

@@ -133,6 +133,13 @@ class NeoChatRoom : public Quotient::Room
*/
Q_PROPERTY(qsizetype childrenNotificationCount READ childrenNotificationCount NOTIFY childrenNotificationCountChanged)
/**
* @brief Whether this room's children have any highlight notifications.
*
* Will always return false if this is not a space.
*/
Q_PROPERTY(bool childrenHaveHighlightNotifications READ childrenHaveHighlightNotifications NOTIFY childrenHaveHighlightNotificationsChanged)
/**
* @brief Whether the local user has an invite to the room.
*
@@ -405,6 +412,16 @@ public:
*/
[[nodiscard]] bool lastEventIsSpoiler() const;
/**
* @brief Return the notification count for the room accounting for tags and notification state.
*
* The following rules are observed:
* - Rooms tagged as low priority or mentions and keywords notification state
* only return the number of highlights.
* - Muted rooms always return 0.
*/
int contextAwareNotificationCount() const;
[[nodiscard]] bool hasFileUploading() const;
void setHasFileUploading(bool value);
@@ -535,6 +552,8 @@ public:
qsizetype childrenNotificationCount();
bool childrenHaveHighlightNotifications() const;
/**
* @brief Add the given room as a child.
*
@@ -825,6 +844,7 @@ Q_SIGNALS:
void canonicalParentChanged();
void lastActiveTimeChanged();
void childrenNotificationCountChanged();
void childrenHaveHighlightNotificationsChanged();
void isInviteChanged();
void readOnlyChanged();
void displayNameChanged();

35
src/qml/AccountData.qml Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.neochat
ColumnLayout {
id: root
required property NeoChatConnection connection
FormCard.FormHeader {
title: i18nc("@title:group", "Account Data")
}
FormCard.FormCard {
Repeater {
model: root.connection.accountDataEventTypes
delegate: FormCard.FormButtonDelegate {
text: modelData
onClicked: applicationWindow().pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/MessageSourceSheet.qml", {
sourceText: root.connection.accountDataJsonString(modelData)
}, {
title: i18nc("@title:window", "Event Source"),
width: Kirigami.Units.gridUnit * 25
})
}
}
}
}

View File

@@ -213,7 +213,7 @@ FormCard.FormCardPage {
FormCard.FormButtonDelegate {
id: deactivateAccountButton
text: i18n("Deactivate Account")
onClicked: pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/ConfirmDeactivateAccountDialog.qml", {
onClicked: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ConfirmDeactivateAccountDialog.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Confirm Deactivating Account")

View File

@@ -20,7 +20,7 @@ QQC2.Menu {
QQC2.MenuItem {
text: i18n("Edit this account")
icon.name: "document-edit"
onTriggered: pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/AccountEditorPage.qml", {
onTriggered: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'AccountEditorPage.qml'), {
connection: root.connection
}, {
title: i18n("Account editor")
@@ -29,7 +29,7 @@ QQC2.Menu {
QQC2.MenuItem {
text: i18n("Notification settings")
icon.name: "notifications"
onTriggered: pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/SettingsPage.qml", {
onTriggered: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'SettingsPage.qml'), {
defaultPage: "notifications",
connection: root.connection
}, {
@@ -41,7 +41,7 @@ QQC2.Menu {
QQC2.MenuItem {
text: i18n("Devices")
icon.name: "computer-symbolic"
onTriggered: pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/SettingsPage.qml", {
onTriggered: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'SettingsPage.qml'), {
defaultPage: "devices",
connection: root.connection
}, {
@@ -50,6 +50,18 @@ QQC2.Menu {
height: Kirigami.Units.gridUnit * 42
})
}
QQC2.MenuItem {
text: i18n("Open developer tools")
icon.name: "tools"
visible: Config.developerTools
onTriggered: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'DevtoolsPage.qml'), {
connection: root.connection
}, {
title: i18nc("@title:window", "Developer Tools"),
width: Kirigami.Units.gridUnit * 50,
height: Kirigami.Units.gridUnit * 42
})
}
QQC2.MenuItem {
text: i18n("Logout")
icon.name: "list-remove-user"

View File

@@ -29,7 +29,7 @@ FormCard.FormCardPage {
id: accountDelegate
required property NeoChatConnection connection
Layout.fillWidth: true
onClicked: pageStack.layers.push("qrc:/org/kde/neochat/qml/AccountEditorPage.qml", {
onClicked: applicationWindow().pageStack.layers.push(Qt.createComponent('org.kde.neochat', 'AccountEditorPage.qml'), {
connection: accountDelegate.connection
}, {
title: i18n("Account editor")
@@ -101,7 +101,7 @@ FormCard.FormCardPage {
id: addAccountDelegate
text: i18n("Add Account")
icon.name: "list-add"
onClicked: pageStack.layers.push("qrc:/org/kde/neochat/qml/WelcomePage.qml")
onClicked: applicationWindow().pageStack.layers.push(Qt.createComponent('org.kde.neochat', 'WelcomePage.qml'))
}
}

View File

@@ -221,7 +221,7 @@ FormCard.FormCardPage {
Loader {
id: colorSchemeDelegate
visible: item !== null
source: "qrc:/org/kde/neochat/qml/ColorScheme.qml"
sourceComponent: Qt.createComponent('org.kde.neochat', 'ColorScheme.qml')
Layout.fillWidth: true
}
}

View File

@@ -57,7 +57,7 @@ ColumnLayout {
*/
readonly property bool downloaded: root.fileTransferInfo && root.fileTransferInfo.completed
onDownloadedChanged: if (downloaded) {
audio.play()
audio.play();
}
/**
@@ -93,7 +93,7 @@ ColumnLayout {
target: playButton
icon.name: "media-playback-stop"
onClicked: {
root.room.cancelFileTransfer(root.eventId)
root.room.cancelFileTransfer(root.eventId);
}
}
},
@@ -105,7 +105,8 @@ ColumnLayout {
icon.name: "media-playback-start"
onClicked: {
audio.source = root.fileTransferInfo.localPath;
audio.play()
MediaManager.startPlayback();
audio.play();
}
}
},
@@ -123,6 +124,15 @@ ColumnLayout {
}
]
Connections {
target: MediaManager
function onPlaybackStarted() {
if (audio.playbackState === MediaPlayer.PlayingState) {
audio.pause();
}
}
}
RowLayout {
QQC2.ToolButton {
id: playButton

View File

@@ -106,7 +106,7 @@ QQC2.Control {
/**
* @brief Request a context menu be show for the message.
*/
signal showMessageMenu()
signal showMessageMenu
contentItem: ColumnLayout {
id: contentColumn
@@ -152,8 +152,12 @@ QQC2.Control {
timeline: root.timeline
maxContentWidth: root.maxContentWidth
onReplyClicked: (eventId) => {root.replyClicked(eventId)}
onSelectedTextChanged: (selectedText) => {root.selectedTextChanged(selectedText);}
onReplyClicked: eventId => {
root.replyClicked(eventId);
}
onSelectedTextChanged: selectedText => {
root.selectedTextChanged(selectedText);
}
onShowMessageMenu: root.showMessageMenu()
}
}

View File

@@ -43,6 +43,6 @@ LoginStep {
}
}
previousAction: Kirigami.Action {
onTriggered: root.processed("qrc:/org/kde/neochat/qml/Username.qml")
onTriggered: root.processed("Username.qml")
}
}

115
src/qml/CodeComponent.qml Normal file
View File

@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2024 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 QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.syntaxhighlighting
import org.kde.neochat
QQC2.Control {
id: root
/**
* @brief The display text of the message.
*/
required property string display
/**
* @brief The attributes of the component.
*/
required property var componentAttributes
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
/**
* @brief The user selected text has changed.
*/
signal selectedTextChanged(string selectedText)
/**
* @brief Request a context menu be show for the message.
*/
signal showMessageMenu
Layout.fillWidth: true
Layout.fillHeight: true
Layout.maximumWidth: root.maxContentWidth
topPadding: 0
bottomPadding: 0
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
ColumnLayout {
id: lineNumberColumn
spacing: 0
Repeater {
id: repeater
model: LineModel {
id: lineModel
document: codeText.textDocument
}
delegate: QQC2.Label {
id: label
required property int index
required property int docLineHeight
Layout.fillWidth: true
Layout.preferredHeight: docLineHeight
horizontalAlignment: Text.AlignRight
text: index + 1
color: Kirigami.Theme.disabledTextColor
font.family: "monospace"
}
}
}
Kirigami.Separator {
Layout.fillHeight: true
}
TextEdit {
id: codeText
Layout.fillWidth: true
topPadding: Kirigami.Units.smallSpacing
bottomPadding: Kirigami.Units.smallSpacing
text: root.display
readOnly: true
textFormat: TextEdit.PlainText
wrapMode: TextEdit.Wrap
color: Kirigami.Theme.textColor
font.family: "monospace"
Kirigami.SpellCheck.enabled: false
onWidthChanged: lineModel.resetModel()
onHeightChanged: lineModel.resetModel()
onSelectedTextChanged: root.selectedTextChanged(selectedText)
SyntaxHighlighter {
property string definitionName: Repository.definitionForName(root.componentAttributes.class).name
textEdit: definitionName == "None" ? null : codeText
definition: definitionName
}
TapHandler {
acceptedButtons: Qt.LeftButton
onLongPressed: root.showMessageMenu()
}
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
radius: Kirigami.Units.smallSpacing
}
}

View File

@@ -39,7 +39,7 @@ Loader {
}
QQC2.MenuItem {
text: room.isFavourite ? i18n("Remove from Favourites") : i18n("Add to Favourites")
text: room.isFavourite ? i18n("Remove from Favorites") : i18n("Add to Favorites")
icon.name: room.isFavourite ? "bookmark-remove" : "bookmark-new"
onTriggered: room.isFavourite ? room.removeTag("m.favourite") : room.addTag("m.favourite", 1.0)
}
@@ -121,7 +121,7 @@ Loader {
QQC2.MenuItem {
text: i18n("Room Settings")
icon.name: "configure"
onTriggered: QQC2.ApplicationWindow.window.pageStack.pushDialogLayer('qrc:/org/kde/neochat/qml/Categories.qml', {
onTriggered: QQC2.ApplicationWindow.window.pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'Categories.qml'), {
room: room,
connection: connection
}, {
@@ -195,7 +195,7 @@ Loader {
QQC2.ToolButton {
icon.name: 'settings-configure'
onClicked: {
QQC2.ApplicationWindow.window.pageStack.pushDialogLayer('qrc:/org/kde/neochat/qml/Categories.qml', {
QQC2.ApplicationWindow.window.pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'Categories.qml'), {
room: room,
connection: root.connection
}, {

View File

@@ -111,7 +111,7 @@ FormCard.FormCardPage {
visible: !chosenRoomDelegate.visible
text: i18nc("@action:button", "Pick room")
onClicked: {
let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/ExploreRoomsPage.qml", {
let dialog = pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ExploreRoomsPage.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Explore Rooms")
@@ -194,7 +194,7 @@ FormCard.FormCardPage {
}
onClicked: {
let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/ExploreRoomsPage.qml", {
let dialog = pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ExploreRoomsPage.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Explore Rooms")

View File

@@ -0,0 +1,370 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.neochat
import org.kde.neochat.config
/**
* @brief The base menu for most message types.
*
* This menu supports showing a list of actions to be shown for a particular event
* delegate in a message timeline. The menu supports both desktop and mobile menus
* with different visuals appropriate to the platform.
*
* The menu supports both a list of main actions and the ability to define sub menus
* using the nested action parameter.
*
* For event types that need alternate actions this class can be used as a base and
* the actions and nested actions can be overwritten to show the alternate items.
*/
Loader {
id: root
/**
* @brief The current connection for the account accessing the event.
*/
required property NeoChatConnection connection
/**
* @brief The matrix ID of the message event.
*/
required property string eventId
/**
* @brief The message author.
*
* This should consist of the following:
* - id - The matrix ID of the author.
* - isLocalUser - Whether the author is the local user.
* - avatarSource - The mxc URL for the author's avatar in the current room.
* - avatarMediaId - The media ID of the author's avatar.
* - avatarUrl - The mxc URL for the author's avatar.
* - displayName - The display name of the author.
* - display - The name of the author.
* - color - The color for the author.
* - object - The Quotient::User object for the author.
*
* @sa Quotient::User
*/
required property var author
/**
* @brief The display text of the message as plain text.
*/
required property string plainText
/**
* @brief The text the user currently has selected.
*/
property string selectedText: ""
/**
* @brief The list of menu item actions that have sub-actions.
*
* Each action will be instantiated as a single line that open a sub menu.
*/
property list<Kirigami.Action> nestedActions
/**
* @brief The main list of menu item actions.
*
* Each action will be instantiated as a single line in the menu.
*/
property list<Kirigami.Action> actions
/**
* Some common actions shared between menus
*/
component ViewSourceAction: Kirigami.Action {
text: i18n("View Source")
icon.name: "code-context"
onTriggered: RoomManager.viewEventSource(root.eventId)
}
component RemoveMessageAction: Kirigami.Action {
visible: author.isLocalUser || currentRoom.canSendState("redact")
text: i18n("Remove")
icon.name: "edit-delete-remove"
icon.color: "red"
onTriggered: applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'RemoveSheet.qml'), {
room: currentRoom,
eventId: eventId
}, {
title: i18nc("@title", "Remove Message"),
width: Kirigami.Units.gridUnit * 25
})
}
component ReplyMessageAction: Kirigami.Action {
text: i18n("Reply")
icon.name: "mail-replied-symbolic"
onTriggered: {
currentRoom.mainCache.replyId = eventId;
currentRoom.editCache.editId = "";
RoomManager.requestFullScreenClose();
}
}
component ReportMessageAction: Kirigami.Action {
text: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report")
icon.name: "dialog-warning-symbolic"
visible: !author.isLocalUser
onTriggered: applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReportSheet.qml'), {
room: currentRoom,
eventId: eventId
}, {
title: i18nc("@title", "Report Message"),
width: Kirigami.Units.gridUnit * 25
})
}
Component {
id: regularMenu
QQC2.Menu {
id: menu
Instantiator {
model: root.nestedActions
delegate: QQC2.Menu {
id: menuItem
visible: modelData.visible
title: modelData.text
Instantiator {
model: modelData.children
delegate: QQC2.MenuItem {
text: modelData.text
icon.name: modelData.icon.name
onTriggered: modelData.trigger()
}
onObjectAdded: (index, object) => {
menuItem.insertItem(0, object);
}
}
}
onObjectAdded: (index, object) => {
object.visible = false;
menu.addMenu(object);
}
}
Repeater {
model: root.actions
QQC2.MenuItem {
visible: modelData.visible
action: modelData
onClicked: root.item.close()
}
}
QQC2.Menu {
id: webshortcutmenu
title: i18n("Search for '%1'", webshortcutmodel.trunkatedSearchText)
property bool isVisible: webshortcutmodel.enabled
Component.onCompleted: {
webshortcutmenu.parent.visible = isVisible;
}
onIsVisibleChanged: webshortcutmenu.parent.visible = isVisible
Instantiator {
model: WebShortcutModel {
id: webshortcutmodel
selectedText: root.selectedText.length > 0 ? root.selectedText : root.plainText
onOpenUrl: RoomManager.resolveResource(url)
}
delegate: QQC2.MenuItem {
text: model.display
icon.name: model.decoration
onTriggered: webshortcutmodel.trigger(model.edit)
}
onObjectAdded: (index, object) => webshortcutmenu.insertItem(0, object)
}
QQC2.MenuSeparator {}
QQC2.MenuItem {
text: i18n("Configure Web Shortcuts...")
icon.name: "configure"
visible: !Controller.isFlatpak
onTriggered: webshortcutmodel.configureWebShortcuts()
}
}
}
}
Component {
id: mobileMenu
Kirigami.OverlayDrawer {
id: drawer
height: stackView.implicitHeight
edge: Qt.BottomEdge
padding: 0
leftPadding: 0
rightPadding: 0
bottomPadding: 0
topPadding: 0
parent: applicationWindow().overlay
QQC2.StackView {
id: stackView
width: parent.width
implicitHeight: currentItem.implicitHeight
Component {
id: nestedActionsComponent
ColumnLayout {
id: actionLayout
property string title: ""
property list<Kirigami.Action> actions
width: parent.width
spacing: 0
RowLayout {
QQC2.ToolButton {
icon.name: 'draw-arrow-back'
onClicked: stackView.pop()
}
Kirigami.Heading {
level: 3
Layout.fillWidth: true
text: actionLayout.title
wrapMode: Text.WordWrap
}
}
Repeater {
id: listViewAction
model: actionLayout.actions
FormCard.FormButtonDelegate {
icon.name: modelData.icon.name
icon.color: modelData.icon.color ?? undefined
enabled: modelData.enabled
visible: modelData.visible
text: modelData.text
onClicked: {
modelData.triggered();
root.item.close();
}
}
}
}
}
initialItem: ColumnLayout {
id: popupContent
width: parent.width
spacing: 0
RowLayout {
id: headerLayout
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
KirigamiComponents.Avatar {
id: avatar
source: author.avatarSource
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
Layout.alignment: Qt.AlignTop
}
ColumnLayout {
Layout.fillWidth: true
Kirigami.Heading {
level: 3
Layout.fillWidth: true
text: currentRoom.htmlSafeMemberName(author.id)
wrapMode: Text.WordWrap
}
QQC2.Label {
text: plainText
Layout.fillWidth: true
wrapMode: Text.WordWrap
onLinkActivated: RoomManager.resolveResource(link, "join")
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
RowLayout {
spacing: 0
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5
Repeater {
model: ["👍", "👎️", "😄", "🎉", "🚀", "👀"]
delegate: QQC2.ItemDelegate {
Layout.fillWidth: true
Layout.fillHeight: true
contentItem: Kirigami.Heading {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: "emoji"
text: modelData
}
onClicked: {
currentRoom.toggleReaction(eventId, modelData);
root.item.close();
}
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
Repeater {
id: listViewAction
model: root.actions
FormCard.FormButtonDelegate {
icon.name: modelData.icon.name
icon.color: modelData.icon.color ?? undefined
enabled: modelData.enabled
visible: modelData.visible
text: modelData.text
onClicked: {
modelData.triggered();
root.item.close();
}
}
}
Repeater {
model: root.nestedActions
FormCard.FormButtonDelegate {
action: modelData
visible: modelData.visible
onClicked: {
stackView.push(nestedActionsComponent, {
title: modelData.text,
actions: modelData.children
});
}
}
}
}
}
}
}
asynchronous: true
sourceComponent: Kirigami.Settings.isMobile ? mobileMenu : regularMenu
function open() {
active = true;
}
onStatusChanged: if (status == Loader.Ready) {
if (Kirigami.Settings.isMobile) {
item.open();
} else {
item.popup();
}
}
}

View File

@@ -29,6 +29,12 @@ FormCard.FormCardPage {
QQC2.TabButton {
text: qsTr("Server Info")
}
QQC2.TabButton {
text: i18nc("@title:tab", "Account Data")
}
QQC2.TabButton {
text: i18nc("@title:tab", "Feature Flags")
}
}
StackLayout {
@@ -43,5 +49,9 @@ FormCard.FormCardPage {
ServerData {
connection: root.connection
}
AccountData {
connection: root.connection
}
FeatureFlagPage {}
}
}

View File

@@ -55,6 +55,6 @@ LoginStep {
}
}
previousAction: Kirigami.Action {
onTriggered: root.processed("qrc:/org/kde/neochat/qml/Username.qml")
onTriggered: root.processed("Username.qml")
}
}

View File

@@ -60,7 +60,7 @@ QQC2.Popup {
padding: 2
implicitHeight: Kirigami.Units.gridUnit * 20 + 2 * padding
width: Math.min(contentItem.categoryIconSize * 11 + 2 * padding, QQC2.Overlay.overlay.width)
width: Math.min(contentItem.categoryIconSize * 11 + 2 * padding, applicationWindow().width)
contentItem: EmojiPicker {
id: emojiPicker
height: 400

View File

@@ -23,7 +23,7 @@ RowLayout {
text: i18n("Explore rooms")
icon.name: "compass"
onTriggered: {
let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/ExploreRoomsPage.qml", {
let dialog = pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ExploreRoomsPage.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Explore Rooms")
@@ -36,7 +36,7 @@ RowLayout {
property Kirigami.Action chatAction: Kirigami.Action {
text: i18n("Find your friends")
icon.name: "list-add-user"
onTriggered: pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/UserSearchPage.qml", {
onTriggered: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'UserSearchPage.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Find your friends")
@@ -46,7 +46,7 @@ RowLayout {
text: i18n("Create a Room")
icon.name: "system-users-symbolic"
onTriggered: {
pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateRoomDialog.qml", {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Create a Room")
@@ -58,7 +58,7 @@ RowLayout {
text: i18n("Create a Space")
icon.name: "list-add"
onTriggered: {
pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateRoomDialog.qml", {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog.qml'), {
connection: root.connection,
isSpace: true,
title: i18nc("@title", "Create a Space")

View File

@@ -52,7 +52,7 @@ ColumnLayout {
text: i18n("Explore rooms")
icon.name: "compass"
onTriggered: {
let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/ExploreRoomsPage.qml", {
let dialog = pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ExploreRoomsPage.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Explore Rooms")
@@ -67,7 +67,7 @@ ColumnLayout {
text: i18n("Find your friends")
icon.name: "list-add-user"
onTriggered: {
pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/UserSearchPage.qml", {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'UserSearchPage.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Find your friends")
@@ -140,7 +140,7 @@ ColumnLayout {
text: i18n("Create a Room")
icon.name: "system-users-symbolic"
onTriggered: {
pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateRoomDialog.qml", {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomPage.qml'), {
connection: root.connection
}, {
title: i18nc("@title", "Create a Room")
@@ -156,7 +156,7 @@ ColumnLayout {
text: i18n("Create a Space")
icon.name: "list-add"
onTriggered: {
pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateRoomDialog.qml", {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog.qml'), {
connection: root.connection,
isSpace: true,
title: i18nc("@title", "Create a Space")

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