Compare commits

..

30 Commits

Author SHA1 Message Date
Tomaz Canabrava
0083199493 Fix crash on Qt6/KF6 2023-10-02 17:39:05 +00:00
Tobias Fella
d4cb27eca4 Make singletons owned by the C++ side 2023-10-02 16:29:04 +00:00
Fushan Wen
541350e678 appiumtests: port away from deprecated desired_capabilities
AppiumOptions replaces it
2023-10-02 15:48:58 +00:00
l10n daemon script
843deefaf8 GIT_SILENT Sync po/docbooks with svn 2023-10-02 02:16:12 +00:00
James Graham
070d579bc2 Restore the show author functionality to bubble 2023-10-01 14:05:42 +00:00
l10n daemon script
add283c9fb GIT_SILENT Sync po/docbooks with svn 2023-10-01 02:30:40 +00:00
James Graham
fe4230b5fd Use variable placeholder instead of string concatenation 2023-09-30 10:54:07 +01:00
l10n daemon script
e8f40d98de GIT_SILENT Sync po/docbooks with svn 2023-09-30 02:13:47 +00:00
James Graham
eba62103a4 Remove Space Child
Add button to remove a child in a space if the user has the correct power levels
2023-09-29 20:15:17 +00:00
Tobias Fella
925393deab Add type registration for KeyVerificationSession 2023-09-29 19:26:41 +00:00
Laurent Montel
abe881caf7 Add missing include moc 2023-09-29 13:45:46 +02:00
Yuri Chornoivan
237a3c9dfb Fix minor typo 2023-09-29 09:08:37 +03:00
l10n daemon script
9715440854 GIT_SILENT Sync po/docbooks with svn 2023-09-29 02:10:20 +00:00
James Graham
ecdad9f965 Space Home Page
Add a space homepage with the ability to both create new room and add existing rooms to the space. This uses a tree model for the space hierarchy and will go to any number of levels. The user should only see the add options if they have appropriate permissions.

This MR also combines the create space and room pages and adds a lot of optional functionality for managing space children.

![image](/uploads/1764b0319241ff870dc39b18b39f5d51/image.png)
2023-09-28 17:36:23 +00:00
Carl Schwan
08711fc927 Fix missing renaming in roomlastmessageprovider 2023-09-28 10:38:31 +02:00
Carl Schwan
e44cd405b7 Fix import name 2023-09-28 10:31:14 +02:00
Carl Schwan
8945e004e2 Optimize room config 2023-09-28 07:37:22 +00:00
Janet Blackquill
c04d8d6f59 Redraw tray icon
It seems at some point in time the 16x16 tray icon got lost/hastily upscaled to a 22x22 tray icon,
which resulted in proportions as well as icon guidelines being slightly off.

This replaces the tray icon with a new one redrawn to adhere to icon guidelines and proportions closer
to the colour icon.
2023-09-27 23:43:55 -04:00
l10n daemon script
58a73c0208 GIT_SILENT Sync po/docbooks with svn 2023-09-28 02:11:52 +00:00
Joshua Goins
852110debd Make it clear that the session is broken when the keys are lost
If you use your private keys (like when deleting the quotient database)
your session is broken as you have differing keys on the server. While
it is possible to work your way out of it, it's better to warn users to
bite the bullet and log in again.
2023-09-27 15:13:06 -04:00
Joshua Goins
6b71d3c78d Make the key verification message horizontally centered 2023-09-27 15:13:00 -04:00
Christophe Marin
f3a0adee39 Fix manpage installation 2023-09-27 16:23:29 +02:00
l10n daemon script
6e7b6c9ce0 GIT_SILENT Sync po/docbooks with svn 2023-09-27 02:12:21 +00:00
James Graham
f67cd7deb5 Remove the now unused author ID role from MessageEventModel
Remove the now unused author ID role from `MessageEventModel`. This can be obtained from the author roles object.
2023-09-26 20:21:08 +00:00
l10n daemon script
931b4b1f9a GIT_SILENT Sync po/docbooks with svn 2023-09-26 02:22:24 +00:00
l10n daemon script
167ed4eca3 GIT_SILENT made messages (after extraction) 2023-09-26 01:46:11 +00:00
l10n daemon script
7d5b2c1b6a GIT_SILENT Sync po/docbooks with svn 2023-09-25 02:15:50 +00:00
l10n daemon script
be7b1e49b4 GIT_SILENT Sync po/docbooks with svn 2023-09-24 02:09:06 +00:00
Tobias Fella
957419070a Remove unused includes 2023-09-23 22:43:48 +02:00
Carl Schwan
f22107c8ab Colorful emoji in reaction
Use ICU to determine if the string contains only emojis
2023-09-23 22:16:11 +02:00
93 changed files with 99155 additions and 94257 deletions

View File

@@ -63,9 +63,9 @@ set_package_properties(KF6 PROPERTIES
PURPOSE "Basic application components"
)
set_package_properties(KF6Kirigami2 PROPERTIES
TYPE REQUIRED
PURPOSE "Kirigami application UI framework"
)
TYPE REQUIRED
PURPOSE "Kirigami application UI framework"
)
find_package(KF6KirigamiAddons 0.7.2 REQUIRED)
if(ANDROID)
@@ -81,6 +81,12 @@ else()
TYPE RUNTIME
)
ecm_find_qmlmodule(org.kde.syntaxhighlighting 1.0)
find_package(ICU 61.0 COMPONENTS uc)
set_package_properties(ICU PROPERTIES
TYPE REQUIRED
PURPOSE "Unicode library"
)
endif()
if (NOT ANDROID AND NOT WIN32 AND NOT APPLE)

View File

@@ -4,25 +4,26 @@
# SPDX-FileCopyrightText: 2021-2022 Harald Sitter <sitter@kde.org>
# SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
import unittest
from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
import os
import time
import subprocess
import sys
import unittest
from appium import webdriver
from appium.options.common.base import AppiumOptions
from appium.webdriver.common.appiumby import AppiumBy
class LoginTest(unittest.TestCase):
mockServerProcess: subprocess.Popen
@classmethod
def setUpClass(self):
desired_caps = {}
desired_caps["app"] = "neochat --ignore-ssl-errors"
desired_caps["timeouts"] = {'implicit': 10000}
self.driver = webdriver.Remote(
command_executor='http://127.0.0.1:4723',
desired_capabilities=desired_caps)
self.mockServerProcess = subprocess.Popen([sys.executable, os.path.join(os.path.dirname(__file__), "login-server.py")])
def setUpClass(cls):
options = AppiumOptions()
options.set_capability("app", "neochat --ignore-ssl-errors")
cls.driver = webdriver.Remote(command_executor='http://127.0.0.1:4723', options=options)
cls.mockServerProcess = subprocess.Popen([sys.executable, os.path.join(os.path.dirname(__file__), "login-server.py")])
def setUp(self):
pass
@@ -45,5 +46,6 @@ class LoginTest(unittest.TestCase):
self.driver.find_element(by=AppiumBy.NAME, value="Login").click()
self.driver.find_element(by=AppiumBy.NAME, value="Join some rooms to get started").click()
if __name__ == '__main__':
unittest.main()

View File

@@ -2,4 +2,4 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
kdoctools_create_manpage(man-neochat.1.docbook 1 INSTALL_DESTINATION ${MAN_INSTALL_DIR})
kdoctools_create_manpage(man-neochat.1.docbook 1 INSTALL_DESTINATION ${KDE_INSTALL_MANDIR})

View File

@@ -53,6 +53,7 @@
<summary xml:lang="eo">Babilu kun viaj amikoj sur matrix</summary>
<summary xml:lang="es">Charle con sus amigos en matrix</summary>
<summary xml:lang="eu">Berriketan jardun zure lagunekin «Matrix»en</summary>
<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>

View File

@@ -1 +1,8 @@
<svg width="22" height="22" fill="none" version="1.1" id="svg13" xmlns="http://www.w3.org/2000/svg"><style type="text/css" id="current-color-scheme">.ColorScheme-Text{color:#232629}</style><path class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none" fill-rule="evenodd" clip-rule="evenodd" d="M2 4h18v11H6.681L3 18.067V15H2zm1 10h1v1.933L6.319 14H19V5H3z" id="path3"/><path class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none" id="rect5" d="M4 7h9v1H4z"/><path class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none" id="rect7" d="M4 9h7v1H4z"/><path class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none" id="rect9" d="M4 11h5v1H4z"/><path class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none" fill-rule="evenodd" clip-rule="evenodd" d="m16 15.293-1.147-1.146-.707.707 2.853 2.853V14.5h-1z" id="path11"/></svg>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text{color:#232629}</style>
<path class="ColorScheme-Text" fill-rule="evenodd" clip-rule="evenodd" d="M3 3H19V14H8.68787L4 18.1019V14H3V3ZM4 13H5V15.8981L8.31213 13H18V4H4V13Z" fill="currentColor"/>
<path class="ColorScheme-Text" fill-rule="evenodd" clip-rule="evenodd" d="M17 15.2929L14.8536 13.1465L14.1465 13.8536L18 17.7071V13.5H17V15.2929Z" fill="currentColor"/>
<path class="ColorScheme-Text" d="M5 6H15V7H5V6Z" fill="currentColor"/>
<path class="ColorScheme-Text" d="M5 8H13V9H5V8Z" fill="currentColor"/>
<path class="ColorScheme-Text" d="M5 10H11V11H5V10Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 928 B

After

Width:  |  Height:  |  Size: 752 B

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

@@ -40,6 +40,12 @@ add_library(neochat STATIC
models/userfiltermodel.h
models/publicroomlistmodel.cpp
models/publicroomlistmodel.h
models/spacechildrenmodel.cpp
models/spacechildrenmodel.h
models/spacechildsortfiltermodel.cpp
models/spacechildsortfiltermodel.h
models/spacetreeitem.cpp
models/spacetreeitem.h
models/userdirectorylistmodel.cpp
models/userdirectorylistmodel.h
models/pushrulemodel.cpp
@@ -127,6 +133,8 @@ add_library(neochat STATIC
mediasizehelper.h
eventhandler.cpp
enums/delegatetype.h
roomlastmessageprovider.cpp
roomlastmessageprovider.h
)
qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
@@ -207,7 +215,6 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
qml/Sso.qml
qml/UserDetailDialog.qml
qml/CreateRoomDialog.qml
qml/CreateSpaceDialog.qml
qml/EmojiDialog.qml
qml/OpenFileDialog.qml
qml/KeyVerificationDialog.qml
@@ -271,6 +278,9 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
qml/RoomMedia.qml
qml/ChooseRoomDialog.qml
qml/ShareAction.qml
qml/SpaceHomePage.qml
qml/SpaceHierarchyDelegate.qml
qml/RemoveChildDialog.qml
RESOURCES
qml/confetti.png
qml/glowdot.png
@@ -319,9 +329,10 @@ if(NOT ANDROID)
else()
target_sources(neochat PRIVATE trayicon.cpp trayicon.h)
endif()
target_link_libraries(neochat PUBLIC KF6::ConfigWidgets KF6::WindowSystem)
target_link_libraries(neochat PUBLIC KF6::ConfigWidgets KF6::WindowSystem ICU::uc)
target_compile_definitions(neochat PUBLIC -DHAVE_COLORSCHEME)
target_compile_definitions(neochat PUBLIC -DHAVE_WINDOWSYSTEM)
target_compile_definitions(neochat PUBLIC -DHAVE_ICU)
endif()
if (NOT ANDROID AND NOT WIN32 AND NOT APPLE)

View File

@@ -93,8 +93,9 @@ public:
Q_ENUM(PasswordStatus)
static Controller &instance();
static Controller *create(QQmlEngine *, QJSEngine *)
static Controller *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}

View File

@@ -231,8 +231,11 @@ bool EventHandler::isHidden()
}
}
if (m_event->isStateEvent() && eventCast<const StateEvent>(m_event)->repeatsState()) {
return true;
if (m_event->isStateEvent()) {
auto *stateEvent = eventCast<const StateEvent>(m_event);
if (stateEvent && stateEvent->repeatsState()) {
return true;
}
}
// isReplacement?
@@ -986,3 +989,5 @@ QString EventHandler::getReadMarkersString() const
readMarkersString.chop(2);
return readMarkersString;
}
#include "moc_eventhandler.cpp"

View File

@@ -34,69 +34,25 @@
#include "neochat-version.h"
#include <Quotient/accountregistry.h>
#include <Quotient/keyverificationsession.h>
#include <Quotient/networkaccessmanager.h>
#include <Quotient/room.h>
#include <Quotient/user.h>
#include <Quotient/util.h>
#include "actionshandler.h"
#include "blurhashimageprovider.h"
#include "chatdocumenthandler.h"
#include "controller.h"
#include "delegatesizehelper.h"
#include "enums/delegatetype.h"
#include "linkpreviewer.h"
#include "locationhelper.h"
#include "logger.h"
#include "login.h"
#include "matriximageprovider.h"
#include "mediasizehelper.h"
#include "models/accountemoticonmodel.h"
#include "models/customemojimodel.h"
#include "models/devicesmodel.h"
#include "models/devicesproxymodel.h"
#include "models/emojimodel.h"
#include "models/emoticonfiltermodel.h"
#include "models/imagepacksmodel.h"
#include "models/livelocationsmodel.h"
#include "models/locationsmodel.h"
#include "models/mediamessagefiltermodel.h"
#include "models/messageeventmodel.h"
#include "models/messagefiltermodel.h"
#include "models/publicroomlistmodel.h"
#include "models/pushrulemodel.h"
#include "models/reactionmodel.h"
#include "models/roomlistmodel.h"
#include "models/searchmodel.h"
#include "models/serverlistmodel.h"
#include "models/sortfilterroomlistmodel.h"
#include "models/sortfilterspacelistmodel.h"
#include "models/statefiltermodel.h"
#include "models/stickermodel.h"
#include "models/userdirectorylistmodel.h"
#include "models/userfiltermodel.h"
#include "models/userlistmodel.h"
#include "models/webshortcutmodel.h"
#include "neochatconfig.h"
#include "pollhandler.h"
#include "roommanager.h"
#include "spacehierarchycache.h"
#include "urlhelper.h"
#include "windowcontroller.h"
#ifdef HAVE_COLORSCHEME
#include "colorschemer.h"
#endif
#include "models/completionmodel.h"
#include "models/statemodel.h"
#ifdef HAVE_RUNNER
#include "runner.h"
#include <QDBusConnection>
#endif
#include "registration.h"
#ifdef Q_OS_WINDOWS
#include <Windows.h>
@@ -223,7 +179,7 @@ int main(int argc, char *argv[])
qmlRegisterSingletonInstance("org.kde.neochat.config", 1, 0, "Config", NeoChatConfig::self());
qmlRegisterSingletonInstance("org.kde.neochat.accounts", 1, 0, "AccountRegistry", &Controller::instance().accounts());
// qmlRegisterUncreatableType<KeyVerificationSession>("org.kde.neochat", 1, 0, "KeyVerificationSession", {});
qmlRegisterUncreatableType<KeyVerificationSession>("com.github.quotient_im.libquotient", 1, 0, "KeyVerificationSession", {});
QQmlApplicationEngine engine;

View File

@@ -51,8 +51,9 @@ public:
static CustomEmojiModel _instance;
return _instance;
}
static CustomEmojiModel *create(QQmlEngine *, QJSEngine *)
static CustomEmojiModel *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}

View File

@@ -86,8 +86,9 @@ public:
static EmojiModel _instance;
return _instance;
}
static EmojiModel *create(QQmlEngine *, QJSEngine *)
static EmojiModel *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}

View File

@@ -55,7 +55,6 @@ QHash<int, QByteArray> MessageEventModel::roleNames() const
roles[ShowReadMarkersRole] = "showReadMarkers";
roles[ReactionRole] = "reaction";
roles[ShowReactionsRole] = "showReactions";
roles[AuthorIdRole] = "authorId";
roles[VerifiedRole] = "verified";
roles[AuthorDisplayNameRole] = "authorDisplayName";
roles[IsRedactedRole] = "isRedacted";
@@ -659,10 +658,6 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
return m_reactionModels.contains(evt.id());
}
if (role == AuthorIdRole) {
return evt.senderId();
}
if (role == VerifiedRole) {
if (evt.originalEvent()) {
auto encrypted = dynamic_cast<const EncryptedEvent *>(evt.originalEvent());

View File

@@ -73,8 +73,6 @@ public:
ReactionRole, /**< List model for this event. */
ShowReactionsRole, /**< Whether there are any reactions to be shown. */
AuthorIdRole, /**< Matrix ID of the message author. */
VerifiedRole, /**< Whether an encrypted message is sent in a verified session. */
AuthorDisplayNameRole, /**< The displayname for the event's sender; for name change events, the old displayname. */
IsRedactedRole, /**< Whether an event has been deleted. */

View File

@@ -4,6 +4,12 @@
#include "reactionmodel.h"
#include <QDebug>
#ifdef HAVE_ICU
#include <QTextBoundaryFinder>
#include <QTextCharFormat>
#include <unicode/uchar.h>
#include <unicode/urename.h>
#endif
#include <KLocalizedString>
@@ -29,11 +35,38 @@ QVariant ReactionModel::data(const QModelIndex &index, int role) const
const auto &reaction = m_reactions.at(index.row());
if (role == TextRole) {
const auto isEmoji = [](const QString &text) {
#ifdef HAVE_ICU
QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
int from = 0;
while (finder.toNextBoundary() != -1) {
auto to = finder.position();
if (text[from].isSpace()) {
from = to;
continue;
}
auto first = text.mid(from, to - from).toUcs4()[0];
if (!u_hasBinaryProperty(first, UCHAR_EMOJI_PRESENTATION)) {
return false;
}
from = to;
}
return true;
#else
return false;
#endif
};
const auto reactionText = isEmoji(reaction.reaction)
? QStringLiteral("<span style=\"font-family: 'emoji';\">") + reaction.reaction + QStringLiteral("</span>")
: reaction.reaction;
if (role == TextContentRole) {
if (reaction.authors.count() > 1) {
return QStringLiteral("%1 %2").arg(reaction.reaction, QString::number(reaction.authors.count()));
return QStringLiteral("%1 %2").arg(reactionText, QString::number(reaction.authors.count()));
} else {
return reaction.reaction;
return reactionText;
}
}
@@ -64,7 +97,7 @@ QVariant ReactionModel::data(const QModelIndex &index, int role) const
"%2 reacted with %3",
reaction.authors.count(),
text,
reaction.reaction);
reactionText);
return text;
}
@@ -101,7 +134,7 @@ void ReactionModel::setReactions(QList<Reaction> reactions)
QHash<int, QByteArray> ReactionModel::roleNames() const
{
return {
{TextRole, "text"},
{TextContentRole, "textContent"},
{ReactionRole, "reaction"},
{ToolTipRole, "toolTip"},
{AuthorsRole, "authors"},

View File

@@ -34,7 +34,7 @@ public:
* @brief Defines the model roles.
*/
enum Roles {
TextRole = Qt::DisplayRole, /**< The text to show in the reaction. */
TextContentRole = Qt::DisplayRole, /**< The text to show in the reaction. */
ReactionRole, /**< The reaction emoji. */
ToolTipRole, /**< The tool tip to show for the reaction. */
AuthorsRole, /**< The list of authors who sent the given reaction. */

View File

@@ -0,0 +1,334 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "spacechildrenmodel.h"
#include <Quotient/connection.h>
#include <Quotient/jobs/basejob.h>
#include <Quotient/room.h>
#include "controller.h"
SpaceChildrenModel::SpaceChildrenModel(QObject *parent)
: QAbstractItemModel(parent)
{
m_rootItem = new SpaceTreeItem();
}
SpaceChildrenModel::~SpaceChildrenModel()
{
delete m_rootItem;
}
NeoChatRoom *SpaceChildrenModel::space() const
{
return m_space;
}
void SpaceChildrenModel::setSpace(NeoChatRoom *space)
{
if (space == m_space) {
return;
}
// disconnect the new room signal from the old connection in case it is different.
if (m_space != nullptr) {
disconnect(m_space->connection(), &Quotient::Connection::loadedRoomState, this, nullptr);
}
m_space = space;
Q_EMIT spaceChanged();
for (auto job : m_currentJobs) {
if (job) {
job->abandon();
}
}
m_currentJobs.clear();
auto connection = m_space->connection();
connect(connection, &Quotient::Connection::loadedRoomState, this, [this](Quotient::Room *room) {
if (m_pendingChildren.contains(room->name())) {
m_pendingChildren.removeAll(room->name());
refreshModel();
}
});
connect(m_space, &Quotient::Room::changed, this, [this]() {
refreshModel();
});
refreshModel();
}
bool SpaceChildrenModel::loading() const
{
return m_loading;
}
void SpaceChildrenModel::refreshModel()
{
beginResetModel();
m_replacedRooms.clear();
delete m_rootItem;
m_loading = true;
Q_EMIT loadingChanged();
m_rootItem = new SpaceTreeItem(nullptr, m_space->id(), m_space->displayName(), m_space->canonicalAlias());
endResetModel();
auto job = m_space->connection()->callApi<Quotient::GetSpaceHierarchyJob>(m_space->id(), Quotient::none, Quotient::none, 1);
m_currentJobs.append(job);
connect(job, &Quotient::BaseJob::success, this, [this, job]() {
insertChildren(job->rooms());
});
}
void SpaceChildrenModel::insertChildren(std::vector<Quotient::GetSpaceHierarchyJob::ChildRoomsChunk> children, const QModelIndex &parent)
{
SpaceTreeItem *parentItem = getItem(parent);
if (children[0].roomId == m_space->id() || children[0].roomId == parentItem->id()) {
children.erase(children.begin());
}
// If this is the first set of children added to the root item then we need to
// set it so that we are no longer loading.
if (rowCount(QModelIndex()) == 0 && !children.empty()) {
m_loading = false;
Q_EMIT loadingChanged();
}
beginInsertRows(parent, parentItem->childCount(), parentItem->childCount() + children.size() - 1);
for (unsigned long i = 0; i < children.size(); ++i) {
if (children[i].roomId == m_space->id() || children[i].roomId == parentItem->id()) {
continue;
} else {
int insertRow = parentItem->childCount();
if (const auto room = m_space->connection()->room(children[i].roomId)) {
const auto predecessorId = room->predecessorId();
if (!predecessorId.isEmpty()) {
m_replacedRooms += predecessorId;
}
const auto successorId = room->successorId();
if (!successorId.isEmpty()) {
m_replacedRooms += successorId;
}
}
parentItem->insertChild(insertRow,
new SpaceTreeItem(parentItem,
children[i].roomId,
children[i].name,
children[i].canonicalAlias,
children[i].topic,
children[i].numJoinedMembers,
children[i].avatarUrl,
children[i].guestCanJoin,
children[i].worldReadable,
children[i].roomType == QLatin1String("m.space")));
if (children[i].childrenState.size() > 0) {
auto job = m_space->connection()->callApi<Quotient::GetSpaceHierarchyJob>(children[i].roomId, Quotient::none, Quotient::none, 1);
m_currentJobs.append(job);
connect(job, &Quotient::BaseJob::success, this, [this, parent, insertRow, job]() {
insertChildren(job->rooms(), index(insertRow, 0, parent));
});
}
}
}
endInsertRows();
}
SpaceTreeItem *SpaceChildrenModel::getItem(const QModelIndex &index) const
{
if (index.isValid()) {
SpaceTreeItem *item = static_cast<SpaceTreeItem *>(index.internalPointer());
if (item) {
return item;
}
}
return m_rootItem;
}
QVariant SpaceChildrenModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid()) {
return QVariant();
}
SpaceTreeItem *child = getItem(index);
if (role == DisplayNameRole) {
auto displayName = child->name();
if (!displayName.isEmpty()) {
return displayName;
}
displayName = child->canonicalAlias();
if (!displayName.isEmpty()) {
return displayName;
}
return child->id();
}
if (role == AvatarUrlRole) {
return child->avatarUrl();
}
if (role == TopicRole) {
return child->topic();
}
if (role == RoomIDRole) {
return child->id();
}
if (role == AliasRole) {
return child->canonicalAlias();
}
if (role == MemberCountRole) {
return child->memberCount();
}
if (role == AllowGuestsRole) {
return child->allowGuests();
}
if (role == WorldReadableRole) {
return child->worldReadable();
}
if (role == IsJoinedRole) {
return child->isJoined();
}
if (role == IsSpaceRole) {
return child->isSpace();
}
if (role == CanAddChildrenRole) {
if (const auto room = static_cast<NeoChatRoom *>(m_space->connection()->room(child->id()))) {
return room->canSendState(QLatin1String("m.space.child"));
}
return false;
}
if (role == ParentDisplayNameRole) {
const auto parent = child->parentItem();
auto displayName = parent->name();
if (!displayName.isEmpty()) {
return displayName;
}
displayName = parent->canonicalAlias();
if (!displayName.isEmpty()) {
return displayName;
}
return parent->id();
}
if (role == CanSetParentRole) {
if (const auto room = static_cast<NeoChatRoom *>(m_space->connection()->room(child->id()))) {
return room->canSendState(QLatin1String("m.space.parent"));
}
return false;
}
if (role == IsDeclaredParentRole) {
if (const auto room = static_cast<NeoChatRoom *>(m_space->connection()->room(child->id()))) {
return room->currentState().contains(QLatin1String("m.space.parent"), child->parentItem()->id());
}
return false;
}
if (role == CanRemove) {
const auto parent = child->parentItem();
if (const auto room = static_cast<NeoChatRoom *>(m_space->connection()->room(parent->id()))) {
return room->canSendState(QLatin1String("m.space.child"));
}
return false;
}
if (role == ParentRoomRole) {
if (const auto parentRoom = static_cast<NeoChatRoom *>(m_space->connection()->room(child->parentItem()->id()))) {
return QVariant::fromValue(parentRoom);
}
return QVariant::fromValue(nullptr);
}
return {};
}
QModelIndex SpaceChildrenModel::index(int row, int column, const QModelIndex &parent) const
{
if (!hasIndex(row, column, parent)) {
return QModelIndex();
}
SpaceTreeItem *parentItem = getItem(parent);
if (!parentItem) {
return QModelIndex();
}
SpaceTreeItem *childItem = parentItem->child(row);
if (childItem) {
return createIndex(row, column, childItem);
}
return QModelIndex();
}
QModelIndex SpaceChildrenModel::parent(const QModelIndex &index) const
{
if (!index.isValid()) {
return QModelIndex();
}
SpaceTreeItem *childItem = static_cast<SpaceTreeItem *>(index.internalPointer());
SpaceTreeItem *parentItem = childItem->parentItem();
if (parentItem == m_rootItem) {
return QModelIndex();
}
return createIndex(parentItem->row(), 0, parentItem);
}
int SpaceChildrenModel::rowCount(const QModelIndex &parent) const
{
SpaceTreeItem *parentItem;
if (parent.column() > 0) {
return 0;
}
if (!parent.isValid()) {
parentItem = m_rootItem;
} else {
parentItem = static_cast<SpaceTreeItem *>(parent.internalPointer());
}
return parentItem->childCount();
}
int SpaceChildrenModel::columnCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return 1;
}
QHash<int, QByteArray> SpaceChildrenModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[DisplayNameRole] = "displayName";
roles[AvatarUrlRole] = "avatarUrl";
roles[TopicRole] = "topic";
roles[RoomIDRole] = "roomId";
roles[MemberCountRole] = "memberCount";
roles[AllowGuestsRole] = "allowGuests";
roles[WorldReadableRole] = "worldReadable";
roles[IsJoinedRole] = "isJoined";
roles[AliasRole] = "alias";
roles[IsSpaceRole] = "isSpace";
roles[CanAddChildrenRole] = "canAddChildren";
roles[ParentDisplayNameRole] = "parentDisplayName";
roles[CanSetParentRole] = "canSetParent";
roles[IsDeclaredParentRole] = "isDeclaredParent";
roles[CanRemove] = "canRemove";
roles[ParentRoomRole] = "parentRoom";
return roles;
}
bool SpaceChildrenModel::isRoomReplaced(const QString &roomId) const
{
return m_replacedRooms.contains(roomId);
}
void SpaceChildrenModel::addPendingChild(const QString &childName)
{
m_pendingChildren += childName;
}
#include "moc_spacechildrenmodel.cpp"

View File

@@ -0,0 +1,144 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QAbstractItemModel>
#include <QQmlEngine>
#include <Quotient/csapi/space_hierarchy.h>
#include <qtmetamacros.h>
#include "neochatroom.h"
#include "spacetreeitem.h"
/**
* @class SpaceChildrenModel
*
* Create a model that contains a list of the child rooms for any given space id.
*/
class SpaceChildrenModel : public QAbstractItemModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The current space that the hierarchy is being generated for.
*/
Q_PROPERTY(NeoChatRoom *space READ space WRITE setSpace NOTIFY spaceChanged)
/**
* @brief Whether the model is loading the initial set of children.
*/
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
public:
enum Roles {
DisplayNameRole = Qt::DisplayRole,
AvatarUrlRole,
TopicRole,
RoomIDRole,
AliasRole,
MemberCountRole,
AllowGuestsRole,
WorldReadableRole,
IsJoinedRole,
IsSpaceRole,
CanAddChildrenRole,
ParentDisplayNameRole,
CanSetParentRole,
IsDeclaredParentRole,
CanRemove,
ParentRoomRole,
};
explicit SpaceChildrenModel(QObject *parent = nullptr);
~SpaceChildrenModel();
NeoChatRoom *space() const;
void setSpace(NeoChatRoom *space);
bool loading() const;
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
QVariant data(const QModelIndex &index, int role = DisplayNameRole) const override;
/**
* @brief Returns the index of the item in the model specified by the given row, column and parent index.
*
* @sa QAbstractItemModel::index
*/
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns the parent of the model item with the given index.
*
* If the item has no parent, an invalid QModelIndex is returned.
*
* @sa QAbstractItemModel::parent
*/
QModelIndex parent(const QModelIndex &index) const override;
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Number of columns in the model.
*
* @sa QAbstractItemModel::columnCount
*/
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
QHash<int, QByteArray> roleNames() const override;
/**
* @brief Whether the room has been replaced.
*
* @note This information is only available if the local user is either a member
* of the replaced room or is a member of the successor room as currently
* there is no other way to obtain the required information.
*/
bool isRoomReplaced(const QString &roomId) const;
/**
* @brief Add the name of new child room that is expected to be added soon.
*
* A pending child is one where Quotient::Connection::createRoom has been called
* but the room hasn't synced with the server yet. This list is used to check
* whether a new room loading should trigger a refresh of the model, as we only
* want to trigger a refresh if the loading room is part of this space.
*/
Q_INVOKABLE void addPendingChild(const QString &childName);
Q_SIGNALS:
void spaceChanged();
void loadingChanged();
private:
NeoChatRoom *m_space = nullptr;
SpaceTreeItem *m_rootItem;
bool m_loading = false;
QList<QPointer<Quotient::GetSpaceHierarchyJob>> m_currentJobs;
QList<QString> m_pendingChildren;
QList<QString> m_replacedRooms;
SpaceTreeItem *getItem(const QModelIndex &index) const;
void refreshModel();
void insertChildren(std::vector<Quotient::GetSpaceHierarchyJob::ChildRoomsChunk> children, const QModelIndex &parent = QModelIndex());
};

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "spacechildsortfiltermodel.h"
#include "spacechildrenmodel.h"
SpaceChildSortFilterModel::SpaceChildSortFilterModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
setRecursiveFilteringEnabled(true);
sort(0);
}
void SpaceChildSortFilterModel::setFilterText(const QString &filterText)
{
m_filterText = filterText;
Q_EMIT filterTextChanged();
invalidateFilter();
}
QString SpaceChildSortFilterModel::filterText() const
{
return m_filterText;
}
bool SpaceChildSortFilterModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const
{
if (!source_left.data(SpaceChildrenModel::IsSpaceRole).toBool() && source_right.data(SpaceChildrenModel::IsSpaceRole).toBool()) {
return false;
}
return true;
}
bool SpaceChildSortFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
if (auto sourceModel = static_cast<SpaceChildrenModel *>(this->sourceModel())) {
bool isReplaced = sourceModel->isRoomReplaced(index.data(SpaceChildrenModel::RoomIDRole).toString());
bool acceptRoom = index.data(SpaceChildrenModel::DisplayNameRole).toString().contains(m_filterText, Qt::CaseInsensitive);
return !isReplaced && acceptRoom;
}
return true;
}
#include "moc_spacechildsortfiltermodel.cpp"

View File

@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QQmlEngine>
#include <QSortFilterProxyModel>
/**
* @class SpaceChildSortFilterModel
*
* This class creates a custom QSortFilterProxyModel for filtering and sorting spaces
* in a SpaceChildrenModel.
*
* @sa SpaceChildrenModel
*/
class SpaceChildSortFilterModel : public QSortFilterProxyModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The text to use to filter room names.
*/
Q_PROPERTY(QString filterText READ filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged)
public:
SpaceChildSortFilterModel(QObject *parent = nullptr);
void setFilterText(const QString &filterText);
[[nodiscard]] QString filterText() const;
protected:
/**
* @brief Returns true if the value of source_left is less than source_right.
*
* @sa QSortFilterProxyModel::lessThan
*/
bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override;
/**
* @brief Custom filter function checking if an event type has been filtered out.
*
* The filter rejects a row if the room is known been replaced or if a search
* string is set it will only return rooms that match.
*/
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
Q_SIGNALS:
void filterTextChanged();
private:
QString m_filterText;
};

View File

@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "spacetreeitem.h"
#include "controller.h"
SpaceTreeItem::SpaceTreeItem(SpaceTreeItem *parent,
const QString &id,
const QString &name,
const QString &canonicalAlias,
const QString &topic,
int memberCount,
const QUrl &avatarUrl,
bool allowGuests,
bool worldReadable,
bool isSpace)
: m_parentItem(parent)
, m_id(id)
, m_name(name)
, m_canonicalAlias(canonicalAlias)
, m_topic(topic)
, m_memberCount(memberCount)
, m_avatarUrl(avatarUrl)
, m_allowGuests(allowGuests)
, m_worldReadable(worldReadable)
, m_isSpace(isSpace)
{
}
SpaceTreeItem::~SpaceTreeItem()
{
qDeleteAll(m_children);
}
SpaceTreeItem *SpaceTreeItem::child(int number)
{
if (number < 0 || number >= m_children.size()) {
return nullptr;
}
return m_children[number];
}
int SpaceTreeItem::childCount() const
{
return m_children.count();
}
bool SpaceTreeItem::insertChild(int row, SpaceTreeItem *newChild)
{
if (row < 0 || row > m_children.size()) {
return false;
}
m_children.insert(row, newChild);
return true;
}
bool SpaceTreeItem::removeChild(int row)
{
if (row < 0 || row >= m_children.size()) {
return false;
}
delete m_children.takeAt(row);
return true;
}
int SpaceTreeItem::row() const
{
if (m_parentItem) {
return m_parentItem->m_children.indexOf(const_cast<SpaceTreeItem *>(this));
}
return 0;
}
SpaceTreeItem *SpaceTreeItem::parentItem()
{
return m_parentItem;
}
QString SpaceTreeItem::id() const
{
return m_id;
}
QString SpaceTreeItem::name() const
{
return m_name;
}
QString SpaceTreeItem::canonicalAlias() const
{
return m_canonicalAlias;
}
QString SpaceTreeItem::topic() const
{
return m_topic;
}
int SpaceTreeItem::memberCount() const
{
return m_memberCount;
}
QUrl SpaceTreeItem::avatarUrl() const
{
if (m_avatarUrl.isEmpty() || m_avatarUrl.scheme() != QLatin1String("mxc")) {
return {};
}
auto connection = Controller::instance().activeConnection();
auto url = connection->makeMediaUrl(m_avatarUrl);
if (url.scheme() == QLatin1String("mxc")) {
return url;
}
return {};
}
bool SpaceTreeItem::allowGuests() const
{
return m_allowGuests;
}
bool SpaceTreeItem::worldReadable() const
{
return m_worldReadable;
}
bool SpaceTreeItem::isJoined() const
{
auto connection = Controller::instance().activeConnection();
if (!connection) {
return false;
}
return connection->room(id(), Quotient::JoinState::Join) != nullptr;
}
bool SpaceTreeItem::isSpace() const
{
return m_isSpace;
}

136
src/models/spacetreeitem.h Normal file
View File

@@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include <Quotient/csapi/space_hierarchy.h>
/**
* @class SpaceTreeItem
*
* This class defines an item in the space tree hierarchy model.
*
* @note This is separate from Quotient::Room and NeoChatRoom because we don't have
* full room information for any room/space the user hasn't joined and we
* don't want to create one for ever possible child in a space as that would
* be expensive.
*
* @sa Quotient::Room, NeoChatRoom
*/
class SpaceTreeItem
{
public:
explicit SpaceTreeItem(SpaceTreeItem *parent = nullptr,
const QString &id = {},
const QString &name = {},
const QString &canonicalAlias = {},
const QString &topic = {},
int memberCount = {},
const QUrl &avatarUrl = {},
bool allowGuests = {},
bool worldReadable = {},
bool isSpace = {});
~SpaceTreeItem();
/**
* @brief Return the child at the given row number.
*
* Nullptr is returned if there is no child at the given row number.
*/
SpaceTreeItem *child(int number);
/**
* @brief The number of children this item has.
*/
int childCount() const;
/**
* @brief Insert the given child at the given row number.
*/
bool insertChild(int row, SpaceTreeItem *newChild);
/**
* @brief Remove the child at the given row number.
*
* @return True if a child was removed, false if the given row isn't valid.
*/
bool removeChild(int row);
/**
* @brief Return this item's parent.
*/
SpaceTreeItem *parentItem();
/**
* @brief Return the row number for this child relative to the parent.
*
* @return The row value if the child has a parent, 0 otherwise.
*/
int row() const;
/**
* @brief The ID of the room.
*/
QString id() const;
/**
* @brief The name of the room, if any.
*/
QString name() const;
/**
* @brief The canonical alias of the room, if any.
*/
QString canonicalAlias() const;
/**
* @brief The topic of the room, if any.
*/
QString topic() const;
/**
* @brief The number of members joined to the room.
*/
int memberCount() const;
/**
* @brief The URL for the room's avatar, if one is set.
*
* @return A CS API QUrl.
*/
QUrl avatarUrl() const;
/**
* @brief Whether guest users may join the room and participate in it.
*
* If they can, they will be subject to ordinary power level rules like any other users.
*/
bool allowGuests() const;
/**
* @brief Whether the room may be viewed by guest users without joining.
*/
bool worldReadable() const;
/**
* @brief Whether the local user is a member of the rooom.
*/
bool isJoined() const;
/**
* @brief Whether the room is a space.
*/
bool isSpace() const;
private:
QList<SpaceTreeItem *> m_children;
SpaceTreeItem *m_parentItem;
QString m_id;
QString m_name;
QString m_canonicalAlias;
QString m_topic;
int m_memberCount;
QUrl m_avatarUrl;
bool m_allowGuests;
bool m_worldReadable;
bool m_isSpace;
};

View File

@@ -156,9 +156,30 @@ void NeoChatConnection::deactivateAccount(const QString &password)
});
}
void NeoChatConnection::createRoom(const QString &name, const QString &topic)
void NeoChatConnection::createRoom(const QString &name, const QString &topic, const QString &parent, bool setChildParent)
{
const auto job = Connection::createRoom(Connection::PublishRoom, {}, name, topic, {});
QVector<CreateRoomJob::StateEvent> initialStateEvents;
if (!parent.isEmpty()) {
initialStateEvents.append(CreateRoomJob::StateEvent{
"m.space.parent"_ls,
QJsonObject{
{"canonical"_ls, true},
{"via"_ls, QJsonArray{domain()}},
},
parent,
});
}
const auto job = Connection::createRoom(Connection::PublishRoom, QString(), name, topic, QStringList(), {}, {}, {}, initialStateEvents);
if (!parent.isEmpty()) {
connect(job, &Quotient::CreateRoomJob::success, this, [this, parent, setChildParent, job]() {
if (setChildParent) {
if (auto parentRoom = room(parent)) {
parentRoom->setState(QLatin1String("m.space.child"), job->roomId(), QJsonObject{{QLatin1String("via"), QJsonArray{domain()}}});
}
}
});
}
connect(job, &CreateRoomJob::failure, this, [this, job] {
Q_EMIT Controller::instance().errorOccured(i18n("Room creation failed: %1", job->errorString()));
});
@@ -167,9 +188,30 @@ void NeoChatConnection::createRoom(const QString &name, const QString &topic)
});
}
void NeoChatConnection::createSpace(const QString &name, const QString &topic)
void NeoChatConnection::createSpace(const QString &name, const QString &topic, const QString &parent, bool setChildParent)
{
const auto job = Connection::createRoom(Connection::UnpublishRoom, {}, name, topic, {}, {}, {}, false, {}, {}, QJsonObject{{"type"_ls, "m.space"_ls}});
QVector<CreateRoomJob::StateEvent> initialStateEvents;
if (!parent.isEmpty()) {
initialStateEvents.append(CreateRoomJob::StateEvent{
"m.space.parent"_ls,
QJsonObject{
{"canonical"_ls, true},
{"via"_ls, QJsonArray{domain()}},
},
parent,
});
}
const auto job = Connection::createRoom(Connection::UnpublishRoom, {}, name, topic, {}, {}, {}, false, initialStateEvents, {}, QJsonObject{{"type"_ls, "m.space"_ls}});
if (!parent.isEmpty()) {
connect(job, &Quotient::CreateRoomJob::success, this, [this, parent, setChildParent, job]() {
if (setChildParent) {
if (auto parentRoom = room(parent)) {
parentRoom->setState(QLatin1String("m.space.child"), job->roomId(), QJsonObject{{QLatin1String("via"), QJsonArray{domain()}}});
}
}
});
}
connect(job, &CreateRoomJob::failure, this, [this, job] {
Q_EMIT Controller::instance().errorOccured(i18n("Space creation failed: %1", job->errorString()));
});

View File

@@ -54,12 +54,12 @@ public:
/**
* @brief Create new room for a group chat.
*/
Q_INVOKABLE void createRoom(const QString &name, const QString &topic);
Q_INVOKABLE void createRoom(const QString &name, const QString &topic, const QString &parent = {}, bool setChildParent = false);
/**
* @brief Create new space.
*/
Q_INVOKABLE void createSpace(const QString &name, const QString &topic);
Q_INVOKABLE void createSpace(const QString &name, const QString &topic, const QString &parent = {}, bool setChildParent = false);
Q_SIGNALS:
void labelChanged();

View File

@@ -47,6 +47,7 @@
#include "filetransferpseudojob.h"
#include "neochatconfig.h"
#include "notificationsmanager.h"
#include "roomlastmessageprovider.h"
#include "texthandler.h"
#include "urlhelper.h"
#include "utils.h"
@@ -72,12 +73,10 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS
connect(this, &Room::aboutToAddHistoricalMessages, this, &NeoChatRoom::readMarkerLoadedChanged);
// Load cached event if available.
KConfig dataResource("data"_ls, KConfig::SimpleConfig, QStandardPaths::AppDataLocation);
KConfigGroup eventCacheGroup(&dataResource, "EventCache"_ls);
const auto &roomLastMessageProvider = RoomLastMessageProvider::self();
if (eventCacheGroup.hasKey(id())) {
auto eventJson = QJsonDocument::fromJson(eventCacheGroup.readEntry(id(), QByteArray())).object();
if (roomLastMessageProvider.hasKey(id())) {
auto eventJson = QJsonDocument::fromJson(roomLastMessageProvider.read(id())).object();
if (!eventJson.isEmpty()) {
auto event = loadEvent<RoomEvent>(eventJson);
@@ -309,11 +308,10 @@ void NeoChatRoom::cacheLastEvent()
{
auto event = lastEvent();
if (event != nullptr) {
KConfig dataResource("data"_ls, KConfig::SimpleConfig, QStandardPaths::AppDataLocation);
KConfigGroup eventCacheGroup(&dataResource, "EventCache"_ls);
auto &roomLastMessageProvider = RoomLastMessageProvider::self();
auto eventJson = QJsonDocument(event->fullJson()).toJson();
eventCacheGroup.writeEntry(id(), eventJson);
roomLastMessageProvider.write(id(), eventJson);
auto uniqueEvent = loadEvent<RoomEvent>(event->fullJson());
@@ -1111,6 +1109,44 @@ bool NeoChatRoom::isSpace()
return creationEvent->roomType() == RoomType::Space;
}
void NeoChatRoom::addChild(const QString &childId, bool setChildParent)
{
if (!isSpace()) {
return;
}
if (!canSendEvent("m.space.child"_ls)) {
return;
}
setState("m.space.child"_ls, childId, QJsonObject{{QLatin1String("via"), QJsonArray{connection()->domain()}}});
if (setChildParent) {
if (auto child = static_cast<NeoChatRoom *>(connection()->room(childId))) {
if (child->canSendState("m.space.parent"_ls)) {
child->setState("m.space.parent"_ls, id(), QJsonObject{{"canonical"_ls, true}, {"via"_ls, QJsonArray{connection()->domain()}}});
}
}
}
}
void NeoChatRoom::removeChild(const QString &childId, bool unsetChildParent)
{
if (!isSpace()) {
return;
}
if (!canSendEvent("m.space.child"_ls)) {
return;
}
setState("m.space.child"_ls, childId, {});
if (unsetChildParent) {
if (auto child = static_cast<NeoChatRoom *>(connection()->room(childId))) {
if (child->canSendState("m.space.parent"_ls) && child->currentState().contains("m.space.parent"_ls, id())) {
child->setState("m.space.parent"_ls, id(), {});
}
}
}
}
PushNotificationState::State NeoChatRoom::pushNotificationState() const
{
return m_currentPushNotificationState;
@@ -1134,9 +1170,9 @@ void NeoChatRoom::setPushNotificationState(PushNotificationState::State state)
m_pushNotificationStateUpdating = true;
/**
* First remove any exisiting room rules of the wrong type.
* First remove any existing room rules of the wrong type.
* Note to prevent race conditions any rule that is going ot be overridden later is not removed.
* If the default push notification state is chosen any exisiting rule needs to be removed.
* If the default push notification state is chosen any existing rule needs to be removed.
*/
QJsonObject accountData = connection()->accountDataJson("m.push_rules"_ls);

View File

@@ -589,6 +589,10 @@ public:
[[nodiscard]] bool isSpace();
Q_INVOKABLE void addChild(const QString &childId, bool setChildParent = false);
Q_INVOKABLE void removeChild(const QString &childId, bool unsetChildParent = false);
bool isInvite() const;
Q_INVOKABLE void clearInvitationNotification();

View File

@@ -59,8 +59,9 @@ class NotificationsManager : public QObject
public:
static NotificationsManager &instance();
static NotificationsManager *create(QQmlEngine *, QJSEngine *)
static NotificationsManager *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}

View File

@@ -80,4 +80,3 @@ X-Plasma-DBusRunner-Service=org.kde.neochat
X-Plasma-DBusRunner-Path=/RoomRunner
X-Plasma-Request-Actions-Once=true
X-Plasma-Runner-Min-Letter-Count=3
X-Plasma-Runner-Has-Activation=true

View File

@@ -40,6 +40,11 @@ QQC2.Control {
*/
property var author
/**
* @brief Whether the author should be shown.
*/
required property bool showAuthor
/**
* @brief The timestamp of the message.
*/
@@ -133,6 +138,7 @@ QQC2.Control {
contentItem: ColumnLayout {
RowLayout {
Layout.maximumWidth: root.maxContentWidth
visible: root.showAuthor
QQC2.Label {
Layout.fillWidth: true
text: root.author.displayName

View File

@@ -2,44 +2,225 @@
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.kirigamiaddons.labs.components as Components
import org.kde.neochat
FormCard.FormCardPage {
id: root
title: i18nc("@title", "Create a Room")
property string parentId: ""
property bool isSpace: false
property bool showChildType: false
property bool showCreateChoice: false
required property NeoChatConnection connection
signal addChild(string childId, bool setChildParent)
signal newChild(string childName)
title: isSpace ? i18nc("@title", "Create a Space") : i18nc("@title", "Create a Room")
Component.onCompleted: roomNameField.forceActiveFocus()
FormCard.FormHeader {
title: i18nc("@title", "Room Information")
title: root.isSpace ? i18n("New Space Information") : i18n("New Room Information")
}
FormCard.FormCard {
FormCard.FormComboBoxDelegate {
id: roomTypeCombo
property bool isInitialising: true
visible: root.showChildType
text: i18n("Select type")
model: ListModel {
id: roomTypeModel
}
textRole: "text"
valueRole: "isSpace"
Component.onCompleted: {
currentIndex = indexOfValue(root.isSpace)
roomTypeModel.append({"text": i18n("Room"), "isSpace": false});
roomTypeModel.append({"text": i18n("Space"), "isSpace": true});
roomTypeCombo.currentIndex = 0
roomTypeCombo.isInitialising = false
}
onCurrentValueChanged: {
if (!isInitialising) {
root.isSpace = currentValue
}
}
}
FormCard.FormTextFieldDelegate {
id: roomNameField
label: i18n("Room name:")
label: i18n("Name:")
onAccepted: if (roomNameField.text.length > 0) roomTopicField.forceActiveFocus();
}
FormCard.FormTextFieldDelegate {
id: roomTopicField
label: i18n("Room topic:")
label: i18n("Topic:")
onAccepted: ok.clicked()
}
FormCard.FormCheckDelegate {
id: newOfficialCheck
visible: root.parentId.length > 0
text: i18n("Make this parent official")
checked: true
}
FormCard.FormButtonDelegate {
id: ok
text: i18nc("@action:button", "Ok")
enabled: roomNameField.text.length > 0
onClicked: {
root.connection.createRoom(roomNameField.text, roomTopicField.text);
if (root.isSpace) {
root.connection.createSpace(roomNameField.text, roomTopicField.text, root.parentId, newOfficialCheck.checked);
} else {
root.connection.createRoom(roomNameField.text, roomTopicField.text, root.parentId, newOfficialCheck.checked);
}
root.newChild(roomNameField.text)
root.closeDialog()
}
}
}
FormCard.FormHeader {
visible: root.showChildType
title: i18n("Select Existing Room")
}
FormCard.FormCard {
visible: root.showChildType
FormCard.FormButtonDelegate {
visible: !chosenRoomDelegate.visible
text: i18nc("@action:button", "Pick room")
onClicked: {
let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/JoinRoomPage.qml", {connection: root.connection}, {title: i18nc("@title", "Explore Rooms")})
dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => {
chosenRoomDelegate.roomId = roomId;
chosenRoomDelegate.displayName = displayName;
chosenRoomDelegate.avatarUrl = avatarUrl;
chosenRoomDelegate.alias = alias;
chosenRoomDelegate.topic = topic;
chosenRoomDelegate.memberCount = memberCount;
chosenRoomDelegate.isJoined = isJoined;
chosenRoomDelegate.visible = true;
})
}
}
FormCard.AbstractFormDelegate {
id: chosenRoomDelegate
property string roomId
property string displayName
property url avatarUrl
property string alias
property string topic
property int memberCount
property bool isJoined
visible: false
contentItem: RowLayout {
Components.Avatar {
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
source: chosenRoomDelegate.avatarUrl
name: chosenRoomDelegate.displayName
}
ColumnLayout {
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
Kirigami.Heading {
Layout.fillWidth: true
level: 4
text: chosenRoomDelegate.displayName
font.bold: true
textFormat: Text.PlainText
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
QQC2.Label {
visible: chosenRoomDelegate.isJoined
text: i18n("Joined")
color: Kirigami.Theme.linkColor
}
}
QQC2.Label {
Layout.fillWidth: true
visible: text
text: chosenRoomDelegate.topic ? chosenRoomDelegate.topic.replace(/(\r\n\t|\n|\r\t)/gm," ") : ""
textFormat: Text.PlainText
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
RowLayout {
Layout.fillWidth: true
Kirigami.Icon {
source: "user"
color: Kirigami.Theme.disabledTextColor
implicitHeight: Kirigami.Units.iconSizes.small
implicitWidth: Kirigami.Units.iconSizes.small
}
QQC2.Label {
text: chosenRoomDelegate.memberCount + " " + (chosenRoomDelegate.alias ?? chosenRoomDelegate.roomId)
color: Kirigami.Theme.disabledTextColor
elide: Text.ElideRight
Layout.fillWidth: true
}
}
}
}
onClicked: {
let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/JoinRoomPage.qml", {connection: root.connection}, {title: i18nc("@title", "Explore Rooms")})
dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => {
chosenRoomDelegate.roomId = roomId;
chosenRoomDelegate.displayName = displayName;
chosenRoomDelegate.avatarUrl = avatarUrl;
chosenRoomDelegate.alias = alias;
chosenRoomDelegate.topic = topic;
chosenRoomDelegate.memberCount = memberCount;
chosenRoomDelegate.isJoined = isJoined;
chosenRoomDelegate.visible = true;
})
}
}
FormCard.FormCheckDelegate {
id: existingOfficialCheck
visible: root.parentId.length > 0
text: i18n("Make this parent official")
description: enabled ? i18n("You have the required privilege level in the child to set this state") : i18n("You do not have a high enough privilege level in the child to set this state")
checked: enabled
enabled: {
if (chosenRoomDelegate.visible) {
let room = root.connection.room(chosenRoomDelegate.roomId);
if (room) {
if (room.canSendState("m.space.parent")) {
return true;
}
}
}
return false;
}
}
FormCard.FormButtonDelegate {
text: i18nc("@action:button", "Ok")
enabled: chosenRoomDelegate.visible
onClicked: {
root.addChild(chosenRoomDelegate.roomId, existingOfficialCheck.checked);
root.closeDialog();
}
}
}
}

View File

@@ -1,42 +0,0 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.kirigamiaddons.formcard as FormCard
FormCard.FormCardPage {
id: root
required property NeoChatConnection connection
title: i18n("Create a Space")
Kirigami.Theme.colorSet: Kirigami.Theme.Window
FormCard.FormHeader {
title: i18nc("@title", "Create a Space")
}
FormCard.FormCard {
FormCard.FormTextFieldDelegate {
id: nameDelegate
label: i18n("Space name")
}
FormCard.FormTextFieldDelegate {
id: topicDelegate
label: i18n("Space topic (optional)")
}
FormCard.FormButtonDelegate {
text: i18n("Create space")
onClicked: {
root.connection.createSpace(nameDelegate.text, topicDelegate.text)
root.close()
root.destroy()
}
enabled: nameDelegate.text.length > 0
}
}
}

View File

@@ -5,6 +5,8 @@ import QtQuick
import QtQuick.Controls as QQC2
import QtQml
import com.github.quotient_im.libquotient
import org.kde.kirigami as Kirigami
import org.kde.neochat

View File

@@ -48,7 +48,7 @@ RowLayout {
text: i18n("Create a Space")
icon.name: "list-add"
onTriggered: {
pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateSpaceDialog.qml", {connection: root.connection}, {title: i18nc("@title", "Create a Space")})
pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateRoomDialog.qml", {connection: root.connection, isSpace: true, title: i18nc("@title", "Create a Space")}, {title: i18nc("@title", "Create a Space")})
}
}

View File

@@ -11,6 +11,10 @@ import org.kde.neochat
ColumnLayout {
id: root
/**
* @brief The current room that user is viewing.
*/
required property NeoChatRoom room
Layout.fillWidth: true
@@ -26,8 +30,8 @@ ColumnLayout {
Layout.preferredWidth: Kirigami.Units.iconSizes.large
Layout.preferredHeight: Kirigami.Units.iconSizes.large
name: room ? room.displayName : ""
source: room && room.avatarMediaId ? ("image://mxc/" + room.avatarMediaId) : ""
name: root.room ? root.room.displayName : ""
source: root.room && root.room.avatarMediaId ? ("image://mxc/" + root.room.avatarMediaId) : ""
Rectangle {
visible: room.usesEncryption
@@ -58,7 +62,7 @@ ColumnLayout {
Kirigami.Heading {
Layout.fillWidth: true
text: room ? room.displayName : i18n("No name")
text: root.room ? root.room.displayName : i18n("No name")
textFormat: Text.PlainText
wrapMode: Text.Wrap
}
@@ -67,8 +71,8 @@ ColumnLayout {
Layout.fillWidth: true
font: Kirigami.Theme.smallFont
textFormat: TextEdit.PlainText
visible: room && room.canonicalAlias
text: room && room.canonicalAlias ? room.canonicalAlias : ""
visible: root.room && root.room.canonicalAlias
text: root.room && root.room.canonicalAlias ? root.room.canonicalAlias : ""
}
}
}
@@ -78,7 +82,7 @@ ColumnLayout {
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
text: room && room.topic ? room.topic.replace(replaceLinks, "<a href=\"$1\">$1</a>") : i18n("No Topic")
text: root.room && root.room.topic ? root.room.topic.replace(replaceLinks, "<a href=\"$1\">$1</a>") : i18n("No Topic")
readonly property var replaceLinks: /(http[s]?:\/\/[^ \r\n]*)/g
textFormat: TextEdit.MarkdownText
wrapMode: Text.Wrap

View File

@@ -6,6 +6,8 @@ import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQml
import com.github.quotient_im.libquotient
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.neochat

View File

@@ -24,5 +24,6 @@ Column {
QQC2.Label {
text: root.text
textFormat: Text.MarkdownText
horizontalAlignment: Text.AlignHCenter
}
}

View File

@@ -394,6 +394,7 @@ TimelineDelegate {
]
author: root.author
showAuthor: root.showAuthor || root.alwaysShowAuthor
time: root.time
timeString: root.timeString

View File

@@ -28,39 +28,45 @@ Flow {
id: reactionRepeater
delegate: QQC2.AbstractButton {
width: Math.max(reactionTextMetrics.advanceWidth + Kirigami.Units.smallSpacing * 4, height)
id: reactionDelegate
required property string textContent
required property string reaction
required property string toolTip
required property bool hasLocalUser
width: Math.max(contentItem.implicitWidth + leftPadding + rightPadding, height)
height: Math.round(Kirigami.Units.gridUnit * 1.5)
contentItem: QQC2.Label {
id: reactionLabel
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: model.text
TextMetrics {
id: reactionTextMetrics
text: reactionLabel.text
}
text: reactionDelegate.textContent
background: null
wrapMode: TextEdit.NoWrap
textFormat: Text.RichText
}
padding: Kirigami.Units.smallSpacing
background: Kirigami.ShadowedRectangle {
color: model.hasLocalUser ? Kirigami.Theme.positiveBackgroundColor : Kirigami.Theme.backgroundColor
color: reactionDelegate.hasLocalUser ? Kirigami.Theme.positiveBackgroundColor : Kirigami.Theme.backgroundColor
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.View
radius: height / 2
shadow {
size: Kirigami.Units.smallSpacing
color: !model.hasLocalUser ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10)
color: !reactionDelegate.hasLocalUser ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10)
}
}
onClicked: reactionClicked(model.reaction)
onClicked: reactionClicked(reactionDelegate.reaction)
hoverEnabled: true
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: model.toolTip
QQC2.ToolTip.text: reactionDelegate.toolTip
}
}
}

View File

@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
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.kirigamiaddons.labs.components as Components
import org.kde.neochat
Kirigami.Dialog {
id: root
required property NeoChatRoom parentRoom
required property string roomId
required property string displayName
required property string parentDisplayName
required property bool canSetParent
required property bool isDeclaredParent
title: i18nc("@title", "Remove Child")
width: Math.min(applicationWindow().width, Kirigami.Units.gridUnit * 24)
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
onAccepted: parentRoom.removeChild(root.roomId, removeOfficalCheck.checked)
contentItem: FormCard.FormCardPage {
FormCard.FormCard {
Layout.topMargin: Kirigami.Units.largeSpacing
FormCard.FormTextDelegate {
text: i18n("The child %1 will be removed from the space %2", root.displayName, root.parentDisplayName)
textItem.wrapMode: Text.Wrap
}
FormCard.FormCheckDelegate {
id: removeOfficalCheck
visible: root.isDeclaredParent
enabled: root.canSetParent
text: i18n("The current space is the official parent of this room, should this be cleared?")
checked: root.canSetParent
}
}
}
}

View File

@@ -136,6 +136,7 @@ Kirigami.OverlayDrawer {
Kirigami.NavigationTabBar {
id: navigationBar
Layout.fillWidth: true
visible: !root.room.isSpace
Kirigami.Theme.colorSet: Kirigami.Theme.Window
Kirigami.Theme.inherit: false

View File

@@ -73,6 +73,7 @@ Kirigami.Page {
footer: Kirigami.NavigationTabBar {
id: navigationBar
visible: !root.room.isSpace
Kirigami.Theme.colorSet: Kirigami.Theme.Window
Kirigami.Theme.inherit: false

View File

@@ -38,7 +38,7 @@ QQC2.ScrollView {
/**
* @brief The title that should be displayed for this component if available.
*/
readonly property string title: i18nc("@action:title", "Room information")
readonly property string title: root.room.isSpace ? i18nc("@action:title", "Space Members") : i18nc("@action:title", "Room information")
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
@@ -57,6 +57,7 @@ QQC2.ScrollView {
active: true
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: !root.room.isSpace
sourceComponent: root.room.isDirectChat() ? directChatDrawerHeader : groupChatDrawerHeader
onItemChanged: if (item) {
userList.positionViewAtBeginning();
@@ -64,6 +65,7 @@ QQC2.ScrollView {
}
Kirigami.ListSectionHeader {
visible: !root.room.isSpace
label: i18n("Options")
activeFocusOnTab: false
@@ -75,7 +77,7 @@ QQC2.ScrollView {
icon.name: "tools"
text: i18n("Open developer tools")
visible: Config.developerTools
visible: Config.developerTools && !root.room.isSpace
Layout.fillWidth: true
@@ -86,7 +88,7 @@ QQC2.ScrollView {
Delegates.RoundedItemDelegate {
id: searchButton
visible: !root.room.isSpace
icon.name: "search"
text: i18n("Search in this room")
@@ -104,7 +106,7 @@ QQC2.ScrollView {
Delegates.RoundedItemDelegate {
id: favouriteButton
visible: !root.room.isSpace
icon.name: root.room && root.room.isFavourite ? "rating" : "rating-unrated"
text: root.room && root.room.isFavourite ? i18n("Remove room from favorites") : i18n("Make room favorite")
@@ -115,7 +117,7 @@ QQC2.ScrollView {
Delegates.RoundedItemDelegate {
id: locationsButton
visible: !root.room.isSpace
icon.name: "map-flat"
text: i18n("Show locations for this room")
@@ -240,7 +242,9 @@ QQC2.ScrollView {
Component {
id: groupChatDrawerHeader
GroupChatDrawerHeader {}
GroupChatDrawerHeader {
room: root.room
}
}
Component {

View File

@@ -0,0 +1,162 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.kirigamiaddons.labs.components as Components
import org.kde.neochat
Item {
id: root
required property TreeView treeView
required property bool isTreeNode
required property bool expanded
required property int hasChildren
required property int depth
required property string roomId
required property string displayName
required property url avatarUrl
required property bool isSpace
required property int memberCount
required property string topic
required property bool isJoined
required property bool canAddChildren
required property string parentDisplayName
required property bool canSetParent
required property bool isDeclaredParent
required property bool canRemove
required property NeoChatRoom parentRoom
signal createRoom()
signal enterRoom()
Delegates.RoundedItemDelegate {
anchors.centerIn: root
width: sizeHelper.currentWidth
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
RowLayout {
spacing: 0
Item {
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium * (root.depth + (root.isSpace ? 0 : 1))
}
Kirigami.Icon {
visible: root.isSpace
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: root.hasChildren ? (root.expanded ? "go-up" : "go-down") : "go-next"
}
}
Components.Avatar {
Layout.fillHeight: true
Layout.preferredWidth: height
implicitWidth: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
implicitHeight: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
source: root.avatarUrl
name: root.displayName
}
ColumnLayout {
spacing: 0
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
RowLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft | Qt.AlignBottom
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
id: label
text: root.displayName
elide: Text.ElideRight
textFormat: Text.PlainText
}
QQC2.Label {
visible: root.isJoined
text: i18n("Joined")
color: Kirigami.Theme.linkColor
}
}
QQC2.Label {
id: subtitle
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
text: root.memberCount + (root.topic !== "" ? i18nc("number of room members", " members - ") + root.topic : i18nc("number of room members", " members"))
elide: Text.ElideRight
font: Kirigami.Theme.smallFont
textFormat: Text.PlainText
maximumLineCount: 1
}
}
QQC2.ToolButton {
visible: root.isSpace && root.canAddChildren
text: i18nc("@button", "Add new child")
icon.name: "list-add"
display: QQC2.AbstractButton.IconOnly
onClicked: root.createRoom()
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
visible: root.canRemove
text: i18nc("@button", "Remove")
icon.name: "list-remove"
display: QQC2.AbstractButton.IconOnly
onClicked: {
removeChildDialog.createObject(QQC2.ApplicationWindow.overlay, {
parentRoom: root.parentRoom,
roomId: root.roomId,
displayName: root.displayName,
parentDisplayName: root.parentDisplayName,
canSetParent: root.canSetParent,
isDeclaredParent: root.isDeclaredParent
}).open();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
TapHandler {
onTapped: {
if (root.isSpace) {
root.treeView.toggleExpanded(row)
} else {
if (root.isJoined) {
root.enterRoom()
} else {
Controller.joinRoom(root.roomId)
}
}
}
}
}
DelegateSizeHelper {
id: sizeHelper
startBreakpoint: Kirigami.Units.gridUnit * 46
endBreakpoint: Kirigami.Units.gridUnit * 66
startPercentWidth: 100
endPercentWidth: 85
maxWidth: Kirigami.Units.gridUnit * 60
parentWidth: root.treeView ? root.treeView.width : 0
}
Component {
id: removeChildDialog
RemoveChildDialog {}
}
}

178
src/qml/SpaceHomePage.qml Normal file
View File

@@ -0,0 +1,178 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
Kirigami.Page {
id: root
readonly property NeoChatRoom currentRoom: RoomManager.currentRoom
padding: 0
ColumnLayout {
id: columnLayout
anchors.fill: parent
spacing: 0
Item {
id: headerItem
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
implicitHeight: headerColumn.implicitHeight
ColumnLayout {
id: headerColumn
anchors.centerIn: headerItem
width: sizeHelper.currentWidth
spacing: Kirigami.Units.largeSpacing
GroupChatDrawerHeader {
id: header
Layout.fillWidth: true
room: root.currentRoom
}
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
QQC2.Button {
visible: root.currentRoom.canSendState("invite")
text: i18nc("@button", "Invite user to space")
icon.name: "list-add-user"
onClicked: applicationWindow().pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/InviteUserPage.qml", {room: root.currentRoom}, {title: i18nc("@title", "Invite a User")})
}
QQC2.Button {
visible: root.currentRoom.canSendState("m.space.child")
text: i18nc("@button", "Add new child")
icon.name: "list-add"
onClicked: _private.createRoom(root.currentRoom.id)
}
QQC2.Button {
text: i18nc("@button", "Leave the space")
icon.name: "go-previous"
onClicked: RoomManager.leaveRoom(root.currentRoom)
}
Item {
Layout.fillWidth: true
}
QQC2.Button {
text: i18nc("@button", "Space settings")
icon.name: "settings-configure"
display: QQC2.AbstractButton.IconOnly
onClicked: applicationWindow().pageStack.pushDialogLayer('qrc:/org/kde/neochat/qml/Categories.qml', {room: root.currentRoom, connection: root.currentRoom.connection}, { title: i18n("Room Settings") })
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.visible: hovered
}
}
Kirigami.SearchField {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.largeSpacing
onTextChanged: spaceChildSortFilterModel.filterText = text
}
}
DelegateSizeHelper {
id: sizeHelper
startBreakpoint: Kirigami.Units.gridUnit * 46
endBreakpoint: Kirigami.Units.gridUnit * 66
startPercentWidth: 100
endPercentWidth: 85
maxWidth: Kirigami.Units.gridUnit * 60
parentWidth: columnLayout.width
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
QQC2.ScrollView {
id: hierarchyScrollView
Layout.fillWidth: true
Layout.fillHeight: true
visible: !spaceChildrenModel.loading
TreeView {
id: spaceTree
columnWidthProvider: function (column) { return spaceTree.width }
clip: true
model: SpaceChildSortFilterModel {
id: spaceChildSortFilterModel
sourceModel: SpaceChildrenModel {
id: spaceChildrenModel
space: root.currentRoom
}
}
delegate: SpaceHierarchyDelegate {
onCreateRoom: _private.createRoom(roomId)
onEnterRoom: _private.enterRoom(roomId)
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
visible: spaceChildrenModel.loading
Loader {
active: spaceChildrenModel.loading
anchors.centerIn: parent
sourceComponent: Kirigami.LoadingPlaceholder {}
}
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.View
}
QtObject {
id: _private
function createRoom(parentId) {
let dialog = applicationWindow().pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateRoomDialog.qml", {
title: i18nc("@title", "Create a Child"),
connection: root.currentRoom.connection,
parentId : parentId,
showChildType: true,
showCreateChoice: true
}, {
title: i18nc("@title", "Create a Child")
})
dialog.addChild.connect((childId, setChildParent) => {
// We have to get a room object from the connection as we may not
// be adding to the top level parent.
let parent = root.currentRoom.connection.room(parentId)
if (parent) {
parent.addChild(childId, setChildParent)
}
})
dialog.newChild.connect(childName => {spaceChildrenModel.addPendingChild(childName)})
}
function enterRoom(roomId) {
let room = root.currentRoom.connection.room(roomId)
if (room) {
RoomManager.enterRoom(room)
}
}
}
}

View File

@@ -29,7 +29,7 @@ Loader {
QQC2.MenuItem {
text: i18nc("'Space' is a matrix space", "View Space")
icon.name: "view-list-details"
onTriggered: RoomManager.enterRoom(room);
onTriggered: RoomManager.enterSpaceHome(room);
}
QQC2.MenuItem {

View File

@@ -4,6 +4,8 @@
import QtQuick
import QtQml
import com.github.quotient_im.libquotient
import org.kde.neochat
Message {
@@ -40,7 +42,7 @@ Message {
case KeyVerificationSession.KEY_MISMATCH:
return i18n("The session verification was canceled because the keys are incorrect.");
case KeyVerificationSession.REMOTE_KEY_MISMATCH:
return i18n("The remote party canceled the session verification because the keys are incorrect.");
return i18n("The remote party canceled the session verification because the keys are incorrect.\n\n**Please log out and log back in, your session is broken/corrupt.**");
case KeyVerificationSession.USER_MISMATCH:
return i18n("The session verification was canceled because it verifies an unexpected user.");
case KeyVerificationSession.REMOTE_USER_MISMATCH:

View File

@@ -21,6 +21,7 @@ Kirigami.ApplicationWindow {
property bool roomListLoaded: false
property RoomPage roomPage
property SpaceHomePage spaceHomePage
property NeoChatConnection connection: Controller.activeConnection
@@ -96,15 +97,36 @@ Kirigami.ApplicationWindow {
}
}
function onPushSpaceHome(room) {
root.spaceHomePage = pageStack.push("qrc:/org/kde/neochat/qml/SpaceHomePage.qml");
root.spaceHomePage.forceActiveFocus();
}
function onReplaceRoom(room, event) {
const roomItem = pageStack.get(pageStack.depth - 1);
pageStack.currentIndex = pageStack.depth - 1;
if (root.roomPage) {
pageStack.currentIndex = pageStack.depth - 1;
} else {
pageStack.pop();
root.roomPage = pageStack.push("qrc:/org/kde/neochat/qml/RoomPage.qml", {connection: root.connection});
root.spaceHomePage = null;
}
root.roomPage.forceActiveFocus();
if (event.length > 0) {
roomItem.goToEvent(event);
root.roomPage.goToEvent(event);
}
}
function onReplaceSpaceHome(room) {
if (root.spaceHomePage) {
pageStack.currentIndex = pageStack.depth - 1;
} else {
pageStack.pop();
root.spaceHomePage = pageStack.push("qrc:/org/kde/neochat/qml/SpaceHomePage.qml");
root.roomPage = null;
}
root.spaceHomePage.forceActiveFocus();
}
function goToEvent(event) {
if (event.length > 0) {
roomItem.goToEvent(event);
@@ -335,13 +357,6 @@ Kirigami.ApplicationWindow {
}
}
Component {
id: createSpaceDialog
CreateSpaceDialog {
connection: root.connection
}
}
Component {
id: roomWindow
RoomWindow {}

View File

@@ -91,8 +91,9 @@ public:
static Registration _instance;
return _instance;
}
static Registration *create(QQmlEngine *, QJSEngine *)
static Registration *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}

View File

@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2023 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "roomlastmessageprovider.h"
using namespace Qt::Literals::StringLiterals;
RoomLastMessageProvider::RoomLastMessageProvider()
: m_config(KSharedConfig::openConfig(u"data"_s, KConfig::SimpleConfig, QStandardPaths::AppDataLocation))
, m_configGroup(KConfigGroup(m_config, u"EventCache"_s))
{
}
RoomLastMessageProvider::~RoomLastMessageProvider()
{
m_config->sync();
}
RoomLastMessageProvider &RoomLastMessageProvider::self()
{
static RoomLastMessageProvider instance;
return instance;
}
bool RoomLastMessageProvider::hasKey(const QString &roomId) const
{
return m_configGroup.hasKey(roomId);
}
QByteArray RoomLastMessageProvider::read(const QString &roomId) const
{
return m_configGroup.readEntry(roomId, QByteArray{});
}
void RoomLastMessageProvider::write(const QString &roomId, const QByteArray &json)
{
m_configGroup.writeEntry(roomId, json);
}

View File

@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2023 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <KConfigGroup>
#include <KSharedConfig>
/**
* Store and retrieve the last message of a room.
*/
class RoomLastMessageProvider
{
public:
/**
* Get the global instance of RoomLastMessageProvider.
*/
static RoomLastMessageProvider &self();
~RoomLastMessageProvider();
/**
* Check if we have the last message content for the specified roomId.
*/
bool hasKey(const QString &roomId) const;
/**
* Read the last message content of the specified roomId.
*/
QByteArray read(const QString &roomId) const;
/**
* Write the last message content for the specified roomId.
*/
void write(const QString &roomId, const QByteArray &json);
private:
RoomLastMessageProvider();
KSharedConfig::Ptr m_config;
KConfigGroup m_configGroup;
};

View File

@@ -179,7 +179,11 @@ void RoomManager::openRoomForActiveConnection()
const auto room = qobject_cast<NeoChatRoom *>(Controller::instance().activeConnection()->room(roomId));
if (room) {
enterRoom(room);
if (room->isSpace()) {
enterSpaceHome(room);
} else {
enterRoom(room);
}
}
}
}
@@ -222,6 +226,34 @@ void RoomManager::openWindow(NeoChatRoom *room)
Q_EMIT openRoomInNewWindow(room);
}
void RoomManager::enterSpaceHome(NeoChatRoom *spaceRoom)
{
if (!spaceRoom->isSpace()) {
return;
}
// If replacing a normal room message timeline make sure any edit is cancelled.
if (m_currentRoom && !m_currentRoom->chatBoxEditId().isEmpty()) {
m_currentRoom->setChatBoxEditId({});
}
// Save the chatbar text for the current room if any before switching
if (m_currentRoom && m_chatDocumentHandler) {
if (m_chatDocumentHandler->document()) {
m_currentRoom->setSavedText(m_chatDocumentHandler->document()->textDocument()->toPlainText());
}
}
m_lastCurrentRoom = std::exchange(m_currentRoom, spaceRoom);
Q_EMIT currentRoomChanged();
if (!m_lastCurrentRoom) {
Q_EMIT pushSpaceHome(spaceRoom);
} else {
Q_EMIT replaceSpaceHome(m_currentRoom);
}
// Save last open room
m_lastRoomConfig.writeEntry(Controller::instance().activeConnection()->userId(), spaceRoom->id());
}
UriResolveResult RoomManager::visitUser(User *user, const QString &action)
{
if (action == "mention"_ls || action.isEmpty()) {

View File

@@ -90,8 +90,9 @@ public:
explicit RoomManager(QObject *parent = nullptr);
virtual ~RoomManager();
static RoomManager &instance();
static RoomManager *create(QQmlEngine *, QJSEngine *)
static RoomManager *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}
@@ -127,6 +128,13 @@ public:
*/
Q_INVOKABLE void leaveRoom(NeoChatRoom *room);
/**
* @brief Enter the home page of the given space.
*
* This method will tell NeoChat to open the home page for the given space.
*/
Q_INVOKABLE void enterSpaceHome(NeoChatRoom *spaceRoom);
// Overrided methods from UriResolverBase
/**
* @brief Resolve a user URI.
@@ -263,6 +271,26 @@ Q_SIGNALS:
*/
void replaceRoom(NeoChatRoom *room, const QString &event);
/**
* @brief Push a new space home page.
*
* Signal triggered when the main window pageStack should push a new page with
* the space home for the given space room.
*
* @param spaceRoom the space room to be shown on the new page.
*/
void pushSpaceHome(NeoChatRoom *spaceRoom);
/**
* @brief Replace the existing space home.
*
* Signal triggered when the currently displayed room page should be changed
* to the space home for the given space room.
*
* @param spaceRoom the space room to be shown on the new page.
*/
void replaceSpaceHome(NeoChatRoom *spaceRoom);
/**
* @brief Go to the specified event in the current room.
*/

View File

@@ -5,8 +5,6 @@
#include <QDBusMetaType>
#include <KWindowSystem>
#include "controller.h"
#include "neochatroom.h"
#include "roommanager.h"
@@ -82,12 +80,10 @@ RemoteMatches Runner::Match(const QString &searchTerm)
return matches;
}
void Runner::Run(const QString &id, const QString &actionId, const QString &activationToken)
void Runner::Run(const QString &id, const QString &actionId)
{
Q_UNUSED(actionId);
KWindowSystem::setCurrentXdgActivationToken(activationToken);
NeoChatRoom *room = qobject_cast<NeoChatRoom *>(Controller::instance().activeConnection()->room(id));
if (!room) {

View File

@@ -183,7 +183,7 @@ public:
/**
* @brief Handle action calls.
*/
Q_SCRIPTABLE void Run(const QString &id, const QString &actionId, const QString &activationToken = QString());
Q_SCRIPTABLE void Run(const QString &id, const QString &actionId);
private:
RemoteImage serializeImage(const QImage &image);

View File

@@ -1,3 +1,4 @@
// SPDX-FileCopyrightText: 2022 Snehit Sah <hi@snehit.dev>
// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
@@ -33,8 +34,9 @@ public:
static SpaceHierarchyCache _instance;
return _instance;
}
static SpaceHierarchyCache *create(QQmlEngine *, QJSEngine *)
static SpaceHierarchyCache *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}