Compare commits

..

1 Commits

Author SHA1 Message Date
Joshua Goins
1bf23c491a Don't erase existing reply-to relationships when editing messages
Signed-off-by: Joshua Goins <josh@redstrate.com>
2023-01-04 23:36:30 -05:00
138 changed files with 27446 additions and 40679 deletions

View File

@@ -2,7 +2,7 @@
"id": "org.kde.neochat", "id": "org.kde.neochat",
"branch": "master", "branch": "master",
"runtime": "org.kde.Platform", "runtime": "org.kde.Platform",
"runtime-version": "5.15-22.08", "runtime-version": "5.15-21.08",
"sdk": "org.kde.Sdk", "sdk": "org.kde.Sdk",
"command": "neochat", "command": "neochat",
"tags": [ "tags": [

1
.gitignore vendored
View File

@@ -9,4 +9,3 @@ compile_commands.json
kate.project.ctags.* kate.project.ctags.*
*.user *.user
.flatpak-builder/ .flatpak-builder/
.idea/

View File

@@ -4,11 +4,9 @@
include: include:
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/reuse-lint.yml - https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/reuse-lint.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/android.yml - https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/android.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/android-qt6.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/linux.yml - https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/linux.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/linux-qt6.yml # TODO enable once we can have qt6 libQuotient on the CI
# - https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/linux-qt6.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/windows.yml - https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/windows.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/windows-qt6.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/freebsd.yml - https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/freebsd.yml
# - https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/freebsd-qt6.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/flatpak.yml - https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/flatpak.yml

View File

@@ -30,4 +30,4 @@ Dependencies:
'frameworks/kdbusaddons': '@stable' 'frameworks/kdbusaddons': '@stable'
Options: Options:
require-passing-tests-on: [ 'Linux/Qt5', 'FreeBSD', 'Windows' ] require-passing-tests-on: [ 'Linux', 'FreeBSD', 'Windows' ]

View File

@@ -6,19 +6,11 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
# KDE Applications version, managed by release script. project(NeoChat)
set(RELEASE_SERVICE_VERSION_MAJOR "23") set(PROJECT_VERSION "22.11")
set(RELEASE_SERVICE_VERSION_MINOR "03")
set(RELEASE_SERVICE_VERSION_MICRO "70")
set(RELEASE_SERVICE_VERSION "${RELEASE_SERVICE_VERSION_MAJOR}.${RELEASE_SERVICE_VERSION_MINOR}.${RELEASE_SERVICE_VERSION_MICRO}")
project(NeoChat VERSION ${RELEASE_SERVICE_VERSION})
set(KF5_MIN_VERSION "5.91.0") set(KF5_MIN_VERSION "5.91.0")
set(QT_MIN_VERSION "5.15.2") set(QT_MIN_VERSION "5.15.2")
if (ANDROID)
set(QT_MIN_VERSION "5.15.8")
endif()
find_package(ECM ${KF5_MIN_VERSION} REQUIRED NO_MODULE) find_package(ECM ${KF5_MIN_VERSION} REQUIRED NO_MODULE)
@@ -130,8 +122,6 @@ set_package_properties(KF5DocTools PROPERTIES DESCRIPTION
TYPE OPTIONAL TYPE OPTIONAL
) )
find_package(Sqlite3)
if(NOT Quotient_VERSION_MINOR GREATER 6) if(NOT Quotient_VERSION_MINOR GREATER 6)
cmake_policy(SET CMP0063 OLD) cmake_policy(SET CMP0063 OLD)
endif() endif()

0
LICENSES/MIT.txt Normal file → Executable file
View File

View File

@@ -14,8 +14,7 @@
android:name="org.qtproject.qt5.android.bindings.QtActivity" android:name="org.qtproject.qt5.android.bindings.QtActivity"
android:label="NeoChat" android:label="NeoChat"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:launchMode="singleTop" android:launchMode="singleTop">
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
@@ -23,6 +22,7 @@
</intent-filter> </intent-filter>
<meta-data android:name="android.app.lib_name" android:value="neochat-app"/> <meta-data android:name="android.app.lib_name" android:value="neochat-app"/>
<meta-data android:name="android.app.qt_sources_resource_id" android:resource="@array/qt_sources"/>
<meta-data android:name="android.app.repository" android:value="default"/> <meta-data android:name="android.app.repository" android:value="default"/>
<meta-data android:name="android.app.qt_libs_resource_id" android:resource="@array/qt_libs"/> <meta-data android:name="android.app.qt_libs_resource_id" android:resource="@array/qt_libs"/>
<meta-data android:name="android.app.bundled_libs_resource_id" android:resource="@array/bundled_libs"/> <meta-data android:name="android.app.bundled_libs_resource_id" android:resource="@array/bundled_libs"/>
@@ -38,6 +38,8 @@
<meta-data android:name="android.app.load_local_jars" android:value="-- %%INSERT_LOCAL_JARS%% --"/> <meta-data android:name="android.app.load_local_jars" android:value="-- %%INSERT_LOCAL_JARS%% --"/>
<meta-data android:name="android.app.static_init_classes" android:value="-- %%INSERT_INIT_CLASSES%% --"/> <meta-data android:name="android.app.static_init_classes" android:value="-- %%INSERT_INIT_CLASSES%% --"/>
<!-- Messages maps --> <!-- Messages maps -->
<meta-data android:value="@string/ministro_not_found_msg" android:name="android.app.ministro_not_found_msg"/>
<meta-data android:value="@string/ministro_needed_msg" android:name="android.app.ministro_needed_msg"/>
<meta-data android:value="@string/fatal_error_msg" android:name="android.app.fatal_error_msg"/> <meta-data android:value="@string/fatal_error_msg" android:name="android.app.fatal_error_msg"/>
<!-- Splash screen --> <!-- Splash screen -->

View File

@@ -12,7 +12,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.0.2' classpath 'com.android.tools.build:gradle:3.6.4'
} }
} }
@@ -73,10 +73,6 @@ android {
defaultConfig { defaultConfig {
minSdkVersion qtMinSdkVersion minSdkVersion qtMinSdkVersion
targetSdkVersion qtTargetSdkVersion targetSdkVersion qtTargetSdkVersion
applicationId "org.kde.neochat"
namespace "org.kde.neochat"
versionCode timestamp
versionName projectVersionFull
manifestPlaceholders = [versionName: projectVersionFull, versionCode: timestamp] manifestPlaceholders = [versionName: projectVersionFull, versionCode: timestamp]
} }

View File

@@ -231,20 +231,6 @@
<content_attribute id="social-chat">intense</content_attribute> <content_attribute id="social-chat">intense</content_attribute>
</content_rating> </content_rating>
<releases> <releases>
<release version="23.01" date="2023-01-30">
<url>https://plasma-mobile.org/2023/01/30/january-blog-post/</url>
<description>
<p>New features and bugfixes:</p>
<ul>
<li>Notifications will now be shown for all accounts, not just the active one</li>
<li>There is a new "compact" mode for the room list</li>
<li>You can now search in the room history</li>
<li>Emojis and Reactions have been significantly improved</li>
<li>Fixed several crashes around user invitations</li>
<li>Room permission settings can now be configured</li>
</ul>
</description>
</release>
<release version="22.11" date="2022-11-30"> <release version="22.11" date="2022-11-30">
<url>https://plasma-mobile.org/2022/11/30/plasma-mobile-gear-22-11/</url> <url>https://plasma-mobile.org/2022/11/30/plasma-mobile-gear-22-11/</url>
</release> </release>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,47 +6,47 @@
add_library(neochat STATIC add_library(neochat STATIC
controller.cpp controller.cpp
actionshandler.cpp actionshandler.cpp
models/emojimodel.cpp emojimodel.cpp
emojitones.cpp emojitones.cpp
models/customemojimodel.cpp customemojimodel.cpp
clipboard.cpp clipboard.cpp
matriximageprovider.cpp matriximageprovider.cpp
models/messageeventmodel.cpp messageeventmodel.cpp
models/messagefiltermodel.cpp messagefiltermodel.cpp
models/roomlistmodel.cpp roomlistmodel.cpp
models/sortfilterspacelistmodel.cpp sortfilterspacelistmodel.cpp
spacehierarchycache.cpp spacehierarchycache.cpp
roommanager.cpp roommanager.cpp
neochatroom.cpp neochatroom.cpp
neochatuser.cpp neochatuser.cpp
models/userlistmodel.cpp userlistmodel.cpp
models/userfiltermodel.cpp userfiltermodel.cpp
models/publicroomlistmodel.cpp publicroomlistmodel.cpp
models/userdirectorylistmodel.cpp userdirectorylistmodel.cpp
models/keywordnotificationrulemodel.cpp keywordnotificationrulemodel.cpp
utils.cpp utils.cpp
notificationsmanager.cpp notificationsmanager.cpp
models/sortfilterroomlistmodel.cpp sortfilterroomlistmodel.cpp
chatdocumenthandler.cpp chatdocumenthandler.cpp
models/devicesmodel.cpp devicesmodel.cpp
filetypesingleton.cpp filetypesingleton.cpp
login.cpp login.cpp
stickerevent.cpp stickerevent.cpp
models/webshortcutmodel.cpp webshortcutmodel.cpp
blurhash.cpp blurhash.cpp
blurhashimageprovider.cpp blurhashimageprovider.cpp
joinrulesevent.cpp joinrulesevent.cpp
models/collapsestateproxymodel.cpp collapsestateproxymodel.cpp
urlhelper.cpp urlhelper.cpp
windowcontroller.cpp windowcontroller.cpp
linkpreviewer.cpp linkpreviewer.cpp
models/completionmodel.cpp completionmodel.cpp
models/completionproxymodel.cpp completionproxymodel.cpp
models/actionsmodel.cpp actionsmodel.cpp
models/serverlistmodel.cpp serverlistmodel.cpp
models/statemodel.cpp statemodel.cpp
filetransferpseudojob.cpp filetransferpseudojob.cpp
models/searchmodel.cpp searchmodel.cpp
) )
add_executable(neochat-app add_executable(neochat-app
@@ -103,9 +103,6 @@ endif()
if(ANDROID) if(ANDROID)
target_sources(neochat PRIVATE notifyrc.qrc) target_sources(neochat PRIVATE notifyrc.qrc)
target_link_libraries(neochat PRIVATE Qt::Svg OpenSSL::SSL) target_link_libraries(neochat PRIVATE Qt::Svg OpenSSL::SSL)
if(SQLite3_FOUND)
target_link_libraries(neochat-app PRIVATE SQLite::SQLite3)
endif()
target_sources(neochat-app PRIVATE notifyrc.qrc) target_sources(neochat-app PRIVATE notifyrc.qrc)
target_link_libraries(neochat PUBLIC Qt::Svg OpenSSL::SSL) target_link_libraries(neochat PUBLIC Qt::Svg OpenSSL::SSL)
kirigami_package_breeze_icons(ICONS kirigami_package_breeze_icons(ICONS
@@ -162,7 +159,7 @@ if(ANDROID)
"zoom-out" "zoom-out"
"image-rotate-left-symbolic" "image-rotate-left-symbolic"
"image-rotate-right-symbolic" "image-rotate-right-symbolic"
"channel-secure-symbolic" "channel-insecure-symbolic"
"download" "download"
"smiley" "smiley"
"tools-check-spelling" "tools-check-spelling"

View File

@@ -13,11 +13,10 @@
#include <KLocalizedString> #include <KLocalizedString>
#include <QStringBuilder> #include <QStringBuilder>
#include "actionsmodel.h"
#include "controller.h" #include "controller.h"
#include "models/actionsmodel.h" #include "customemojimodel.h"
#include "models/customemojimodel.h"
#include "neochatconfig.h" #include "neochatconfig.h"
#include "neochatroom.h"
#include "neochatuser.h" #include "neochatuser.h"
#include "roommanager.h" #include "roommanager.h"
@@ -59,9 +58,9 @@ void ActionsHandler::setRoom(NeoChatRoom *room)
Q_EMIT roomChanged(); Q_EMIT roomChanged();
} }
void ActionsHandler::handleNewMessage() void ActionsHandler::handleMessage()
{ {
checkEffects(m_room->chatBoxText()); checkEffects();
if (!m_room->chatBoxAttachmentPath().isEmpty()) { if (!m_room->chatBoxAttachmentPath().isEmpty()) {
QUrl url(m_room->chatBoxAttachmentPath()); QUrl url(m_room->chatBoxAttachmentPath());
auto path = url.isLocalFile() ? url.toLocalFile() : url.toString(); auto path = url.isLocalFile() ? url.toLocalFile() : url.toString();
@@ -70,39 +69,13 @@ void ActionsHandler::handleNewMessage()
m_room->setChatBoxText({}); m_room->setChatBoxText({});
return; return;
} }
QString handledText = m_room->chatBoxText(); QString handledText = m_room->chatBoxText();
handledText = handleMentions(handledText);
handleMessage(m_room->chatBoxText(), handledText);
}
void ActionsHandler::handleEdit() std::sort(m_room->mentions()->begin(), m_room->mentions()->end(), [](const auto &a, const auto &b) -> bool {
{
checkEffects(m_room->editText());
QString handledText = m_room->editText();
handledText = handleMentions(handledText, true);
handleMessage(m_room->editText(), handledText, true);
}
QString ActionsHandler::handleMentions(QString handledText, const bool &isEdit)
{
if (!m_room) {
return QString();
}
QVector<Mention> *mentions;
if (isEdit) {
mentions = m_room->editMentions();
} else {
mentions = m_room->mentions();
}
std::sort(mentions->begin(), mentions->end(), [](const auto &a, const auto &b) -> bool {
return a.cursor.anchor() > b.cursor.anchor(); return a.cursor.anchor() > b.cursor.anchor();
}); });
for (const auto &mention : *mentions) { for (const auto &mention : *m_room->mentions()) {
if (mention.text.isEmpty() || mention.id.isEmpty()) { if (mention.text.isEmpty() || mention.id.isEmpty()) {
continue; continue;
} }
@@ -110,16 +83,11 @@ QString ActionsHandler::handleMentions(QString handledText, const bool &isEdit)
mention.cursor.position() - mention.cursor.anchor(), mention.cursor.position() - mention.cursor.anchor(),
QStringLiteral("[%1](https://matrix.to/#/%2)").arg(mention.text, mention.id)); QStringLiteral("[%1](https://matrix.to/#/%2)").arg(mention.text, mention.id));
} }
mentions->clear(); m_room->mentions()->clear();
return handledText;
}
void ActionsHandler::handleMessage(const QString &text, QString handledText, const bool &isEdit)
{
if (NeoChatConfig::allowQuickEdit()) { if (NeoChatConfig::allowQuickEdit()) {
QRegularExpression sed("^s/([^/]*)/([^/]*)(/g)?$"); QRegularExpression sed("^s/([^/]*)/([^/]*)(/g)?$");
auto match = sed.match(text); auto match = sed.match(m_room->chatBoxText());
if (match.hasMatch()) { if (match.hasMatch()) {
const QString regex = match.captured(1); const QString regex = match.captured(1);
const QString replacement = match.captured(2).toHtmlEscaped(); const QString replacement = match.captured(2).toHtmlEscaped();
@@ -178,13 +146,13 @@ void ActionsHandler::handleMessage(const QString &text, QString handledText, con
if (handledText.length() == 0) { if (handledText.length() == 0) {
return; return;
} }
m_room->postMessage(m_room->chatBoxText(), handledText, messageType, m_room->chatBoxReplyId(), m_room->chatBoxEditId());
m_room->postMessage(text, handledText, messageType, m_room->chatBoxReplyId(), isEdit ? m_room->chatBoxEditId() : "");
} }
void ActionsHandler::checkEffects(const QString &text) void ActionsHandler::checkEffects()
{ {
std::optional<QString> effect = std::nullopt; std::optional<QString> effect = std::nullopt;
const auto &text = m_room->chatBoxText();
if (text.contains("\u2744")) { if (text.contains("\u2744")) {
effect = QLatin1String("snowflake"); effect = QLatin1String("snowflake");
} else if (text.contains("\u1F386")) { } else if (text.contains("\u1F386")) {

View File

@@ -33,22 +33,14 @@ Q_SIGNALS:
public Q_SLOTS: public Q_SLOTS:
/** /// \brief Post a message.
* @brief Pre-process text and send message. ///
*/ /// This also interprets commands if any.
void handleNewMessage(); void handleMessage();
/**
* @brief Pre-process text and send edit.
*/
void handleEdit();
private: private:
NeoChatRoom *m_room = nullptr; NeoChatRoom *m_room = nullptr;
void checkEffects(const QString &text); void checkEffects();
QString handleMentions(QString handledText, const bool &isEdit = false);
void handleMessage(const QString &text, QString handledText, const bool &isEdit = false);
}; };
QString markdownToHTML(const QString &markdown); QString markdownToHTML(const QString &markdown);

View File

@@ -158,12 +158,11 @@ QVector<ActionsModel::Action> actions{
return QString(); return QString();
} }
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
const RoomMemberEvent *roomMemberEvent = room->currentState().get<RoomMemberEvent>(text); if (room->currentState().get<RoomMemberEvent>(text)->membership() == Membership::Invite) {
if (roomMemberEvent && roomMemberEvent->membership() == Membership::Invite) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> is already invited to this room.", "%1 is already invited to this room.", text)); Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> is already invited to this room.", "%1 is already invited to this room.", text));
return QString(); return QString();
} }
if (roomMemberEvent && roomMemberEvent->membership() == Membership::Ban) { if (room->currentState().get<RoomMemberEvent>(text)->membership() == Membership::Ban) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> is banned from this room.", "%1 is banned from this room.", text)); Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> is banned from this room.", "%1 is banned from this room.", text));
return QString(); return QString();
} }

View File

@@ -14,9 +14,9 @@
#include <Sonnet/BackgroundChecker> #include <Sonnet/BackgroundChecker>
#include <Sonnet/Settings> #include <Sonnet/Settings>
#include "models/actionsmodel.h" #include "actionsmodel.h"
#include "models/roomlistmodel.h"
#include "neochatroom.h" #include "neochatroom.h"
#include "roomlistmodel.h"
class SyntaxHighlighter : public QSyntaxHighlighter class SyntaxHighlighter : public QSyntaxHighlighter
{ {
@@ -105,19 +105,14 @@ ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
{ {
connect(this, &ChatDocumentHandler::roomChanged, this, [this]() { connect(this, &ChatDocumentHandler::roomChanged, this, [this]() {
m_completionModel->setRoom(m_room); m_completionModel->setRoom(m_room);
static QPointer<NeoChatRoom> previousRoom = nullptr; static NeoChatRoom *previousRoom = nullptr;
if (previousRoom) { if (previousRoom) {
disconnect(previousRoom, &NeoChatRoom::chatBoxTextChanged, this, nullptr); disconnect(previousRoom, &NeoChatRoom::chatBoxTextChanged, this, nullptr);
disconnect(previousRoom, &NeoChatRoom::editTextChanged, this, nullptr);
} }
previousRoom = m_room; previousRoom = m_room;
connect(m_room, &NeoChatRoom::chatBoxTextChanged, this, [this]() { connect(m_room, &NeoChatRoom::chatBoxTextChanged, this, [this]() {
int start = completionStartIndex(); int start = completionStartIndex();
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start)); m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start));
});
connect(m_room, &NeoChatRoom::editTextChanged, this, [this]() {
int start = completionStartIndex();
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
}); });
}); });
connect(this, &ChatDocumentHandler::documentChanged, this, [this]() { connect(this, &ChatDocumentHandler::documentChanged, this, [this]() {
@@ -128,7 +123,7 @@ ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
return; return;
} }
int start = completionStartIndex(); int start = completionStartIndex();
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start)); m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start));
}); });
} }
@@ -143,7 +138,7 @@ int ChatDocumentHandler::completionStartIndex() const
#else #else
const auto cursor = cursorPosition(); const auto cursor = cursorPosition();
#endif #endif
const auto &text = getText(); const auto &text = m_room->chatBoxText();
auto start = std::min(cursor, text.size()) - 1; auto start = std::min(cursor, text.size()) - 1;
while (start > -1) { while (start > -1) {
if (text.at(start) == QLatin1Char(' ')) { if (text.at(start) == QLatin1Char(' ')) {
@@ -155,20 +150,6 @@ int ChatDocumentHandler::completionStartIndex() const
return start; return start;
} }
bool ChatDocumentHandler::isEdit() const
{
return m_isEdit;
}
void ChatDocumentHandler::setIsEdit(bool edit)
{
if (edit == m_isEdit) {
return;
}
m_isEdit = edit;
Q_EMIT isEditChanged();
}
QQuickTextDocument *ChatDocumentHandler::document() const QQuickTextDocument *ChatDocumentHandler::document() const
{ {
return m_document; return m_document;
@@ -223,7 +204,7 @@ void ChatDocumentHandler::complete(int index)
if (m_completionModel->autoCompletionType() == CompletionModel::User) { if (m_completionModel->autoCompletionType() == CompletionModel::User) {
auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString(); auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString();
auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString(); auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
auto text = getText(); auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('@'), cursorPosition() - 1); auto at = text.lastIndexOf(QLatin1Char('@'), cursorPosition() - 1);
QTextCursor cursor(document()->textDocument()); QTextCursor cursor(document()->textDocument());
cursor.setPosition(at); cursor.setPosition(at);
@@ -232,11 +213,11 @@ void ChatDocumentHandler::complete(int index)
cursor.setPosition(at); cursor.setPosition(at);
cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor); cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true); cursor.setKeepPositionOnInsert(true);
pushMention({cursor, name, 0, 0, id}); m_room->mentions()->push_back({cursor, name, 0, 0, id});
m_highlighter->rehighlight(); m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == CompletionModel::Command) { } else if (m_completionModel->autoCompletionType() == CompletionModel::Command) {
auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString(); auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString();
auto text = getText(); auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('/')); auto at = text.lastIndexOf(QLatin1Char('/'));
QTextCursor cursor(document()->textDocument()); QTextCursor cursor(document()->textDocument());
cursor.setPosition(at); cursor.setPosition(at);
@@ -244,7 +225,7 @@ void ChatDocumentHandler::complete(int index)
cursor.insertText(QStringLiteral("/%1 ").arg(command)); cursor.insertText(QStringLiteral("/%1 ").arg(command));
} else if (m_completionModel->autoCompletionType() == CompletionModel::Room) { } else if (m_completionModel->autoCompletionType() == CompletionModel::Room) {
auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString(); auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
auto text = getText(); auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('#'), cursorPosition() - 1); auto at = text.lastIndexOf(QLatin1Char('#'), cursorPosition() - 1);
QTextCursor cursor(document()->textDocument()); QTextCursor cursor(document()->textDocument());
cursor.setPosition(at); cursor.setPosition(at);
@@ -253,11 +234,11 @@ void ChatDocumentHandler::complete(int index)
cursor.setPosition(at); cursor.setPosition(at);
cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor); cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true); cursor.setKeepPositionOnInsert(true);
pushMention({cursor, alias, 0, 0, alias}); m_room->mentions()->push_back({cursor, alias, 0, 0, alias});
m_highlighter->rehighlight(); m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == CompletionModel::Emoji) { } else if (m_completionModel->autoCompletionType() == CompletionModel::Emoji) {
auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString(); auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString();
auto text = getText(); auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char(':')); auto at = text.lastIndexOf(QLatin1Char(':'));
QTextCursor cursor(document()->textDocument()); QTextCursor cursor(document()->textDocument());
cursor.setPosition(at); cursor.setPosition(at);
@@ -300,27 +281,3 @@ void ChatDocumentHandler::setSelectionEnd(int position)
m_selectionEnd = position; m_selectionEnd = position;
Q_EMIT selectionEndChanged(); Q_EMIT selectionEndChanged();
} }
QString ChatDocumentHandler::getText() const
{
if (!m_room) {
return QString();
}
if (m_isEdit) {
return m_room->editText();
} else {
return m_room->chatBoxText();
}
}
void ChatDocumentHandler::pushMention(const Mention mention) const
{
if (!m_room) {
return;
}
if (m_isEdit) {
m_room->editMentions()->push_back(mention);
} else {
m_room->mentions()->push_back(mention);
}
}

View File

@@ -7,9 +7,8 @@
#include <QQuickTextDocument> #include <QQuickTextDocument>
#include <QTextCursor> #include <QTextCursor>
#include "models/completionmodel.h" #include "completionmodel.h"
#include "models/userlistmodel.h" #include "userlistmodel.h"
#include "neochatroom.h"
class QTextDocument; class QTextDocument;
class NeoChatRoom; class NeoChatRoom;
@@ -18,14 +17,6 @@ class SyntaxHighlighter;
class ChatDocumentHandler : public QObject class ChatDocumentHandler : public QObject
{ {
Q_OBJECT Q_OBJECT
/**
* @brief Is the instance being used to handle an edit message.
*
* This is needed to ensure that the text and mentions are saved and retrieved
* from the correct parameters in the assigned room.
*/
Q_PROPERTY(bool isEdit READ isEdit WRITE setIsEdit NOTIFY isEditChanged)
Q_PROPERTY(QQuickTextDocument *document READ document WRITE setDocument NOTIFY documentChanged) Q_PROPERTY(QQuickTextDocument *document READ document WRITE setDocument NOTIFY documentChanged)
Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged) Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged)
Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged) Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged)
@@ -33,14 +24,11 @@ class ChatDocumentHandler : public QObject
Q_PROPERTY(CompletionModel *completionModel READ completionModel NOTIFY completionModelChanged) Q_PROPERTY(CompletionModel *completionModel READ completionModel NOTIFY completionModelChanged)
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) Q_PROPERTY(NeoChatRoom *room READ room NOTIFY roomChanged)
public: public:
explicit ChatDocumentHandler(QObject *parent = nullptr); explicit ChatDocumentHandler(QObject *parent = nullptr);
[[nodiscard]] bool isEdit() const;
void setIsEdit(bool edit);
[[nodiscard]] QQuickTextDocument *document() const; [[nodiscard]] QQuickTextDocument *document() const;
void setDocument(QQuickTextDocument *document); void setDocument(QQuickTextDocument *document);
@@ -61,7 +49,6 @@ public:
void updateCompletions(); void updateCompletions();
CompletionModel *completionModel() const; CompletionModel *completionModel() const;
Q_SIGNALS: Q_SIGNALS:
void isEditChanged();
void documentChanged(); void documentChanged();
void cursorPositionChanged(); void cursorPositionChanged();
void roomChanged(); void roomChanged();
@@ -72,8 +59,6 @@ Q_SIGNALS:
private: private:
int completionStartIndex() const; int completionStartIndex() const;
bool m_isEdit;
QQuickTextDocument *m_document; QQuickTextDocument *m_document;
NeoChatRoom *m_room = nullptr; NeoChatRoom *m_room = nullptr;
@@ -83,9 +68,6 @@ private:
int m_selectionStart; int m_selectionStart;
int m_selectionEnd; int m_selectionEnd;
QString getText() const;
void pushMention(const Mention mention) const;
SyntaxHighlighter *m_highlighter = nullptr; SyntaxHighlighter *m_highlighter = nullptr;
CompletionModel::AutoCompletionType m_completionType = CompletionModel::None; CompletionModel::AutoCompletionType m_completionType = CompletionModel::None;

View File

@@ -33,14 +33,13 @@ QImage Clipboard::image() const
QString Clipboard::saveImage(QString localPath) const QString Clipboard::saveImage(QString localPath) const
{ {
QString imageDir(QStringLiteral("%1/screenshots").arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation))); if (!QDir().exists(QStringLiteral("%1/screenshots").arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)))) {
QDir().mkdir(QStringLiteral("%1/screenshots").arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)));
if (!QDir().exists(imageDir)) {
QDir().mkdir(imageDir);
} }
if (localPath.isEmpty()) { if (localPath.isEmpty()) {
localPath = QStringLiteral("file://%1/%2.png").arg(imageDir, QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-hh-mm-ss"))); localPath = QStringLiteral("file://%1/screenshots/%2.png")
.arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation),
QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-hh-mm-ss")));
} }
QUrl url(localPath); QUrl url(localPath);
if (!url.isLocalFile()) { if (!url.isLocalFile()) {
@@ -52,11 +51,14 @@ QString Clipboard::saveImage(QString localPath) const
return {}; return {};
} }
if (image.save(url.toLocalFile())) { QDir dir;
return localPath; if (!dir.exists(localPath)) {
} else { dir.mkpath(localPath);
return {};
} }
image.save(url.toLocalFile());
return localPath;
} }
void Clipboard::saveText(QString message) void Clipboard::saveText(QString message)

View File

@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "collapsestateproxymodel.h"
#include <KLocalizedString>
bool CollapseStateProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const
{
Q_UNUSED(source_parent);
return sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::EventTypeRole)
!= MessageEventModel::DelegateType::State // If this is not a state, show it
|| sourceModel()->data(sourceModel()->index(source_row + 1, 0), MessageEventModel::EventTypeRole)
!= MessageEventModel::DelegateType::State // If this is the first state in a block, show it. TODO hidden events?
|| sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::ShowSectionRole).toBool() // If it's a new day, show it
|| sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::EventResolvedTypeRole)
!= sourceModel()->data(sourceModel()->index(source_row + 1, 0),
MessageEventModel::EventResolvedTypeRole) // Also show it if it's of a different type than the one before TODO improve in
|| sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::AuthorIdRole)
!= sourceModel()->data(sourceModel()->index(source_row + 1, 0), MessageEventModel::AuthorIdRole); // Also show it if it's a different author
}
QVariant CollapseStateProxyModel::data(const QModelIndex &index, int role) const
{
if (role == AggregateDisplayRole) {
return aggregateEventToString(mapToSource(index).row());
}
return sourceModel()->data(mapToSource(index), role);
}
QHash<int, QByteArray> CollapseStateProxyModel::roleNames() const
{
auto roles = sourceModel()->roleNames();
roles[AggregateDisplayRole] = "aggregateDisplay";
return roles;
}
QString CollapseStateProxyModel::aggregateEventToString(int sourceRow) const
{
QStringList parts;
for (int i = sourceRow; i >= 0; i--) {
parts += sourceModel()->data(sourceModel()->index(i, 0), Qt::DisplayRole).toString();
if (sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::EventTypeRole)
!= MessageEventModel::DelegateType::State // If it's not a state event
|| (i > 0
&& sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::EventResolvedTypeRole)
!= sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::EventResolvedTypeRole)) // or of a different type
|| (i > 0
&& sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorIdRole)
!= sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::AuthorIdRole)) // or by a different author
|| sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible
) {
break;
}
}
if (!parts.isEmpty()) {
QStringList chunks;
while (!parts.isEmpty()) {
chunks += QString();
int count = 1;
auto part = parts.takeFirst();
chunks.last() += part;
while (!parts.isEmpty() && parts.first() == part) {
parts.removeFirst();
count++;
}
if (count > 1) {
chunks.last() += i18ncp("[user did something] n times", " %1 time", " %1 times", count);
}
}
QString text = chunks.takeFirst();
if (chunks.size() > 0) {
while (chunks.size() > 1) {
text += i18nc("[action 1], [action 2 and action 3]", ", ");
text += chunks.takeFirst();
}
text += i18nc("[action 1, action 2] and [action 3]", " and ");
text += chunks.takeFirst();
}
return text;
} else {
return {};
}
}

View File

@@ -12,29 +12,10 @@ class CollapseStateProxyModel : public QSortFilterProxyModel
public: public:
enum Roles { enum Roles {
AggregateDisplayRole = MessageEventModel::LastRole + 1, AggregateDisplayRole = MessageEventModel::LastRole + 1,
StateEventsRole,
AuthorListRole,
}; };
[[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; [[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override;
[[nodiscard]] QHash<int, QByteArray> roleNames() const override; [[nodiscard]] QHash<int, QByteArray> roleNames() const override;
[[nodiscard]] QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override; [[nodiscard]] QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
/**
* @brief QString aggregating the text of consecutive state events starting at row.
*
* If state events happen on different days they will be split into two aggregate
* events.
*/
[[nodiscard]] QString aggregateEventToString(int row) const; [[nodiscard]] QString aggregateEventToString(int row) const;
/**
* @brief Return a list of consecutive state events starting at row.
*
* If state events happen on different days they will be split into two aggregate
* events.
*/
[[nodiscard]] QVariantList stateEventsList(int row) const;
/**
* @brief List of unique authors for the aggregate state events starting at row.
*/
[[nodiscard]] QVariantList authorList(int row) const;
}; };

View File

@@ -16,7 +16,7 @@ CompletionModel::CompletionModel(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
, m_filterModel(new CompletionProxyModel()) , m_filterModel(new CompletionProxyModel())
, m_userListModel(new UserListModel(this)) , m_userListModel(new UserListModel(this))
, m_emojiModel(new QConcatenateTablesProxyModel(this)) , m_emojiModel(new KConcatenateRowsProxyModel(this))
{ {
connect(this, &CompletionModel::textChanged, this, &CompletionModel::updateCompletion); connect(this, &CompletionModel::textChanged, this, &CompletionModel::updateCompletion);
connect(this, &CompletionModel::roomChanged, this, [this]() { connect(this, &CompletionModel::roomChanged, this, [this]() {

View File

@@ -3,9 +3,10 @@
#pragma once #pragma once
#include <QConcatenateTablesProxyModel>
#include <QSortFilterProxyModel> #include <QSortFilterProxyModel>
#include <KConcatenateRowsProxyModel>
#include "roomlistmodel.h" #include "roomlistmodel.h"
class CompletionProxyModel; class CompletionProxyModel;
@@ -74,6 +75,6 @@ private:
UserListModel *m_userListModel; UserListModel *m_userListModel;
RoomListModel *m_roomListModel; RoomListModel *m_roomListModel;
QConcatenateTablesProxyModel *m_emojiModel; KConcatenateRowsProxyModel *m_emojiModel;
}; };
Q_DECLARE_METATYPE(CompletionModel::AutoCompletionType); Q_DECLARE_METATYPE(CompletionModel::AutoCompletionType);

View File

@@ -4,11 +4,7 @@
#include "controller.h" #include "controller.h"
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
#include <qt5keychain/keychain.h> #include <qt5keychain/keychain.h>
#else
#include <qt6keychain/keychain.h>
#endif
#include <KConfig> #include <KConfig>
#include <KConfigGroup> #include <KConfigGroup>
@@ -132,7 +128,16 @@ Controller::Controller(QObject *parent)
if (AccountRegistry::instance().size() > oldAccountCount) { if (AccountRegistry::instance().size() > oldAccountCount) {
auto connection = AccountRegistry::instance().accounts()[AccountRegistry::instance().size() - 1]; auto connection = AccountRegistry::instance().accounts()[AccountRegistry::instance().size() - 1];
connect(connection, &Connection::syncDone, this, [=]() { connect(connection, &Connection::syncDone, this, [=]() {
handleNotifications(connection); bool changes = false;
for (const auto &room : connection->allRooms()) {
if (m_notificationCounts[room] != room->unreadStats().notableCount) {
m_notificationCounts[room] = room->unreadStats().notableCount;
changes = true;
}
}
if (changes) {
handleNotifications();
}
}); });
} }
oldAccountCount = AccountRegistry::instance().size(); oldAccountCount = AccountRegistry::instance().size();
@@ -141,16 +146,19 @@ Controller::Controller(QObject *parent)
} }
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
void Controller::handleNotifications(QPointer<Quotient::Connection> connection) void Controller::handleNotifications()
{ {
static QStringList initial; static bool initial = true;
static QStringList oldNotifications; static QStringList oldNotifications;
auto job = connection->callApi<GetNotificationsJob>(); if (!m_connection) {
return;
}
auto job = m_connection->callApi<GetNotificationsJob>();
connect(job, &BaseJob::success, this, [job, connection]() { connect(job, &BaseJob::success, this, [this, job]() {
const auto notifications = job->jsonData()["notifications"].toArray(); const auto notifications = job->jsonData()["notifications"].toArray();
if (!initial.contains(connection->user()->id())) { if (initial) {
initial.append(connection->user()->id()); initial = false;
for (const auto &n : notifications) { for (const auto &n : notifications) {
oldNotifications += n.toObject()["event"].toObject()["event_id"].toString(); oldNotifications += n.toObject()["event"].toObject()["event_id"].toString();
} }
@@ -166,7 +174,7 @@ void Controller::handleNotifications(QPointer<Quotient::Connection> connection)
continue; continue;
} }
oldNotifications += notification["event"].toObject()["event_id"].toString(); oldNotifications += notification["event"].toObject()["event_id"].toString();
auto room = connection->room(notification["room_id"].toString()); auto room = m_connection->room(notification["room_id"].toString());
// If room exists, room is NOT active OR the application is NOT active, show notification // If room exists, room is NOT active OR the application is NOT active, show notification
if (room && !(room->id() == RoomManager::instance().currentRoom()->id() && QGuiApplication::applicationState() == Qt::ApplicationActive)) { if (room && !(room->id() == RoomManager::instance().currentRoom()->id() && QGuiApplication::applicationState() == Qt::ApplicationActive)) {
@@ -188,7 +196,7 @@ void Controller::handleNotifications(QPointer<Quotient::Connection> connection)
if (notification["event"]["type"] == "m.room.encrypted") { if (notification["event"]["type"] == "m.room.encrypted") {
#ifdef Quotient_E2EE_ENABLED #ifdef Quotient_E2EE_ENABLED
auto decrypted = connection->decryptNotification(notification); auto decrypted = m_connection->decryptNotification(notification);
body = decrypted["content"].toObject()["body"].toString(); body = decrypted["content"].toObject()["body"].toString();
#endif #endif
if (body.isEmpty()) { if (body.isEmpty()) {
@@ -373,11 +381,6 @@ void Controller::invokeLogin()
if (error == "Unrecognised access token") { if (error == "Unrecognised access token") {
Q_EMIT errorOccured(i18n("Login Failed: Access Token invalid or revoked")); Q_EMIT errorOccured(i18n("Login Failed: Access Token invalid or revoked"));
logout(connection, false); logout(connection, false);
} else if (error == "Connection closed") {
Q_EMIT errorOccured(i18n("Login Failed: %1", error));
// Failed due to network connection issue. This might happen when the homeserver is
// temporary down, or the user trying to re-launch NeoChat in a network that cannot
// connect to the homeserver. In this case, we don't want to do logout().
} else { } else {
Q_EMIT errorOccured(i18n("Login Failed: %1", error)); Q_EMIT errorOccured(i18n("Login Failed: %1", error));
logout(connection, true); logout(connection, true);

View File

@@ -119,7 +119,7 @@ private:
bool hasWindowSystem() const; bool hasWindowSystem() const;
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
void handleNotifications(QPointer<Quotient::Connection> connection); void handleNotifications();
#endif #endif
private Q_SLOTS: private Q_SLOTS:

View File

@@ -4,8 +4,8 @@
#pragma once #pragma once
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QRegularExpression>
#include <memory> #include <memory>
#include <QRegularExpression>
struct CustomEmoji { struct CustomEmoji {
QString name; // with :semicolons: QString name; // with :semicolons:

View File

@@ -70,12 +70,7 @@ void DevicesModel::logout(int index, const QString &password)
auto job = Controller::instance().activeConnection()->callApi<NeochatDeleteDeviceJob>(m_devices[index].deviceId); auto job = Controller::instance().activeConnection()->callApi<NeochatDeleteDeviceJob>(m_devices[index].deviceId);
connect(job, &BaseJob::result, this, [this, job, password, index] { connect(job, &BaseJob::result, this, [this, job, password, index] {
auto onSuccess = [this, index]() { if (job->error() != 0) {
beginRemoveRows(QModelIndex(), index, index);
m_devices.remove(index);
endRemoveRows();
};
if (job->error() != BaseJob::Success) {
QJsonObject replyData = job->jsonData(); QJsonObject replyData = job->jsonData();
QJsonObject authData; QJsonObject authData;
authData["session"] = replyData["session"]; authData["session"] = replyData["session"];
@@ -84,9 +79,11 @@ void DevicesModel::logout(int index, const QString &password)
QJsonObject identifier = {{"type", "m.id.user"}, {"user", Controller::instance().activeConnection()->user()->id()}}; QJsonObject identifier = {{"type", "m.id.user"}, {"user", Controller::instance().activeConnection()->user()->id()}};
authData["identifier"] = identifier; authData["identifier"] = identifier;
auto *innerJob = Controller::instance().activeConnection()->callApi<NeochatDeleteDeviceJob>(m_devices[index].deviceId, authData); auto *innerJob = Controller::instance().activeConnection()->callApi<NeochatDeleteDeviceJob>(m_devices[index].deviceId, authData);
connect(innerJob, &BaseJob::success, this, onSuccess); connect(innerJob, &BaseJob::success, this, [this, index]() {
} else { beginRemoveRows(QModelIndex(), index, index);
onSuccess(); m_devices.remove(index);
endRemoveRows();
});
} }
}); });
} }

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: LGPL-2.0-or-later // SPDX-License-Identifier: LGPL-2.0-or-later
#include "emojitones.h" #include "emojitones.h"
#include "models/emojimodel.h" #include "emojimodel.h"
QMultiHash<QString, QVariant> EmojiTones::_tones = { QMultiHash<QString, QVariant> EmojiTones::_tones = {
#include "emojitones_data.h" #include "emojitones_data.h"

View File

@@ -42,39 +42,39 @@
#include "blurhashimageprovider.h" #include "blurhashimageprovider.h"
#include "chatdocumenthandler.h" #include "chatdocumenthandler.h"
#include "clipboard.h" #include "clipboard.h"
#include "collapsestateproxymodel.h"
#include "controller.h" #include "controller.h"
#include "customemojimodel.h"
#include "devicesmodel.h"
#include "emojimodel.h"
#include "filetypesingleton.h" #include "filetypesingleton.h"
#include "joinrulesevent.h" #include "joinrulesevent.h"
#include "linkpreviewer.h" #include "linkpreviewer.h"
#include "keywordnotificationrulemodel.h"
#include "login.h" #include "login.h"
#include "matriximageprovider.h" #include "matriximageprovider.h"
#include "models/collapsestateproxymodel.h" #include "messageeventmodel.h"
#include "models/customemojimodel.h" #include "messagefiltermodel.h"
#include "models/devicesmodel.h"
#include "models/emojimodel.h"
#include "models/keywordnotificationrulemodel.h"
#include "models/messageeventmodel.h"
#include "models/messagefiltermodel.h"
#include "models/publicroomlistmodel.h"
#include "models/roomlistmodel.h"
#include "models/searchmodel.h"
#include "models/serverlistmodel.h"
#include "models/sortfilterroomlistmodel.h"
#include "models/sortfilterspacelistmodel.h"
#include "models/userdirectorylistmodel.h"
#include "models/userfiltermodel.h"
#include "models/userlistmodel.h"
#include "models/webshortcutmodel.h"
#include "neochatconfig.h" #include "neochatconfig.h"
#include "neochatroom.h" #include "neochatroom.h"
#include "neochatuser.h" #include "neochatuser.h"
#include "notificationsmanager.h" #include "notificationsmanager.h"
#include "searchmodel.h"
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
#include "pollhandler.h" #include "pollhandler.h"
#endif #endif
#include "publicroomlistmodel.h"
#include "roomlistmodel.h"
#include "roommanager.h" #include "roommanager.h"
#include "serverlistmodel.h"
#include "sortfilterroomlistmodel.h"
#include "sortfilterspacelistmodel.h"
#include "spacehierarchycache.h" #include "spacehierarchycache.h"
#include "urlhelper.h" #include "urlhelper.h"
#include "userdirectorylistmodel.h"
#include "userfiltermodel.h"
#include "userlistmodel.h"
#include "webshortcutmodel.h"
#include "windowcontroller.h" #include "windowcontroller.h"
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
#include <keyverificationsession.h> #include <keyverificationsession.h>
@@ -82,19 +82,15 @@
#ifdef HAVE_COLORSCHEME #ifdef HAVE_COLORSCHEME
#include "colorschemer.h" #include "colorschemer.h"
#endif #endif
#include "models/completionmodel.h" #include "completionmodel.h"
#include "models/statemodel.h"
#include "neochatuser.h" #include "neochatuser.h"
#include "statemodel.h"
#ifdef HAVE_RUNNER #ifdef HAVE_RUNNER
#include "runner.h" #include "runner.h"
#include <QDBusConnection> #include <QDBusConnection>
#endif #endif
#ifdef Q_OS_WINDOWS
#include <Windows.h>
#endif
using namespace Quotient; using namespace Quotient;
class NetworkAccessManagerFactory : public QQmlNetworkAccessManagerFactory class NetworkAccessManagerFactory : public QQmlNetworkAccessManagerFactory

View File

@@ -67,7 +67,6 @@ QHash<int, QByteArray> MessageEventModel::roleNames() const
roles[IsNameChangeRole] = "isNameChange"; roles[IsNameChangeRole] = "isNameChange";
roles[IsAvatarChangeRole] = "isAvatarChange"; roles[IsAvatarChangeRole] = "isAvatarChange";
roles[IsRedactedRole] = "isRedacted"; roles[IsRedactedRole] = "isRedacted";
roles[GenericDisplayRole] = "genericDisplay";
return roles; return roles;
} }
@@ -440,7 +439,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
case EventTypeRole: case EventTypeRole:
return DelegateType::ReadMarker; return DelegateType::ReadMarker;
case TimeRole: { case TimeRole: {
const QDateTime eventDate = data(index(m_lastReadEventIndex.row() + 1, 0), TimeRole).toDateTime().toLocalTime(); const QDateTime eventDate = data(index(m_lastReadEventIndex.row() + 1, 0), TimeRole).toDateTime();
const KFormat format; const KFormat format;
return format.formatRelativeDateTime(eventDate, QLocale::ShortFormat); return format.formatRelativeDateTime(eventDate, QLocale::ShortFormat);
} }
@@ -463,14 +462,6 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
return m_currentRoom->eventToString(evt, Qt::RichText); return m_currentRoom->eventToString(evt, Qt::RichText);
} }
if (role == GenericDisplayRole) {
if (evt.isRedacted()) {
return i18n("<i>[This message was deleted]</i>");
}
return m_currentRoom->eventToGenericString(evt);
}
if (role == FormattedBodyRole) { if (role == FormattedBodyRole) {
if (auto e = eventCast<const RoomMessageEvent>(&evt)) { if (auto e = eventCast<const RoomMessageEvent>(&evt)) {
if (e->hasTextContent() && e->mimeType().name() != "text/plain") { if (e->hasTextContent() && e->mimeType().name() != "text/plain") {
@@ -946,12 +937,24 @@ QVariant MessageEventModel::getLastLocalUserMessageEventId()
if (content.contains("m.new_content")) { if (content.contains("m.new_content")) {
// The message has been edited so we have to return the id of the original message instead of the replacement // The message has been edited so we have to return the id of the original message instead of the replacement
eventId = content["m.relates_to"].toObject()["event_id"].toString(); eventId = content["m.relates_to"].toObject()["event_id"].toString();
e = eventCast<const RoomMessageEvent>(m_currentRoom->findInTimeline(eventId)->event());
if (!e) {
return {};
}
content = e->contentJson();
} else { } else {
// For any message that isn't an edit return the id of the current message // For any message that isn't an edit return the id of the current message
eventId = (*it)->id(); eventId = (*it)->id();
} }
targetMessage.insert("event_id", eventId); targetMessage.insert("event_id", eventId);
targetMessage.insert("formattedBody", content["formatted_body"].toString()); targetMessage.insert("formattedBody", content["formatted_body"].toString());
// keep reply relationship
if (content.contains("m.relates_to")) {
targetMessage.insert("m.relates_to", content["m.relates_to"].toObject());
}
// Need to get the message from the original eventId or body will have * on the front // Need to get the message from the original eventId or body will have * on the front
QModelIndex idx = index(eventIDToIndex(eventId), 0); QModelIndex idx = index(eventIDToIndex(eventId), 0);
targetMessage.insert("message", idx.data(Qt::UserRole + 2)); targetMessage.insert("message", idx.data(Qt::UserRole + 2));

View File

@@ -45,7 +45,6 @@ public:
AnnotationRole, AnnotationRole,
UserMarkerRole, UserMarkerRole,
FormattedBodyRole, FormattedBodyRole,
GenericDisplayRole,
MimeTypeRole, MimeTypeRole,
FileMimetypeIcon, FileMimetypeIcon,

View File

@@ -1,134 +0,0 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "collapsestateproxymodel.h"
#include "messageeventmodel.h"
#include <KLocalizedString>
bool CollapseStateProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const
{
Q_UNUSED(source_parent);
return sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::EventTypeRole)
!= MessageEventModel::DelegateType::State // If this is not a state, show it
|| sourceModel()->data(sourceModel()->index(source_row + 1, 0), MessageEventModel::EventTypeRole)
!= MessageEventModel::DelegateType::State // If this is the first state in a block, show it. TODO hidden events?
|| sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::ShowSectionRole).toBool(); // If it's a new day, show it
}
QVariant CollapseStateProxyModel::data(const QModelIndex &index, int role) const
{
if (role == AggregateDisplayRole) {
return aggregateEventToString(mapToSource(index).row());
} else if (role == StateEventsRole) {
return stateEventsList(mapToSource(index).row());
} else if (role == AuthorListRole) {
return authorList(mapToSource(index).row());
}
return sourceModel()->data(mapToSource(index), role);
}
QHash<int, QByteArray> CollapseStateProxyModel::roleNames() const
{
auto roles = sourceModel()->roleNames();
roles[AggregateDisplayRole] = "aggregateDisplay";
roles[StateEventsRole] = "stateEvents";
roles[AuthorListRole] = "authorList";
return roles;
}
QString CollapseStateProxyModel::aggregateEventToString(int sourceRow) const
{
QStringList parts;
QVariantList uniqueAuthors;
for (int i = sourceRow; i >= 0; i--) {
parts += sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::GenericDisplayRole).toString();
QVariant nextAuthor = sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorRole);
if (!uniqueAuthors.contains(nextAuthor)) {
uniqueAuthors.append(nextAuthor);
}
if (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::EventTypeRole)
!= MessageEventModel::DelegateType::State // If it's not a state event
|| sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible
) {
break;
}
}
parts.sort(); // Sort them so that all identical events can be collected.
if (!parts.isEmpty()) {
QStringList chunks;
while (!parts.isEmpty()) {
chunks += QString();
int count = 1;
auto part = parts.takeFirst();
chunks.last() += part;
while (!parts.isEmpty() && parts.first() == part) {
parts.removeFirst();
count++;
}
if (count > 1 && uniqueAuthors.length() == 1) {
chunks.last() += i18ncp("n times", " %1 time ", " %1 times ", count);
}
}
chunks.removeDuplicates();
QString text = "<style>a {text-decoration: none;}</style>"; // There can be links in the event text so make sure all are styled.
// The author text is either "n users" if > 1 user or the matrix.to link to a single user.
QString userText = uniqueAuthors.length() > 1 ? i18ncp("n users", " %1 user ", " %1 users ", uniqueAuthors.length())
: QStringLiteral("<a href=\"https://matrix.to/#/%1\" style=\"color: %2\">%3</a> ")
.arg(uniqueAuthors[0].toMap()["id"].toString(),
uniqueAuthors[0].toMap()["color"].toString(),
uniqueAuthors[0].toMap()["displayName"].toString());
text += userText;
text += chunks.takeFirst();
if (chunks.size() > 0) {
while (chunks.size() > 1) {
text += i18nc("[action 1], [action 2 and/or action 3]", ", ");
text += chunks.takeFirst();
}
text += uniqueAuthors.length() > 1 ? i18nc("[action 1, action 2] or [action 3]", " or ") : i18nc("[action 1, action 2] and [action 3]", " and ");
text += chunks.takeFirst();
}
return text;
} else {
return {};
}
}
QVariantList CollapseStateProxyModel::stateEventsList(int sourceRow) const
{
QVariantList stateEvents;
for (int i = sourceRow; i >= 0; i--) {
auto nextState = QVariantMap{
{"author", sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorRole)},
{"authorDisplayName", sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorDisplayNameRole).toString()},
{"text", sourceModel()->data(sourceModel()->index(i, 0), Qt::DisplayRole).toString()},
};
stateEvents.append(nextState);
if (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::EventTypeRole)
!= MessageEventModel::DelegateType::State // If it's not a state event
|| sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible
) {
break;
}
}
return stateEvents;
}
QVariantList CollapseStateProxyModel::authorList(int sourceRow) const
{
QVariantList uniqueAuthors;
for (int i = sourceRow; i >= 0; i--) {
QVariant nextAvatar = sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorRole);
if (!uniqueAuthors.contains(nextAvatar)) {
uniqueAuthors.append(nextAvatar);
}
if (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::EventTypeRole)
!= MessageEventModel::DelegateType::State // If it's not a state event
|| sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible
) {
break;
}
}
return uniqueAuthors;
}

View File

@@ -25,6 +25,10 @@
<label>Background transparency value</label> <label>Background transparency value</label>
<default>0.3</default> <default>0.3</default>
</entry> </entry>
<entry name="ShowNotifications" type="bool">
<label>Show notifications</label>
<default>true</default>
</entry>
<entry name="MergeRoomList" type="bool"> <entry name="MergeRoomList" type="bool">
<label>Merge Room Lists</label> <label>Merge Room Lists</label>
<default>false</default> <default>false</default>

View File

@@ -658,135 +658,6 @@ QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format,
i18n("Unknown event")); i18n("Unknown event"));
} }
QString NeoChatRoom::eventToGenericString(const RoomEvent &evt) const
{
#ifdef QUOTIENT_07
return switchOnType(
#else
return visit(
#endif
evt,
[](const RoomMessageEvent &e) {
Q_UNUSED(e)
return i18n("sent a message");
},
[](const StickerEvent &e) {
Q_UNUSED(e)
return i18n("sent a sticker");
},
[](const RoomMemberEvent &e) {
switch (e.membership()) {
case MembershipType::Invite:
if (e.repeatsState()) {
return i18n("reinvited someone to the room");
}
Q_FALLTHROUGH();
case MembershipType::Join: {
QString text{};
// Part 1: invites and joins
if (e.repeatsState()) {
text = i18n("joined the room (repeated)");
} else if (e.changesMembership()) {
text = e.membership() == MembershipType::Invite ? i18n("invited someone to the room") : i18n("joined the room");
}
if (!text.isEmpty()) {
return text;
}
// Part 2: profile changes of joined members
if (e.isRename()) {
if (e.displayName().isEmpty()) {
text = i18nc("their refers to a singular user", "cleared their display name");
} else {
text = i18nc("their refers to a singular user", "changed their display name");
}
}
if (e.isAvatarUpdate()) {
if (!text.isEmpty()) {
text += i18n(" and ");
}
if (e.avatarUrl().isEmpty()) {
text += i18nc("their refers to a singular user", "cleared their avatar");
#ifdef QUOTIENT_07
} else if (!e.prevContent()->avatarUrl) {
#else
} else if (e.prevContent()->avatarUrl.isEmpty()) {
#endif
text += i18n("set an avatar");
} else {
text += i18nc("their refers to a singular user", "updated their avatar");
}
}
if (text.isEmpty()) {
text = i18nc("<user> changed nothing", "changed nothing");
}
return text;
}
case MembershipType::Leave:
if (e.prevContent() && e.prevContent()->membership == MembershipType::Invite) {
return (e.senderId() != e.userId()) ? i18n("withdrew a user's invitation") : i18n("rejected the invitation");
}
if (e.prevContent() && e.prevContent()->membership == MembershipType::Ban) {
return (e.senderId() != e.userId()) ? i18n("unbanned a user") : i18n("self-unbanned");
}
return (e.senderId() != e.userId()) ? i18n("put a user out of the room") : i18n("left the room");
case MembershipType::Ban:
if (e.senderId() != e.userId()) {
return i18n("banned a user from the room");
} else {
return i18n("self-banned from the room");
}
case MembershipType::Knock: {
return i18n("requested an invite");
}
default:;
}
return i18n("made something unknown");
},
[](const RoomCanonicalAliasEvent &e) {
return (e.alias().isEmpty()) ? i18n("cleared the room main alias") : i18n("set the room main alias");
},
[](const RoomNameEvent &e) {
return (e.name().isEmpty()) ? i18n("cleared the room name") : i18n("set the room name");
},
[](const RoomTopicEvent &e) {
return (e.topic().isEmpty()) ? i18n("cleared the topic") : i18n("set the topic");
},
[](const RoomAvatarEvent &) {
return i18n("changed the room avatar");
},
[](const EncryptionEvent &) {
return i18n("activated End-to-End Encryption");
},
[](const RoomCreateEvent &e) {
return e.isUpgrade() ? i18n("upgraded the room version") : i18n("created the room");
},
[](const RoomPowerLevelsEvent &) {
return i18nc("'power level' means permission level", "changed the power levels for this room");
},
[](const StateEventBase &e) {
if (e.matrixType() == QLatin1String("m.room.server_acl")) {
return i18n("changed the server access control lists for this room");
}
if (e.matrixType() == QLatin1String("im.vector.modular.widgets")) {
if (e.fullJson()["unsigned"]["prev_content"].toObject().isEmpty()) {
return i18n("added a widget");
}
if (e.contentJson().isEmpty()) {
return i18n("removed a widget");
}
return i18n("configured a widget");
}
return i18n("updated the state");
},
#ifdef QUOTIENT_07
[](const PollStartEvent &e) {
return i18n("started a poll");
},
#endif
i18n("Unknown event"));
}
void NeoChatRoom::changeAvatar(const QUrl &localFile) void NeoChatRoom::changeAvatar(const QUrl &localFile)
{ {
const auto job = connection()->uploadFile(localFile.toLocalFile()); const auto job = connection()->uploadFile(localFile.toLocalFile());
@@ -868,13 +739,25 @@ void NeoChatRoom::postHtmlMessage(const QString &text, const QString &html, Mess
} }
if (isEdit) { if (isEdit) {
QJsonObject content{{"body", text}, {"msgtype", msgTypeToString(type)}, {"format", "org.matrix.custom.html"}, {"formatted_body", html}};
if (isReply) {
content["m.relates_to"] =
QJsonObject {
{"m.in_reply_to",
QJsonObject {
{"event_id", replyEventId}
}
}
};
}
QJsonObject json{ QJsonObject json{
{"type", "m.room.message"}, {"type", "m.room.message"},
{"msgtype", msgTypeToString(type)}, {"msgtype", msgTypeToString(type)},
{"body", "* " + text}, {"body", "* " + text},
{"format", "org.matrix.custom.html"}, {"format", "org.matrix.custom.html"},
{"formatted_body", html}, {"formatted_body", html},
{"m.new_content", QJsonObject{{"body", text}, {"msgtype", msgTypeToString(type)}, {"format", "org.matrix.custom.html"}, {"formatted_body", html}}}, {"m.new_content", content},
{"m.relates_to", QJsonObject{{"rel_type", "m.replace"}, {"event_id", relateToEventId}}}}; {"m.relates_to", QJsonObject{{"rel_type", "m.replace"}, {"event_id", relateToEventId}}}};
postJson("m.room.message", json); postJson("m.room.message", json);
@@ -1626,17 +1509,6 @@ void NeoChatRoom::setChatBoxText(const QString &text)
Q_EMIT chatBoxTextChanged(); Q_EMIT chatBoxTextChanged();
} }
QString NeoChatRoom::editText() const
{
return m_editText;
}
void NeoChatRoom::setEditText(const QString &text)
{
m_editText = text;
Q_EMIT editTextChanged();
}
QString NeoChatRoom::chatBoxReplyId() const QString NeoChatRoom::chatBoxReplyId() const
{ {
return m_chatBoxReplyId; return m_chatBoxReplyId;
@@ -1713,11 +1585,6 @@ QVector<Mention> *NeoChatRoom::mentions()
return &m_mentions; return &m_mentions;
} }
QVector<Mention> *NeoChatRoom::editMentions()
{
return &m_editMentions;
}
QString NeoChatRoom::savedText() const QString NeoChatRoom::savedText() const
{ {
return m_savedText; return m_savedText;

View File

@@ -12,8 +12,8 @@
#include <qcoro/task.h> #include <qcoro/task.h>
#include "neochatuser.h" class PollHandler;
#include "pollhandler.h" class NeoChatUser;
class PushNotificationState : public QObject class PushNotificationState : public QObject
{ {
@@ -79,11 +79,6 @@ class NeoChatRoom : public Quotient::Room
// Due to problems with QTextDocument, unlike the other properties here, chatBoxText is *not* used to store the text when switching rooms // Due to problems with QTextDocument, unlike the other properties here, chatBoxText is *not* used to store the text when switching rooms
Q_PROPERTY(QString chatBoxText READ chatBoxText WRITE setChatBoxText NOTIFY chatBoxTextChanged) Q_PROPERTY(QString chatBoxText READ chatBoxText WRITE setChatBoxText NOTIFY chatBoxTextChanged)
/**
* @brief The text for any message currently being edited in the room.
*/
Q_PROPERTY(QString editText READ editText WRITE setEditText NOTIFY editTextChanged)
Q_PROPERTY(QString chatBoxReplyId READ chatBoxReplyId WRITE setChatBoxReplyId NOTIFY chatBoxReplyIdChanged) Q_PROPERTY(QString chatBoxReplyId READ chatBoxReplyId WRITE setChatBoxReplyId NOTIFY chatBoxReplyIdChanged)
Q_PROPERTY(QString chatBoxEditId READ chatBoxEditId WRITE setChatBoxEditId NOTIFY chatBoxEditIdChanged) Q_PROPERTY(QString chatBoxEditId READ chatBoxEditId WRITE setChatBoxEditId NOTIFY chatBoxEditIdChanged)
Q_PROPERTY(NeoChatUser *chatBoxReplyUser READ chatBoxReplyUser NOTIFY chatBoxReplyIdChanged) Q_PROPERTY(NeoChatUser *chatBoxReplyUser READ chatBoxReplyUser NOTIFY chatBoxReplyIdChanged)
@@ -254,7 +249,6 @@ public:
[[nodiscard]] QString avatarMediaId() const; [[nodiscard]] QString avatarMediaId() const;
[[nodiscard]] QString eventToString(const Quotient::RoomEvent &evt, Qt::TextFormat format = Qt::PlainText, bool removeReply = true) const; [[nodiscard]] QString eventToString(const Quotient::RoomEvent &evt, Qt::TextFormat format = Qt::PlainText, bool removeReply = true) const;
[[nodiscard]] QString eventToGenericString(const Quotient::RoomEvent &evt) const;
Q_INVOKABLE [[nodiscard]] bool containsUser(const QString &userID) const; Q_INVOKABLE [[nodiscard]] bool containsUser(const QString &userID) const;
Q_INVOKABLE [[nodiscard]] bool isUserBanned(const QString &user) const; Q_INVOKABLE [[nodiscard]] bool isUserBanned(const QString &user) const;
@@ -276,9 +270,6 @@ public:
QString chatBoxText() const; QString chatBoxText() const;
void setChatBoxText(const QString &text); void setChatBoxText(const QString &text);
QString editText() const;
void setEditText(const QString &text);
QString chatBoxReplyId() const; QString chatBoxReplyId() const;
void setChatBoxReplyId(const QString &replyId); void setChatBoxReplyId(const QString &replyId);
@@ -296,11 +287,6 @@ public:
QVector<Mention> *mentions(); QVector<Mention> *mentions();
/**
* @brief Vector of mentions in the current edit text.
*/
QVector<Mention> *editMentions();
QString savedText() const; QString savedText() const;
void setSavedText(const QString &savedText); void setSavedText(const QString &savedText);
@@ -350,12 +336,10 @@ private:
QCoro::Task<void> doUploadFile(QUrl url, QString body = QString()); QCoro::Task<void> doUploadFile(QUrl url, QString body = QString());
QString m_chatBoxText; QString m_chatBoxText;
QString m_editText;
QString m_chatBoxReplyId; QString m_chatBoxReplyId;
QString m_chatBoxEditId; QString m_chatBoxEditId;
QString m_chatBoxAttachmentPath; QString m_chatBoxAttachmentPath;
QVector<Mention> m_mentions; QVector<Mention> m_mentions;
QVector<Mention> m_editMentions;
QString m_savedText; QString m_savedText;
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
QCache<QString, PollHandler> m_polls; QCache<QString, PollHandler> m_polls;
@@ -378,7 +362,6 @@ Q_SIGNALS:
void pushNotificationStateChanged(PushNotificationState::State state); void pushNotificationStateChanged(PushNotificationState::State state);
void showMessage(MessageType messageType, const QString &message); void showMessage(MessageType messageType, const QString &message);
void chatBoxTextChanged(); void chatBoxTextChanged();
void editTextChanged();
void chatBoxReplyIdChanged(); void chatBoxReplyIdChanged();
void chatBoxEditIdChanged(); void chatBoxEditIdChanged();
void chatBoxAttachmentPathChanged(); void chatBoxAttachmentPathChanged();

View File

@@ -11,12 +11,6 @@
#include <KNotification> #include <KNotification>
#include <KNotificationReplyAction> #include <KNotificationReplyAction>
#ifdef QUOTIENT_07
#include <accountregistry.h>
#else
#include "neochataccountregistry.h"
#endif
#include <connection.h> #include <connection.h>
#include <csapi/pushrules.h> #include <csapi/pushrules.h>
#include <jobs/basejob.h> #include <jobs/basejob.h>
@@ -55,6 +49,10 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
const QString &replyEventId, const QString &replyEventId,
bool canReply) bool canReply)
{ {
if (!NeoChatConfig::self()->showNotifications()) {
return;
}
QPixmap img; QPixmap img;
img.convertFromImage(icon); img.convertFromImage(icon);
KNotification *notification = new KNotification("message"); KNotification *notification = new KNotification("message");
@@ -70,15 +68,8 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
notification->setDefaultAction(i18n("Open NeoChat in this room")); notification->setDefaultAction(i18n("Open NeoChat in this room"));
connect(notification, &KNotification::defaultActivated, this, [=]() { connect(notification, &KNotification::defaultActivated, this, [=]() {
WindowController::instance().showAndRaiseWindow(notification->xdgActivationToken());
if (room->localUser()->id() != Controller::instance().activeConnection()->userId()) {
#ifdef QUOTIENT_07
Controller::instance().setActiveConnection(Accounts.get(room->localUser()->id()));
#else
Controller::instance().setActiveConnection(AccountRegistry::instance().get(room->localUser()->id()));
#endif
}
RoomManager::instance().enterRoom(room); RoomManager::instance().enterRoom(room);
WindowController::instance().showAndRaiseWindow(notification->xdgActivationToken());
}); });
if (canReply) { if (canReply) {
@@ -99,6 +90,9 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
void NotificationsManager::postInviteNotification(NeoChatRoom *room, const QString &title, const QString &sender, const QImage &icon) void NotificationsManager::postInviteNotification(NeoChatRoom *room, const QString &title, const QString &sender, const QImage &icon)
{ {
if (!NeoChatConfig::self()->showNotifications()) {
return;
}
QPixmap img; QPixmap img;
img.convertFromImage(icon); img.convertFromImage(icon);
KNotification *notification = new KNotification("invite"); KNotification *notification = new KNotification("invite");
@@ -108,9 +102,9 @@ void NotificationsManager::postInviteNotification(NeoChatRoom *room, const QStri
notification->setFlags(KNotification::Persistent); notification->setFlags(KNotification::Persistent);
notification->setDefaultAction(i18n("Open this invitation in NeoChat")); notification->setDefaultAction(i18n("Open this invitation in NeoChat"));
connect(notification, &KNotification::defaultActivated, this, [=]() { connect(notification, &KNotification::defaultActivated, this, [=]() {
WindowController::instance().showAndRaiseWindow(notification->xdgActivationToken());
notification->close(); notification->close();
RoomManager::instance().enterRoom(room); RoomManager::instance().enterRoom(room);
WindowController::instance().showAndRaiseWindow(notification->xdgActivationToken());
}); });
notification->setActions({i18n("Accept Invitation"), i18n("Reject Invitation")}); notification->setActions({i18n("Accept Invitation"), i18n("Reject Invitation")});
connect(notification, &KNotification::action1Activated, this, [room, notification]() { connect(notification, &KNotification::action1Activated, this, [room, notification]() {
@@ -268,10 +262,6 @@ void NotificationsManager::updateNotificationRules(const QString &type)
return; return;
} }
if (!Controller::instance().activeConnection()) {
return;
}
const QJsonObject accountData = Controller::instance().activeConnection()->accountDataJson("m.push_rules"); const QJsonObject accountData = Controller::instance().activeConnection()->accountDataJson("m.push_rules");
// Update override rules // Update override rules
@@ -281,9 +271,7 @@ void NotificationsManager::updateNotificationRules(const QString &type)
if (overrideRule["rule_id"] == ".m.rule.master") { if (overrideRule["rule_id"] == ".m.rule.master") {
bool ruleEnabled = overrideRule["enabled"].toBool(); bool ruleEnabled = overrideRule["enabled"].toBool();
m_globalNotificationsEnabled = !ruleEnabled; m_globalNotificationsEnabled = !ruleEnabled;
if (!m_globalNotificationsSet) { NeoChatConfig::self()->setShowNotifications(m_globalNotificationsEnabled);
m_globalNotificationsSet = true;
}
Q_EMIT globalNotificationsEnabledChanged(m_globalNotificationsEnabled); Q_EMIT globalNotificationsEnabledChanged(m_globalNotificationsEnabled);
} }

View File

@@ -33,7 +33,6 @@ class NotificationsManager : public QObject
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(bool globalNotificationsEnabled MEMBER m_globalNotificationsEnabled WRITE setGlobalNotificationsEnabled NOTIFY globalNotificationsEnabledChanged) Q_PROPERTY(bool globalNotificationsEnabled MEMBER m_globalNotificationsEnabled WRITE setGlobalNotificationsEnabled NOTIFY globalNotificationsEnabledChanged)
Q_PROPERTY(bool globalNotificationsSet MEMBER m_globalNotificationsSet NOTIFY globalNotificationsSetChanged)
Q_PROPERTY(PushNotificationAction::Action oneToOneNotificationAction MEMBER m_oneToOneNotificationAction WRITE setOneToOneNotificationAction NOTIFY Q_PROPERTY(PushNotificationAction::Action oneToOneNotificationAction MEMBER m_oneToOneNotificationAction WRITE setOneToOneNotificationAction NOTIFY
oneToOneNotificationActionChanged) oneToOneNotificationActionChanged)
Q_PROPERTY(PushNotificationAction::Action encryptedOneToOneNotificationAction MEMBER m_encryptedOneToOneNotificationAction WRITE Q_PROPERTY(PushNotificationAction::Action encryptedOneToOneNotificationAction MEMBER m_encryptedOneToOneNotificationAction WRITE
@@ -74,8 +73,7 @@ private:
QMultiMap<QString, KNotification *> m_notifications; QMultiMap<QString, KNotification *> m_notifications;
QHash<QString, QPointer<KNotification>> m_invitations; QHash<QString, QPointer<KNotification>> m_invitations;
bool m_globalNotificationsEnabled = false; bool m_globalNotificationsEnabled;
bool m_globalNotificationsSet = false;
PushNotificationAction::Action m_oneToOneNotificationAction = PushNotificationAction::Unknown; PushNotificationAction::Action m_oneToOneNotificationAction = PushNotificationAction::Unknown;
PushNotificationAction::Action m_encryptedOneToOneNotificationAction = PushNotificationAction::Unknown; PushNotificationAction::Action m_encryptedOneToOneNotificationAction = PushNotificationAction::Unknown;
PushNotificationAction::Action m_groupChatNotificationAction = PushNotificationAction::Unknown; PushNotificationAction::Action m_groupChatNotificationAction = PushNotificationAction::Unknown;
@@ -109,7 +107,6 @@ private Q_SLOTS:
Q_SIGNALS: Q_SIGNALS:
void globalNotificationsEnabledChanged(bool newState); void globalNotificationsEnabledChanged(bool newState);
void globalNotificationsSetChanged(bool newState);
void oneToOneNotificationActionChanged(PushNotificationAction::Action action); void oneToOneNotificationActionChanged(PushNotificationAction::Action action);
void encryptedOneToOneNotificationActionChanged(PushNotificationAction::Action action); void encryptedOneToOneNotificationActionChanged(PushNotificationAction::Action action);
void groupChatNotificationActionChanged(PushNotificationAction::Action action); void groupChatNotificationActionChanged(PushNotificationAction::Action action);

View File

@@ -60,7 +60,7 @@ Comment[nl]=Rooms zoeken in NeoChat
Comment[pl]=Znajdź pokoje w NeoChat Comment[pl]=Znajdź pokoje w NeoChat
Comment[pt]=Procurar salas no NeoChat Comment[pt]=Procurar salas no NeoChat
Comment[pt_BR]=Encontrar salas no NeoChat Comment[pt_BR]=Encontrar salas no NeoChat
Comment[ru]=Поиск комнат NeoChat Comment[ru]=Поиск комнаты NeoChat
Comment[sl]=Najdi sobe v NeoChatu Comment[sl]=Najdi sobe v NeoChatu
Comment[sv]=Sök efter rum i NeoChat Comment[sv]=Sök efter rum i NeoChat
Comment[ta]=நியோச்சாட்டில் அரங்குகளை கண்டுபிடிக்கும் Comment[ta]=நியோச்சாட்டில் அரங்குகளை கண்டுபிடிக்கும்

View File

@@ -10,115 +10,188 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
ColumnLayout { Loader {
id: root id: attachmentPaneLoader
signal attachmentCancelled() readonly property var attachmentMimetype: FileType.mimeTypeForUrl(attachmentPaneLoader.attachmentPath)
property string attachmentPath
readonly property var attachmentMimetype: FileType.mimeTypeForUrl(attachmentPath)
readonly property bool hasImage: attachmentMimetype.valid && FileType.supportedImageFormats.includes(attachmentMimetype.preferredSuffix) readonly property bool hasImage: attachmentMimetype.valid && FileType.supportedImageFormats.includes(attachmentMimetype.preferredSuffix)
readonly property string attachmentPath: currentRoom.chatBoxAttachmentPath
readonly property string baseFileName: attachmentPath.substring(attachmentPath.lastIndexOf('/') + 1, attachmentPath.length) readonly property string baseFileName: attachmentPath.substring(attachmentPath.lastIndexOf('/') + 1, attachmentPath.length)
RowLayout { active: visible
spacing: Kirigami.Units.smallSpacing sourceComponent: Component {
QQC2.Pane {
id: attachmentPane
Kirigami.Theme.colorSet: Kirigami.Theme.View
QQC2.Label { contentItem: Item {
Layout.fillWidth: true property real spacing: attachmentPane.spacing > 0 ? attachmentPane.spacing : toolBar.spacing
Layout.alignment: Qt.AlignLeft implicitWidth: Math.max(image.implicitWidth, imageBusyIndicator.implicitWidth, fileInfoLayout.implicitWidth, toolBar.implicitWidth)
text: i18n("Attachment:") implicitHeight: Math.max(
horizontalAlignment: Text.AlignLeft (hasImage ? Math.max(image.preferredHeight, imageBusyIndicator.implicitHeight) + spacing : 0)
verticalAlignment: Text.AlignVCenter + fileInfoLayout.implicitHeight,
} toolBar.implicitHeight
QQC2.ToolButton { )
id: editImageButton
visible: hasImage
icon.name: "document-edit"
text: i18n("Edit")
display: QQC2.AbstractButton.IconOnly
Component { Image {
id: imageEditorPage id: image
ImageEditorPage { property real preferredHeight: Math.min(implicitHeight, Kirigami.Units.gridUnit * 8)
imagePath: root.attachmentPath height: preferredHeight
anchors {
horizontalCenter: parent.horizontalCenter
bottom: fileInfoLayout.top
bottomMargin: parent.spacing
}
width: Math.min(implicitWidth, attachmentPane.availableWidth)
asynchronous: true
cache: false // Cache is not needed. Images will rarely be shown repeatedly.
smooth: height === preferredHeight && parent.height === parent.implicitHeight // Don't smooth until height animation stops
source: hasImage ? attachmentPaneLoader.attachmentPath : ""
visible: hasImage
fillMode: Image.PreserveAspectFit
onSourceChanged: {
// Reset source size height, which affect implicitHeight
sourceSize.height = -1
}
onSourceSizeChanged: {
if (implicitHeight > Kirigami.Units.gridUnit * 8) {
// This can save a lot of RAM when loading large images.
// It also improves visual quality for large images.
sourceSize.height = Kirigami.Units.gridUnit * 8
}
}
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
QQC2.BusyIndicator {
id: imageBusyIndicator
anchors {
horizontalCenter: parent.horizontalCenter
top: parent.top
bottom: fileInfoLayout.top
bottomMargin: parent.spacing
}
visible: running
running: image.visible && image.progress < 1
}
RowLayout {
id: fileInfoLayout
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: undefined
anchors.bottom: parent.bottom
spacing: parent.spacing
Kirigami.Icon {
id: mimetypeIcon
implicitHeight: Kirigami.Units.fontMetrics.roundedIconSize(fileLabel.implicitHeight)
implicitWidth: implicitHeight
source: attachmentMimetype.iconName
}
QQC2.Label {
id: fileLabel
text: baseFileName
}
states: State {
when: !hasImage
AnchorChanges {
target: fileInfoLayout
anchors.bottom: undefined
anchors.verticalCenter: parent.verticalCenter
}
}
}
// Using a toolbar to get a button spacing consistent with what the QQC2 style normally has
// Also has some accessibility info
QQC2.ToolBar {
id: toolBar
width: parent.width
anchors.top: parent.top
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
Kirigami.Theme.inherit: true
Kirigami.Theme.colorSet: Kirigami.Theme.View
contentItem: RowLayout {
spacing: parent.spacing
QQC2.Label {
Layout.leftMargin: -attachmentPane.leftPadding
Layout.topMargin: -attachmentPane.topPadding
leftPadding: cancelAttachmentButton.leftPadding + 1 + attachmentPane.leftPadding
rightPadding: cancelAttachmentButton.rightPadding + 1
topPadding: cancelAttachmentButton.topPadding + attachmentPane.topPadding
bottomPadding: cancelAttachmentButton.bottomPadding
text: i18n("Attachment:")
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
background: Kirigami.ShadowedRectangle {
property real cornerRadius: cancelAttachmentButton.background.hasOwnProperty("radius") ?
Math.min(cancelAttachmentButton.background.radius, height/2) : 0
corners.bottomLeftRadius: toolBar.mirrored ? cornerRadius : 0
corners.bottomRightRadius: toolBar.mirrored ? 0 : cornerRadius
color: Kirigami.Theme.backgroundColor
opacity: 0.75
}
}
Item {
Layout.fillWidth: true
}
QQC2.ToolButton {
id: editImageButton
visible: hasImage
icon.name: "document-edit"
text: i18n("Edit")
display: QQC2.AbstractButton.IconOnly
Component {
id: imageEditorPage
ImageEditorPage {
imagePath: attachmentPaneLoader.attachmentPath
}
}
onClicked: {
let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage);
imageEditor.newPathChanged.connect(function(newPath) {
applicationWindow().pageStack.layers.pop();
attachmentPaneLoader.attachmentPath = newPath;
});
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
QQC2.ToolButton {
id: cancelAttachmentButton
icon.name: "dialog-close"
text: i18n("Cancel sending Image")
display: QQC2.AbstractButton.IconOnly
onClicked: currentRoom.chatBoxAttachmentPath = "";
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
}
background: null
} }
} }
onClicked: { background: Rectangle {
let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage); color: Kirigami.Theme.backgroundColor
imageEditor.newPathChanged.connect(function(newPath) {
applicationWindow().pageStack.layers.pop();
root.attachmentPath = newPath;
});
} }
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
QQC2.ToolButton {
id: cancelAttachmentButton
display: QQC2.AbstractButton.IconOnly
action: Kirigami.Action {
text: i18n("Cancel sending attachment")
icon.name: "dialog-close"
onTriggered: attachmentCancelled();
shortcut: "Escape"
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
}
Image {
id: image
Layout.alignment: Qt.AlignHCenter
asynchronous: true
cache: false // Cache is not needed. Images will rarely be shown repeatedly.
source: hasImage ? root.attachmentPath : ""
visible: hasImage
fillMode: Image.PreserveAspectFit
onSourceChanged: {
// Reset source size height, which affect implicitHeight
sourceSize.height = -1
}
onSourceSizeChanged: {
if (implicitHeight > Kirigami.Units.gridUnit * 8) {
// This can save a lot of RAM when loading large images.
// It also improves visual quality for large images.
sourceSize.height = Kirigami.Units.gridUnit * 8
}
}
Behavior on height {
NumberAnimation {
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
QQC2.BusyIndicator {
id: imageBusyIndicator
visible: running
running: image.visible && image.progress < 1
}
RowLayout {
id: fileInfoLayout
Layout.alignment: Qt.AlignHCenter
spacing: parent.spacing
Kirigami.Icon {
id: mimetypeIcon
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: attachmentMimetype.iconName
}
QQC2.Label {
id: fileLabel
text: baseFileName
} }
} }
} }

View File

@@ -5,60 +5,208 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15 as QQC2 import QtQuick.Controls 2.15 as QQC2
import QtQuick.Window 2.15
import org.kde.kirigami 2.18 as Kirigami import org.kde.kirigami 2.18 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
QQC2.Control { QQC2.ToolBar {
id: root id: chatBar
property alias inputFieldText: inputField.text
property alias textField: textField property alias textField: inputField
property bool isReplying: currentRoom.chatBoxReplyId.length > 0 property alias cursorPosition: inputField.cursorPosition
property NeoChatUser replyUser: currentRoom.chatBoxReplyUser
property bool attachmentPaneVisible: currentRoom.chatBoxAttachmentPath.length > 0
signal inputFieldForceActiveFocusTriggered()
signal messageSent() signal messageSent()
property list<Kirigami.Action> actions : [ onInputFieldForceActiveFocusTriggered: {
Kirigami.Action { inputField.forceActiveFocus();
id: attachmentAction // set the cursor to the end of the text
inputField.cursorPosition = inputField.length;
}
property bool isBusy: currentRoom && currentRoom.hasFileUploading position: QQC2.ToolBar.Footer
// Matrix does not allow sending attachments in replies Kirigami.Theme.colorSet: Kirigami.Theme.View
visible: currentRoom.chatBoxReplyId.length === 0 && currentRoom.chatBoxAttachmentPath.length === 0
icon.name: "mail-attachment"
text: i18n("Attach an image or file")
displayHint: Kirigami.DisplayHint.IconOnly
onTriggered: { // Using a custom background because some styles like Material
if (Clipboard.hasImage) { // or Fusion might have ugly colors for a TextArea placed inside
attachDialog.open() // of a toolbar. ToolBar is otherwise the closest QQC2 component
} else { // to what we want because of the padding and spacing values.
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay) background: Rectangle {
fileDialog.chosen.connect((path) => { color: Kirigami.Theme.backgroundColor
if (!path) { }
return;
} contentItem: RowLayout {
currentRoom.chatBoxAttachmentPath = path; spacing: chatBar.spacing
})
fileDialog.open() QQC2.ScrollView {
} Layout.fillHeight: true
Layout.fillWidth: true
Layout.minimumHeight: inputField.implicitHeight
// lineSpacing is height+leading, so subtract leading once since leading only exists between lines.
Layout.maximumHeight: fontMetrics.lineSpacing * 8 - fontMetrics.leading
+ inputField.topPadding + inputField.bottomPadding
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
FontMetrics {
id: fontMetrics
font: inputField.font
} }
tooltip: text QQC2.TextArea {
}, id: inputField
Kirigami.Action { focus: true
id: emojiAction /* Some QQC2 styles will have their own predefined backgrounds for TextAreas.
* Make sure there is no background since we are using the ToolBar background.
*
* This could cause a problem if the QQC2 style was designed around TextArea
* background colors being very different from the QPalette::Base color.
* Luckily, none of the Qt QQC2 styles do that and neither do KDE's QQC2 styles.
*/
background: MouseArea {
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
z: 1
}
property bool isBusy: false leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing
rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing
topPadding: 0
bottomPadding: 0
placeholderText: readOnly ? i18n("This room is encrypted. Sending encrypted messages is not yet supported.") : currentRoom.chatBoxEditId.length > 0 ? i18n("Edit Message") : currentRoom.usesEncryption ? i18n("Send an encrypted message…") : i18n("Send a message…")
verticalAlignment: TextEdit.AlignVCenter
horizontalAlignment: TextEdit.AlignLeft
wrapMode: Text.Wrap
readOnly: currentRoom.usesEncryption && !Controller.encryptionSupported
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
color: Kirigami.Theme.textColor
selectionColor: Kirigami.Theme.highlightColor
selectedTextColor: Kirigami.Theme.highlightedTextColor
hoverEnabled: !Kirigami.Settings.tabletMode
selectByMouse: !Kirigami.Settings.tabletMode
Keys.onEnterPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
inputField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
}
Keys.onReturnPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
inputField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
}
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete()
}
}
Keys.onPressed: {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
chatBar.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
let replyEvent = messageEventModel.getLatestMessageFromIndex(0)
if (replyEvent && replyEvent["event_id"]) {
currentRoom.chatBoxReplyId = replyEvent["event_id"]
}
} else if (event.key === Qt.Key_Up && inputField.text.length === 0) {
let editEvent = messageEventModel.getLastLocalUserMessageEventId()
if (editEvent) {
if(editEvent["m.relates_to"]) {
currentRoom.chatBoxReplyId = editEvent["m.relates_to"]["m.in_reply_to"]["event_id"];
}
currentRoom.chatBoxEditId = editEvent["event_id"]
}
} else if (event.key === Qt.Key_Up && completionMenu.visible) {
completionMenu.decrementIndex()
} else if (event.key === Qt.Key_Down && completionMenu.visible) {
completionMenu.incrementIndex()
} else if (event.key === Qt.Key_Backspace && inputField.text.length <= 1) {
currentRoom.sendTypingNotification(false)
repeatTimer.stop()
}
}
Timer {
id: repeatTimer
interval: 5000
}
onTextChanged: {
if (!repeatTimer.running && Config.typingNotifications) {
var textExists = text.length > 0
currentRoom.sendTypingNotification(textExists)
textExists ? repeatTimer.start() : repeatTimer.stop()
}
currentRoom.chatBoxText = text
}
}
}
Item {
visible: currentRoom.chatBoxReplyId.length === 0 && (currentRoom.chatBoxAttachmentPath.length === 0 || uploadingBusySpinner.running)
implicitWidth: uploadButton.implicitWidth
implicitHeight: uploadButton.implicitHeight
QQC2.ToolButton {
id: uploadButton
anchors.fill: parent
// Matrix does not allow sending attachments in replies
visible: currentRoom.chatBoxReplyId.length === 0 && currentRoom.chatBoxAttachmentPath.length === 0 && !uploadingBusySpinner.running
icon.name: "mail-attachment"
text: i18n("Attach an image or file")
display: QQC2.AbstractButton.IconOnly
onClicked: {
if (Clipboard.hasImage) {
attachDialog.open()
} else {
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay)
fileDialog.chosen.connect((path) => {
if (!path) {
return;
}
currentRoom.chatBoxAttachmentPath = path;
})
fileDialog.open()
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
QQC2.BusyIndicator {
id: uploadingBusySpinner
anchors.fill: parent
visible: running
running: currentRoom && currentRoom.hasFileUploading
}
}
QQC2.ToolButton {
id: emojiButton
icon.name: "smiley" icon.name: "smiley"
text: i18n("Add an Emoji") text: i18n("Add an Emoji")
displayHint: Kirigami.DisplayHint.IconOnly display: QQC2.AbstractButton.IconOnly
checkable: true checkable: true
onTriggered: { onClicked: {
if (emojiDialog.visible) { if (emojiDialog.visible) {
emojiDialog.close() emojiDialog.close()
} else { } else {
@@ -66,264 +214,29 @@ QQC2.Control {
} }
} }
tooltip: text QQC2.ToolTip.text: text
}, QQC2.ToolTip.visible: hovered
Kirigami.Action { }
id: sendAction
property bool isBusy: false
QQC2.ToolButton {
id: sendButton
icon.name: "document-send" icon.name: "document-send"
text: i18n("Send message") text: i18n("Send message")
displayHint: Kirigami.DisplayHint.IconOnly display: QQC2.AbstractButton.IconOnly
checkable: true
onTriggered: { onClicked: {
root.postMessage() chatBar.postMessage()
} }
tooltip: text QQC2.ToolTip.text: text
} QQC2.ToolTip.visible: hovered
]
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
contentItem: QQC2.ScrollView {
id: chatBarScrollView
property var textFieldHeight: textField.height
property var visualLeftPadding: (root.width - chatBoxMaxWidth) / 2 - (root.width > chatBoxMaxWidth ? Kirigami.Units.largeSpacing : 0)
property var visualRightPadding: (root.width - chatBoxMaxWidth) / 2 + (root.width > chatBoxMaxWidth ? Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing : 0)
leftPadding: LayoutMirroring.enabled ? visualRightPadding : visualLeftPadding
rightPadding: LayoutMirroring.enabled ? visualLeftPadding : visualRightPadding
// HACK: This is to stop the ScrollBar flickering on and off as the height is increased
QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded
Behavior on implicitHeight {
NumberAnimation {
id: chatBarHeightAnimation
duration: Kirigami.Units.shortDuration
easing.type: Easing.InOutCubic
}
}
QQC2.TextArea{
id: textField
topPadding: Kirigami.Units.largeSpacing + (paneLoader.visible ? paneLoader.height : 0)
bottomPadding: Kirigami.Units.largeSpacing
leftPadding: LayoutMirroring.enabled ? actionsRow.width : (root.width > chatBoxMaxWidth ? 0 : Kirigami.Units.largeSpacing)
rightPadding: LayoutMirroring.enabled ? (root.width > chatBoxMaxWidth ? 0 : Kirigami.Units.largeSpacing) : actionsRow.width
placeholderText: readOnly ? i18n("This room is encrypted. Build libQuotient with encryption enabled to send encrypted messages.") : currentRoom.usesEncryption ? i18n("Send an encrypted message…") : currentRoom.chatBoxAttachmentPath.length > 0 ? i18n("Set an attachment caption...") : i18n("Send a message…")
verticalAlignment: TextEdit.AlignVCenter
wrapMode: Text.Wrap
readOnly: (currentRoom.usesEncryption && !Controller.encryptionSupported)
Timer {
id: repeatTimer
interval: 5000
}
onTextChanged: {
if (!repeatTimer.running && Config.typingNotifications) {
var textExists = text.length > 0
currentRoom.sendTypingNotification(textExists)
textExists ? repeatTimer.start() : repeatTimer.stop()
}
currentRoom.chatBoxText = text
}
onCursorRectangleChanged: chatBarScrollView.ensureVisible(cursorRectangle)
Keys.onEnterPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
textField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
}
Keys.onReturnPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
textField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
}
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete()
}
}
Keys.onPressed: {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
chatBar.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
let replyEvent = messageEventModel.getLatestMessageFromIndex(0)
if (replyEvent && replyEvent["event_id"]) {
currentRoom.chatBoxReplyId = replyEvent["event_id"]
}
} else if (event.key === Qt.Key_Up && textField.text.length === 0) {
let editEvent = messageEventModel.getLastLocalUserMessageEventId()
if (editEvent) {
currentRoom.chatBoxEditId = editEvent["event_id"]
}
} else if (event.key === Qt.Key_Up && completionMenu.visible) {
completionMenu.decrementIndex()
} else if (event.key === Qt.Key_Down && completionMenu.visible) {
completionMenu.incrementIndex()
} else if (event.key === Qt.Key_Backspace && textField.text.length <= 1) {
currentRoom.sendTypingNotification(false)
repeatTimer.stop()
}
}
Loader {
id: paneLoader
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: root.width > chatBoxMaxWidth ? 0 : Kirigami.Units.largeSpacing
anchors.right: parent.right
anchors.rightMargin: root.width > chatBoxMaxWidth ? 0 : (chatBarScrollView.QQC2.ScrollBar.vertical.visible ? Kirigami.Units.largeSpacing * 3.5 : Kirigami.Units.largeSpacing)
active: visible
visible: root.isReplying || root.attachmentPaneVisible
sourceComponent: root.isReplying ? replyPane : attachmentPane
}
Component {
id: replyPane
ReplyPane {
userName: root.replyUser ? root.replyUser.displayName : ""
userColor: root.replyUser ? root.replyUser.color : ""
userAvatar: root.replyUser ? "image://mxc/" + currentRoom.getUser(root.replyUser.id).avatarMediaId : ""
text: currentRoom.chatBoxReplyMessage
}
}
Component {
id: attachmentPane
AttachmentPane {
attachmentPath: currentRoom.chatBoxAttachmentPath
onAttachmentCancelled: {
currentRoom.chatBoxAttachmentPath = "";
root.forceActiveFocus()
}
}
}
background: MouseArea {
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
z: 1
}
}
/**
* Because of the paneLoader we have to manage the scroll
* position manually or it doesn't keep the cursor visible properly all the time.
*/
function ensureVisible(r) {
// Find the child that is the Flickable created by ScrollView.
let flickable = undefined;
for (var index in children) {
if (children[index] instanceof Flickable) {
flickable = children[index];
}
}
if (flickable) {
if (flickable.contentX >= r.x) {
flickable.contentX = r.x;
} else if (flickable.contentX + width <= r.x + r.width) {
flickable.contentX = r.x + r.width - width;
} if (flickable.contentY >= r.y) {
flickable.contentY = r.y;
} else if (flickable.contentY + height <= r.y + r.height) {
flickable.contentY = r.y + r.height - height + textField.bottomPadding;
}
}
}
}
QQC2.ToolButton {
id: cancelButton
anchors.top: parent.top
anchors.right: parent.right
anchors.rightMargin: (root.width - chatBoxMaxWidth) / 2 + Kirigami.Units.largeSpacing + (chatBarScrollView.QQC2.ScrollBar.vertical.visible && !(root.width > chatBoxMaxWidth) ? Kirigami.Units.largeSpacing * 2.5 : 0)
visible: root.isReplying
display: QQC2.AbstractButton.IconOnly
action: Kirigami.Action {
text: i18nc("@action:button", "Cancel reply")
icon.name: "dialog-close"
onTriggered: {
currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxAttachmentPath = "";
root.forceActiveFocus()
}
shortcut: "Escape"
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
Row {
id: actionsRow
padding: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
anchors.right: parent.right
property var requiredMargin: (root.width - chatBoxMaxWidth) / 2 + Kirigami.Units.largeSpacing + (chatBarScrollView.QQC2.ScrollBar.vertical.visible && !(root.width > chatBoxMaxWidth) ? Kirigami.Units.largeSpacing * 2.5 : 0)
anchors.leftMargin: layoutDirection === Qt.RightToLeft ? requiredMargin : 0
anchors.rightMargin: layoutDirection === Qt.RightToLeft ? 0 : requiredMargin
anchors.bottom: parent.bottom
anchors.bottomMargin: Kirigami.Units.largeSpacing - 2
Repeater {
model: root.actions
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source)
active: actionArea.containsPress
visible: modelData.visible
enabled: modelData.enabled
MouseArea {
id: actionArea
anchors.fill: parent
onClicked: modelData.trigger()
cursorShape: Qt.PointingHandCursor
}
QQC2.ToolTip.visible: modelData.tooltip !== "" && hoverHandler.hovered
QQC2.ToolTip.text: modelData.tooltip
HoverHandler { id: hoverHandler }
QQC2.BusyIndicator {
anchors.fill: parent
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
visible: running
running: modelData.isBusy
}
}
} }
} }
EmojiDialog { EmojiDialog {
id: emojiDialog id: emojiDialog
x: parent.width - implicitWidth x: parent.width - implicitWidth
y: -implicitHeight // - Kirigami.Units.smallSpacing y: -implicitHeight - Kirigami.Units.smallSpacing
modal: false modal: false
includeCustom: true includeCustom: true
@@ -333,10 +246,6 @@ QQC2.Control {
onClosed: if (emojiButton.checked) emojiButton.checked = false onClosed: if (emojiButton.checked) emojiButton.checked = false
} }
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
CompletionMenu { CompletionMenu {
id: completionMenu id: completionMenu
height: implicitHeight height: implicitHeight
@@ -352,29 +261,26 @@ QQC2.Control {
} }
} }
Connections {
target: currentRoom
function onChatBoxEditIdChanged() {
if (currentRoom.chatBoxEditMessage.length > 0) {
chatBar.inputFieldText = currentRoom.chatBoxEditMessage
}
}
}
ChatDocumentHandler { ChatDocumentHandler {
id: documentHandler id: documentHandler
document: textField.textDocument document: inputField.textDocument
cursorPosition: textField.cursorPosition cursorPosition: inputField.cursorPosition
selectionStart: textField.selectionStart selectionStart: inputField.selectionStart
selectionEnd: textField.selectionEnd selectionEnd: inputField.selectionEnd
Component.onCompleted: { Component.onCompleted: {
RoomManager.chatDocumentHandler = documentHandler; RoomManager.chatDocumentHandler = documentHandler;
} }
} }
function forceActiveFocus() {
textField.forceActiveFocus();
// set the cursor to the end of the text
textField.cursorPosition = textField.length;
}
function insertText(text) {
let initialCursorPosition = textField.cursorPosition;
textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition)
textField.cursorPosition = initialCursorPosition + text.length
}
function pasteImage() { function pasteImage() {
let localPath = Clipboard.saveImage(); let localPath = Clipboard.saveImage();
@@ -385,11 +291,12 @@ QQC2.Control {
} }
function postMessage() { function postMessage() {
actionsHandler.handleNewMessage(); actionsHandler.handleMessage();
repeatTimer.stop() repeatTimer.stop()
currentRoom.markAllMessagesAsRead(); currentRoom.markAllMessagesAsRead();
textField.clear(); inputField.clear();
currentRoom.chatBoxReplyId = ""; currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxEditId = "";
messageSent() messageSent()
} }
} }

View File

@@ -5,52 +5,108 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2 import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
ColumnLayout { ColumnLayout {
id: chatBox id: chatBox
property alias inputFieldText: chatBar.inputFieldText
signal messageSent() signal messageSent()
property alias chatBar: chatBar
readonly property int extraWidth: width >= Kirigami.Units.gridUnit * 47 ? Math.min((width - Kirigami.Units.gridUnit * 47), Kirigami.Units.gridUnit * 20) : 0
readonly property int chatBoxMaxWidth: Config.compactLayout ? width : Math.min(width, Kirigami.Units.gridUnit * 39 + extraWidth)
spacing: 0 spacing: 0
Kirigami.InlineMessage { Kirigami.Separator {
id: connectionPaneSeparator
visible: connectionPane.visible
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 1 // So we can see the border }
Layout.rightMargin: 1 // So we can see the border
text: i18n("NeoChat is offline. Please check your network connection.") QQC2.Pane {
id: connectionPane
padding: fontMetrics.lineSpacing * 0.25
FontMetrics {
id: fontMetrics
font: networkLabel.font
}
spacing: 0
Kirigami.Theme.colorSet: Kirigami.Theme.View
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
visible: !Controller.isOnline visible: !Controller.isOnline
Layout.fillWidth: true
QQC2.Label {
id: networkLabel
width: parent.width
wrapMode: Text.Wrap
text: i18n("NeoChat is offline. Please check your network connection.")
}
} }
Kirigami.Separator { Kirigami.Separator {
id: replySeparator
visible: replyPane.visible
Layout.fillWidth: true
}
ReplyPane {
id: replyPane
visible: currentRoom.chatBoxReplyId.length > 0 || currentRoom.chatBoxEditId.length > 0
Layout.fillWidth: true
onReplyCancelled: {
chatBox.focusInputField()
}
}
Kirigami.Separator {
id: attachmentSeparator
visible: attachmentPane.visible
Layout.fillWidth: true
}
AttachmentPane {
id: attachmentPane
visible: currentRoom.chatBoxAttachmentPath.length > 0
Layout.fillWidth: true
}
Kirigami.Separator {
id: chatBarSeparator
visible: chatBar.visible
Layout.fillWidth: true Layout.fillWidth: true
} }
ChatBar { ChatBar {
id: chatBar id: chatBar
visible: currentRoom.canSendEvent("m.room.message") visible: currentRoom.canSendEvent("m.room.message")
Layout.fillWidth: true Layout.fillWidth: true
Layout.minimumHeight: implicitHeight + Kirigami.Units.largeSpacing
// lineSpacing is height+leading, so subtract leading once since leading only exists between lines.
Layout.maximumHeight: chatBarFontMetrics.lineSpacing * 8 - chatBarFontMetrics.leading + textField.topPadding + textField.bottomPadding
FontMetrics {
id: chatBarFontMetrics
font: chatBar.textField.font
}
onMessageSent: { onMessageSent: {
chatBox.messageSent(); chatBox.messageSent();
} }
Behavior on implicitHeight {
NumberAnimation {
property: "implicitHeight"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
function insertText(str) {
let index = chatBar.cursorPosition;
chatBox.inputFieldText = inputFieldText.substr(0, chatBar.cursorPosition) + str + inputFieldText.substr(chatBar.cursorPosition);
chatBar.cursorPosition = index + str.length;
}
function focusInputField() {
chatBar.inputFieldForceActiveFocusTriggered()
} }
} }

View File

@@ -22,7 +22,7 @@ QQC2.Popup {
connection: Controller.activeConnection connection: Controller.activeConnection
} }
property var chatDocumentHandler required property var chatDocumentHandler
Component.onCompleted: { Component.onCompleted: {
chatDocumentHandler.completionModel.roomListModel = roomListModel; chatDocumentHandler.completionModel.roomListModel = roomListModel;
} }

View File

@@ -10,82 +10,108 @@ import org.kde.kirigami 2.14 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
GridLayout { Loader {
id: root id: replyPane
property string userName property NeoChatUser user: currentRoom.chatBoxReplyUser ?? currentRoom.chatBoxEditUser
property color userColor: Kirigami.Theme.highlightColor
property var userAvatar: ""
property var text
rows: 3 signal replyCancelled()
columns: 3
rowSpacing: Kirigami.Units.smallSpacing
columnSpacing: Kirigami.Units.largeSpacing
QQC2.Label { active: visible
id: replyLabel sourceComponent: QQC2.Pane {
Layout.fillWidth: true id: replyPane
Layout.alignment: Qt.AlignLeft
Layout.columnSpan: 3
topPadding: Kirigami.Units.smallSpacing
text: i18n("Replying to:") Kirigami.Theme.colorSet: Kirigami.Theme.View
}
Rectangle {
id: verticalBorder
Layout.fillHeight: true spacing: leftPadding
Layout.rowSpan: 2
implicitWidth: Kirigami.Units.smallSpacing contentItem: RowLayout {
color: userColor Layout.fillWidth: true
} spacing: replyPane.spacing
Kirigami.Avatar {
id: replyAvatar
implicitWidth: Kirigami.Units.iconSizes.small FontMetrics {
implicitHeight: Kirigami.Units.iconSizes.small id: fontMetrics
font: textArea.font
}
source: userAvatar Kirigami.Avatar {
name: userName id: avatar
color: userColor Layout.alignment: textContentLayout.height > avatar.height ? Qt.AlignHCenter | Qt.AlignTop : Qt.AlignCenter
} Layout.preferredWidth: Layout.preferredHeight
QQC2.Label { Layout.preferredHeight: fontMetrics.lineSpacing * 2 - fontMetrics.leading
Layout.fillWidth: true source: user ? "image://mxc/" + currentRoom.getUser(user.id).avatarMediaId : ""
Layout.alignment: Qt.AlignLeft name: user ? user.displayName : ""
color: user ? user.color : "transparent"
visible: Boolean(user)
}
color: userColor ColumnLayout {
text: userName id: textContentLayout
elide: Text.ElideRight Layout.alignment: Qt.AlignCenter
} Layout.fillWidth: true
QQC2.TextArea { spacing: fontMetrics.leading
id: textArea QQC2.Label {
Layout.fillWidth: true
textFormat: Text.StyledText
elide: Text.ElideRight
text: {
let heading = "<b>%1</b>"
let userName = user ? "<font color=\""+ user.color +"\">" + currentRoom.htmlSafeMemberName(user.id) + "</font>" : ""
if (currentRoom.chatBoxEditId.length > 0) {
heading = heading.arg(i18n("Editing message:")) + "<br/>"
} else {
heading = heading.arg(i18n("Replying to %1:", userName))
}
Layout.fillWidth: true return heading
Layout.columnSpan: 2 }
}
//TODO edit user mentions
QQC2.ScrollView {
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
Layout.maximumHeight: fontMetrics.lineSpacing * 8 - fontMetrics.leading
leftPadding: 0 // HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
rightPadding: 0 QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
topPadding: 0
bottomPadding: 0 QQC2.TextArea {
text: "<style> a{color:" + Kirigami.Theme.linkColor + ";}.user-pill{}</style>" + replyTextMetrics.elidedText id: textArea
selectByMouse: true leftPadding: 0
selectByKeyboard: true rightPadding: 0
readOnly: true topPadding: 0
wrapMode: QQC2.Label.Wrap bottomPadding: 0
textFormat: TextEdit.RichText text: "<style> a{color:" + Kirigami.Theme.linkColor + ";}.user-pill{}</style>" + (currentRoom.chatBoxEditId.length > 0 ? currentRoom.chatBoxEditMessage : currentRoom.chatBoxReplyMessage)
background: Item {} selectByMouse: true
HoverHandler { selectByKeyboard: true
cursorShape: textArea.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor readOnly: true
wrapMode: QQC2.Label.Wrap
textFormat: TextEdit.RichText
background: Item {}
HoverHandler {
cursorShape: textArea.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
}
}
}
}
QQC2.ToolButton {
display: QQC2.AbstractButton.IconOnly
action: Kirigami.Action {
text: i18nc("@action:button", "Cancel reply")
icon.name: "dialog-close"
onTriggered: {
currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxEditId = "";
}
shortcut: "Escape"
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
} }
TextMetrics { background: Rectangle {
id: replyTextMetrics color: Kirigami.Theme.backgroundColor
text: root.text
font: textArea.font
elide: Qt.ElideRight
elideWidth: textArea.width * 2 - Kirigami.Units.smallSpacing * 2
} }
} }
} }

View File

@@ -16,11 +16,4 @@ Kirigami.LoadingPlaceholder {
QQC2.Label { QQC2.Label {
text: i18n("Please wait. This might take a little while.") text: i18n("Please wait. This might take a little while.")
} }
Connections {
target: Controller
function onInitiated() {
closeDialog()
}
}
} }

View File

@@ -27,24 +27,23 @@ TimelineContainer {
readonly property string mediaId: isThumbnail ? content.thumbnailMediaId : content.mediaId readonly property string mediaId: isThumbnail ? content.thumbnailMediaId : content.mediaId
readonly property var maxWidth: Kirigami.Units.gridUnit * 30 readonly property var maxWidth: Kirigami.Units.gridUnit * 30
readonly property var maxHeight: Kirigami.Units.gridUnit * 30
innerObject: AnimatedImage { innerObject: AnimatedImage {
id: img id: img
property var imageWidth: { property var imageWidth: {
if (imageDelegate.info && imageDelegate.info.w && imageDelegate.info.w > 0) { if (imageDelegate.info.w > 0) {
return imageDelegate.info.w; return imageDelegate.info.w;
} else if (sourceSize.width && sourceSize.width > 0) { } else if (sourceSize.width > 0) {
return sourceSize.width; return sourceSize.width;
} else { } else {
return imageDelegate.contentMaxWidth; return imageDelegate.contentMaxWidth;
} }
} }
property var imageHeight: { property var imageHeight: {
if (imageDelegate.info && imageDelegate.info.h && imageDelegate.info.h > 0) { if (imageDelegate.info.h > 0) {
return imageDelegate.info.h; return imageDelegate.info.h;
} else if (sourceSize.height && sourceSize.height > 0) { } else if (sourceSize.height > 0) {
return sourceSize.height; return sourceSize.height;
} else { } else {
// Default to a 16:9 placeholder // Default to a 16:9 placeholder
@@ -52,29 +51,8 @@ TimelineContainer {
} }
} }
readonly property var aspectRatio: imageWidth / imageHeight Layout.maximumWidth: Math.min(imageDelegate.contentMaxWidth, imageDelegate.maxWidth)
/** Layout.maximumHeight: Math.min(imageDelegate.contentMaxWidth / imageWidth * imageHeight, imageDelegate.maxWidth / imageWidth * imageHeight)
* Whether the image should be limited by height or width.
* We need to prevent excessively tall as well as excessively wide media.
*
* @note In the case of a tie the media is width limited.
*/
readonly property bool limitWidth: imageWidth >= imageHeight
readonly property size maxSize: {
if (limitWidth) {
let width = Math.min(imageDelegate.contentMaxWidth, imageDelegate.maxWidth);
let height = width / aspectRatio;
return Qt.size(width, height);
} else {
let height = Math.min(imageDelegate.maxHeight, imageDelegate.contentMaxWidth / aspectRatio);
let width = height * aspectRatio;
return Qt.size(width, height);
}
}
Layout.maximumWidth: maxSize.width
Layout.maximumHeight: maxSize.height
Layout.preferredWidth: imageWidth Layout.preferredWidth: imageWidth
Layout.preferredHeight: imageHeight Layout.preferredHeight: imageHeight
source: model.mediaUrl source: model.mediaUrl
@@ -91,8 +69,6 @@ TimelineContainer {
QQC2.ToolTip.visible: hoverHandler.hovered QQC2.ToolTip.visible: hoverHandler.hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
paused: !applicationWindow().active
HoverHandler { HoverHandler {
id: hoverHandler id: hoverHandler
} }

View File

@@ -1,5 +1,4 @@
// SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com> // SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.15 import QtQuick 2.15
@@ -10,140 +9,58 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
Loader { RowLayout {
id: root id: row
/** property var links: model.display.match(/\bhttps?:\/\/[^\s\<\>\"\']+/g)
* @brief Get a list of hyperlinks in the text. // don't show previews for room links or user mentions or custom emojis
* .filter(link => !(
* User links i.e. anything starting with https://matrix.to are ignored. link.includes("https://matrix.to") || link.includes("/_matrix/media/r0/download/")
*/ ))
property var links: { // remove ending fullstops and commas
let matches = model.display.match(/\bhttps?:\/\/[^\s\<\>\"\']+/g) .map(link => (link.length && [".", ","].includes(link[link.length-1])) ? link.substring(0, link.length-1) : link)
if (matches && matches.length > 0) {
// don't show previews for room links or user mentions or custom emojis
return matches.filter(link => !(
link.includes("https://matrix.to") || link.includes("/_matrix/media/r0/download/")
))
// remove ending fullstops and commas
.map(link => (link.length && [".", ","].includes(link[link.length-1])) ? link.substring(0, link.length-1) : link)
}
return []
}
LinkPreviewer { LinkPreviewer {
id: linkPreviewer id: lp
url: root.links && root.links.length > 0 ? root.links[0] : "" url: links.length > 0 ? links[0] : ""
} }
visible: lp.loaded && lp.title
/** Rectangle {
* @brief Standard height for the link preview. Layout.fillHeight: true
* width: Kirigami.Units.smallSpacing
* When the content of the link preview is larger than this it will be visible: lp.loaded && lp.title
* elided/hidden until maximized. color: Kirigami.Theme.highlightColor
*/
property var defaultHeight : Kirigami.Units.gridUnit * 3 + Kirigami.Units.smallSpacing * 2
active: !currentRoom.usesEncryption && model.display && links && links.length > 0
visible: Config.showLinkPreview && active
sourceComponent: linkPreviewer.loaded ? linkPreviewComponent : loadingComponent
Component {
id: linkPreviewComponent
QQC2.Control {
id: componentRoot
property bool truncated: linkPreviewDescription.truncated || !linkPreviewDescription.visible
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor
}
Image {
visible: linkPreviewer.imageSource
Layout.maximumHeight: root.defaultHeight
Layout.maximumWidth: root.defaultHeight
source: linkPreviewer.imageSource.replace("mxc://", "image://mxc/")
fillMode: Image.PreserveAspectFit
}
ColumnLayout {
id: column
spacing: Kirigami.Units.smallSpacing
Kirigami.Heading {
id: linkPreviewTitle
Layout.fillWidth: true
level: 3
wrapMode: Text.Wrap
textFormat: Text.RichText
text: "<style>
a {
text-decoration: none;
} }
</style> Image {
<a href=\"" + root.links[0] + "\">" + (maximizeButton.checked ? linkPreviewer.title : titleTextMetrics.elidedText).replace("&ndash;", "—") + "</a>" visible: lp.imageSource
onLinkActivated: RoomManager.openResource(link) Layout.maximumHeight: Kirigami.Units.gridUnit * 5
Layout.maximumWidth: Kirigami.Units.gridUnit * 5
TextMetrics { source: lp.imageSource.replace("mxc://", "image://mxc/")
id: titleTextMetrics fillMode: Image.PreserveAspectFit
text: linkPreviewer.title }
font: linkPreviewTitle.font ColumnLayout {
elide: Text.ElideRight id: column
elideWidth: (linkPreviewTitle.width - Kirigami.Units.largeSpacing * 2.5) * 3 spacing: Kirigami.Units.smallSpacing
} Kirigami.Heading {
} Layout.maximumWidth: messageDelegate.bubbleMaxWidth
QQC2.Label { Layout.fillWidth: true
id: linkPreviewDescription level: 4
Layout.fillWidth: true wrapMode: Text.Wrap
Layout.maximumHeight: maximizeButton.checked ? -1 : root.defaultHeight - linkPreviewTitle.height - column.spacing textFormat: Text.RichText
visible: linkPreviewTitle.height + column.spacing <= root.defaultHeight || maximizeButton.checked text: "<style>
text: linkPreviewer.description a {
wrapMode: Text.Wrap text-decoration: none;
elide: Text.ElideRight }
} </style>
} <a href=\"" + links[0] + "\">" + lp.title.replace("&ndash;", "—") + "</a>"
} visible: lp.loaded
onLinkActivated: RoomManager.openResource(link)
QQC2.Button {
id: maximizeButton
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: componentRoot.hovered && (componentRoot.truncated || checked)
checkable: true
icon.name: checked ? "go-up" : "go-down"
QQC2.ToolTip.text: checked ? i18n("Shrink preview") : i18n("Expand preview")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
} }
} QQC2.Label {
text: lp.description
Component { Layout.maximumWidth: messageDelegate.bubbleMaxWidth
id: loadingComponent Layout.fillWidth: true
RowLayout { wrapMode: Text.Wrap
property bool hovered: false visible: lp.loaded && lp.description
property bool truncated: false
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor
}
QQC2.BusyIndicator { }
Kirigami.Heading {
Layout.fillWidth: true
Layout.minimumHeight: root.defaultHeight
level: 2
text: i18n("Loading URL preview")
}
} }
} }
} }

View File

@@ -21,24 +21,16 @@ TimelineContainer {
RichLabel { RichLabel {
id: label id: label
Layout.fillWidth: true Layout.fillWidth: true
visible: currentRoom.chatBoxEditId !== model.eventId
isEmote: messageDelegate.isEmote isEmote: messageDelegate.isEmote
} }
MessageEditComponent { Loader {
id: linkPreviewLoader
Layout.fillWidth: true Layout.fillWidth: true
messageId: model.eventId active: !currentRoom.usesEncryption && model.display && model.display.includes("http")
visible: currentRoom.chatBoxEditId === model.eventId visible: Config.showLinkPreview && active
onVisibleChanged: { sourceComponent: LinkPreviewDelegate {
if (visible) { anchors.verticalCenter: parent.verticalCenter
editChatDocumentHandler.document = textDocument
editChatDocumentHandler.cursorPosition = cursorPosition
editChatDocumentHandler.selectionStart = selectionStart
editChatDocumentHandler.selectionEnd = selectionEnd
}
} }
} }
LinkPreviewDelegate {
Layout.fillWidth: true
}
} }
} }

View File

@@ -1,139 +0,0 @@
// 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 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
QQC2.TextArea {
id: root
property string messageId
Layout.fillWidth: true
Layout.minimumHeight: editButtons.height + topPadding + bottomPadding
Layout.preferredWidth: editTextMetrics.advanceWidth + rightPadding + Kirigami.Units.smallSpacing + Kirigami.Units.gridUnit
rightPadding: editButtons.width + editButtons.anchors.rightMargin * 2
color: Kirigami.Theme.textColor
verticalAlignment: TextEdit.AlignVCenter
wrapMode: Text.Wrap
onVisibleChanged: {
if (visible) {
forceActiveFocus();
root.cursorPosition = root.length;
}
}
onTextChanged: {
currentRoom.editText = text
}
Keys.onEnterPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
root.insert(cursorPosition, "\n")
} else {
root.postEdit();
}
}
Keys.onReturnPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
root.insert(cursorPosition, "\n")
} else {
root.postEdit();
}
}
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete()
}
}
Keys.onPressed: {
if (event.key === Qt.Key_Up && completionMenu.visible) {
completionMenu.decrementIndex()
} else if (event.key === Qt.Key_Down && completionMenu.visible) {
completionMenu.incrementIndex()
}
}
/**
* This is anchored like this so that control expands properly as the edited
* text grows in length.
*/
RowLayout {
id: editButtons
anchors.verticalCenter: root.verticalCenter
anchors.right: root.right
anchors.rightMargin: Kirigami.Units.smallSpacing
spacing: 0
QQC2.ToolButton {
display: QQC2.AbstractButton.IconOnly
action: Kirigami.Action {
text: i18nc("@action:button", "Confirm edit")
icon.name: "checkmark"
onTriggered: {
root.postEdit();
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
QQC2.ToolButton {
display: QQC2.AbstractButton.IconOnly
action: Kirigami.Action {
text: i18nc("@action:button", "Cancel edit")
icon.name: "dialog-close"
onTriggered: {
currentRoom.chatBoxEditId = "";
}
shortcut: "Escape"
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
}
Connections {
target: currentRoom
function onChatBoxEditIdChanged() {
if (currentRoom.chatBoxEditId == messageId && currentRoom.chatBoxEditMessage.length > 0) {
root.text = currentRoom.chatBoxEditMessage
}
}
}
CompletionMenu {
id: completionMenu
height: implicitHeight
y: -height - 5
z: 10
chatDocumentHandler: documentHandler
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
TextMetrics {
id: editTextMetrics
text: root.text
}
function postEdit() {
actionsHandler.handleEdit();
root.clear();
currentRoom.chatBoxEditId = "";
}
}

View File

@@ -11,13 +11,13 @@ import org.kde.kirigami 2.15 as Kirigami
TextEdit { TextEdit {
id: contentLabel id: contentLabel
readonly property var isEmoji: /^(<span style='.*'>)?(\u00a9|\u00ae|[\u20D0-\u2fff]|[\u3190-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+(<\/span>)?$/ readonly property var isEmoji: /^(<span style='.*'>)?(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+(<\/span>)?$/
readonly property var hasSpoiler: /data-mx-spoiler/g readonly property var hasSpoiler: /data-mx-spoiler/g
property bool isEmote: false property bool isEmote: false
property bool isReplyLabel: false property bool isReplyLabel: false
readonly property var linkRegex: /(href=["'])?(\b(https?):\/\/[^\s\<\>\"\'\\\?\:\)\(]+(\(.*?\))*(\?(?=[a-z])[^\s\\\)]+|$)?)/g readonly property var linkRegex: /(href=["'])?(\b(https?):\/\/[^\s\<\>\"\'\\]+)/g
property string textMessage: model.display.includes("http") property string textMessage: model.display.includes("http")
? model.display.replace(linkRegex, function() { ? model.display.replace(linkRegex, function() {
if (arguments[0].includes("/_matrix/media/r0/download/")) { if (arguments[0].includes("/_matrix/media/r0/download/")) {

View File

@@ -1,47 +0,0 @@
// SPDX-FileCopyrightText: 2022 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 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
RowLayout {
id: root
property var name
property alias avatar: stateAvatar.source
property var color
property alias text: label.text
signal avatarClicked()
signal linkClicked(string link)
implicitHeight: Math.max(label.contentHeight, stateAvatar.implicitHeight)
Kirigami.Avatar {
id: stateAvatar
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
name: root.name
color: root.color
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: avatarClicked()
}
}
QQC2.Label {
id: label
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
wrapMode: Text.WordWrap
textFormat: Text.RichText
onLinkActivated: linkClicked(link)
}
}

View File

@@ -51,9 +51,6 @@ QQC2.Control {
ColumnLayout { ColumnLayout {
id: columnLayout id: columnLayout
property bool folded: true
spacing: sectionVisible ? Kirigami.Units.largeSpacing : 0 spacing: sectionVisible ? Kirigami.Units.largeSpacing : 0
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: sectionVisible ? 0 : Kirigami.Units.largeSpacing anchors.topMargin: sectionVisible ? 0 : Kirigami.Units.largeSpacing
@@ -66,73 +63,45 @@ QQC2.Control {
visible: sectionVisible visible: sectionVisible
labelText: sectionVisible ? section : "" labelText: sectionVisible ? section : ""
} }
RowLayout { RowLayout {
id: rowLayout
implicitHeight: label.contentHeight
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing * 1.5 + (Config.compactLayout ? Kirigami.Units.largeSpacing * 1.25 : 0) Layout.leftMargin: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing * 1.5 + (Config.compactLayout ? Kirigami.Units.largeSpacing * 1.25 : 0)
Layout.rightMargin: Kirigami.Units.largeSpacing Layout.rightMargin: Kirigami.Units.largeSpacing
visible: stateEventRepeater.count !== 1
Flow { Kirigami.Avatar {
visible: columnLayout.folded id: icon
spacing: -Kirigami.Units.iconSizes.small / 2 Layout.preferredWidth: Kirigami.Units.iconSizes.small
Repeater { Layout.preferredHeight: Kirigami.Units.iconSizes.small
model: authorList
delegate: Kirigami.Avatar {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
name: modelData.displayName name: author.displayName
source: modelData.avatarMediaId ? ("image://mxc/" + modelData.avatarMediaId) : "" source: author.avatarMediaId ? ("image://mxc/" + author.avatarMediaId) : ""
color: modelData.color color: author.color
}
Component {
id: userDetailDialog
UserDetailDialog {}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, {room: currentRoom, user: author.object, displayName: author.displayName, avatarMediaId: author.avatarMediaId, avatarUrl: author.avatarUrl}).open()
} }
} }
QQC2.Label {
Layout.fillWidth: true
visible: columnLayout.folded
text: aggregateDisplay QQC2.Label {
elide: Qt.ElideRight id: label
textFormat: Text.RichText Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
textFormat: Text.RichText
text: `<style>a {text-decoration: none;}</style><a href="https://matrix.to/#/${author.id}" style="color: ${author.color}">${model.authorDisplayName}</a> ${aggregateDisplay}`
onLinkActivated: RoomManager.openResource(link) onLinkActivated: RoomManager.openResource(link)
} }
Item {
Layout.fillWidth: true
visible: !columnLayout.folded
}
QQC2.ToolButton {
icon.name: (!columnLayout.folded ? "go-up" : "go-down")
icon.width: Kirigami.Units.iconSizes.small
icon.height: Kirigami.Units.iconSizes.small
onClicked: {
columnLayout.toggleFolded()
}
}
}
Repeater {
id: stateEventRepeater
model: stateEvents
delegate: StateComponent {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing * 1.5 + (Config.compactLayout ? Kirigami.Units.largeSpacing * 1.25 : 0)
Layout.rightMargin: Kirigami.Units.largeSpacing
visible: !columnLayout.folded || stateEventRepeater.count === 1
name: modelData.author.displayName
avatar: modelData.author.avatarMediaId ? ("image://mxc/" + modelData.author.avatarMediaId) : ""
color: modelData.author.color
text: `<style>a {text-decoration: none;}</style><a href="https://matrix.to/#/${modelData.author.id}" style="color: ${modelData.author.color}">${modelData.authorDisplayName}</a> ${modelData.text}`
onAvatarClicked: RoomManager.openResource("https://matrix.to/#/" + modelData.author.id)
onLinkClicked: RoomManager.openResource(link)
}
}
function toggleFolded() {
folded = !folded
foldedChanged()
} }
} }
} }

View File

@@ -87,7 +87,6 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) Layout.topMargin: showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing)
Layout.leftMargin: Kirigami.Units.smallSpacing Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing
implicitHeight: Math.max(model.showAuthor ? avatar.implicitHeight : 0, bubble.height) implicitHeight: Math.max(model.showAuthor ? avatar.implicitHeight : 0, bubble.height)

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