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",
"branch": "master",
"runtime": "org.kde.Platform",
"runtime-version": "5.15-22.08",
"runtime-version": "5.15-21.08",
"sdk": "org.kde.Sdk",
"command": "neochat",
"tags": [

1
.gitignore vendored
View File

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

View File

@@ -4,11 +4,9 @@
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/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-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-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-qt6.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/flatpak.yml

View File

@@ -30,4 +30,4 @@ Dependencies:
'frameworks/kdbusaddons': '@stable'
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)
# KDE Applications version, managed by release script.
set(RELEASE_SERVICE_VERSION_MAJOR "23")
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})
project(NeoChat)
set(PROJECT_VERSION "22.11")
set(KF5_MIN_VERSION "5.91.0")
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)
@@ -130,8 +122,6 @@ set_package_properties(KF5DocTools PROPERTIES DESCRIPTION
TYPE OPTIONAL
)
find_package(Sqlite3)
if(NOT Quotient_VERSION_MINOR GREATER 6)
cmake_policy(SET CMP0063 OLD)
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:label="NeoChat"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTop"
android:exported="true">
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -23,6 +22,7 @@
</intent-filter>
<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.qt_libs_resource_id" android:resource="@array/qt_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.static_init_classes" android:value="-- %%INSERT_INIT_CLASSES%% --"/>
<!-- 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"/>
<!-- Splash screen -->

View File

@@ -12,7 +12,7 @@ buildscript {
}
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 {
minSdkVersion qtMinSdkVersion
targetSdkVersion qtTargetSdkVersion
applicationId "org.kde.neochat"
namespace "org.kde.neochat"
versionCode timestamp
versionName projectVersionFull
manifestPlaceholders = [versionName: projectVersionFull, versionCode: timestamp]
}

View File

@@ -231,20 +231,6 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<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">
<url>https://plasma-mobile.org/2022/11/30/plasma-mobile-gear-22-11/</url>
</release>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -158,12 +158,11 @@ QVector<ActionsModel::Action> actions{
return QString();
}
#ifdef QUOTIENT_07
const RoomMemberEvent *roomMemberEvent = room->currentState().get<RoomMemberEvent>(text);
if (roomMemberEvent && roomMemberEvent->membership() == Membership::Invite) {
if (room->currentState().get<RoomMemberEvent>(text)->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));
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));
return QString();
}

View File

@@ -14,9 +14,9 @@
#include <Sonnet/BackgroundChecker>
#include <Sonnet/Settings>
#include "models/actionsmodel.h"
#include "models/roomlistmodel.h"
#include "actionsmodel.h"
#include "neochatroom.h"
#include "roomlistmodel.h"
class SyntaxHighlighter : public QSyntaxHighlighter
{
@@ -105,19 +105,14 @@ ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
{
connect(this, &ChatDocumentHandler::roomChanged, this, [this]() {
m_completionModel->setRoom(m_room);
static QPointer<NeoChatRoom> previousRoom = nullptr;
static NeoChatRoom *previousRoom = nullptr;
if (previousRoom) {
disconnect(previousRoom, &NeoChatRoom::chatBoxTextChanged, this, nullptr);
disconnect(previousRoom, &NeoChatRoom::editTextChanged, this, nullptr);
}
previousRoom = m_room;
connect(m_room, &NeoChatRoom::chatBoxTextChanged, this, [this]() {
int start = completionStartIndex();
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
});
connect(m_room, &NeoChatRoom::editTextChanged, this, [this]() {
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(this, &ChatDocumentHandler::documentChanged, this, [this]() {
@@ -128,7 +123,7 @@ ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
return;
}
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
const auto cursor = cursorPosition();
#endif
const auto &text = getText();
const auto &text = m_room->chatBoxText();
auto start = std::min(cursor, text.size()) - 1;
while (start > -1) {
if (text.at(start) == QLatin1Char(' ')) {
@@ -155,20 +150,6 @@ int ChatDocumentHandler::completionStartIndex() const
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
{
return m_document;
@@ -223,7 +204,7 @@ void ChatDocumentHandler::complete(int index)
if (m_completionModel->autoCompletionType() == CompletionModel::User) {
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 text = getText();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('@'), cursorPosition() - 1);
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
@@ -232,11 +213,11 @@ void ChatDocumentHandler::complete(int index)
cursor.setPosition(at);
cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
pushMention({cursor, name, 0, 0, id});
m_room->mentions()->push_back({cursor, name, 0, 0, id});
m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == CompletionModel::Command) {
auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString();
auto text = getText();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('/'));
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
@@ -244,7 +225,7 @@ void ChatDocumentHandler::complete(int index)
cursor.insertText(QStringLiteral("/%1 ").arg(command));
} else if (m_completionModel->autoCompletionType() == CompletionModel::Room) {
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);
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
@@ -253,11 +234,11 @@ void ChatDocumentHandler::complete(int index)
cursor.setPosition(at);
cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
pushMention({cursor, alias, 0, 0, alias});
m_room->mentions()->push_back({cursor, alias, 0, 0, alias});
m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == CompletionModel::Emoji) {
auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString();
auto text = getText();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char(':'));
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
@@ -300,27 +281,3 @@ void ChatDocumentHandler::setSelectionEnd(int position)
m_selectionEnd = position;
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 <QTextCursor>
#include "models/completionmodel.h"
#include "models/userlistmodel.h"
#include "neochatroom.h"
#include "completionmodel.h"
#include "userlistmodel.h"
class QTextDocument;
class NeoChatRoom;
@@ -18,14 +17,6 @@ class SyntaxHighlighter;
class ChatDocumentHandler : public QObject
{
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(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged)
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(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
Q_PROPERTY(NeoChatRoom *room READ room NOTIFY roomChanged)
public:
explicit ChatDocumentHandler(QObject *parent = nullptr);
[[nodiscard]] bool isEdit() const;
void setIsEdit(bool edit);
[[nodiscard]] QQuickTextDocument *document() const;
void setDocument(QQuickTextDocument *document);
@@ -61,7 +49,6 @@ public:
void updateCompletions();
CompletionModel *completionModel() const;
Q_SIGNALS:
void isEditChanged();
void documentChanged();
void cursorPositionChanged();
void roomChanged();
@@ -72,8 +59,6 @@ Q_SIGNALS:
private:
int completionStartIndex() const;
bool m_isEdit;
QQuickTextDocument *m_document;
NeoChatRoom *m_room = nullptr;
@@ -83,9 +68,6 @@ private:
int m_selectionStart;
int m_selectionEnd;
QString getText() const;
void pushMention(const Mention mention) const;
SyntaxHighlighter *m_highlighter = nullptr;
CompletionModel::AutoCompletionType m_completionType = CompletionModel::None;

View File

@@ -33,14 +33,13 @@ QImage Clipboard::image() const
QString Clipboard::saveImage(QString localPath) const
{
QString imageDir(QStringLiteral("%1/screenshots").arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)));
if (!QDir().exists(imageDir)) {
QDir().mkdir(imageDir);
if (!QDir().exists(QStringLiteral("%1/screenshots").arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)))) {
QDir().mkdir(QStringLiteral("%1/screenshots").arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)));
}
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);
if (!url.isLocalFile()) {
@@ -52,11 +51,14 @@ QString Clipboard::saveImage(QString localPath) const
return {};
}
if (image.save(url.toLocalFile())) {
return localPath;
} else {
return {};
QDir dir;
if (!dir.exists(localPath)) {
dir.mkpath(localPath);
}
image.save(url.toLocalFile());
return localPath;
}
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:
enum Roles {
AggregateDisplayRole = MessageEventModel::LastRole + 1,
StateEventsRole,
AuthorListRole,
};
[[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override;
[[nodiscard]] QHash<int, QByteArray> roleNames() 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;
/**
* @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)
, m_filterModel(new CompletionProxyModel())
, 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::roomChanged, this, [this]() {

View File

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

View File

@@ -4,11 +4,7 @@
#include "controller.h"
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
#include <qt5keychain/keychain.h>
#else
#include <qt6keychain/keychain.h>
#endif
#include <KConfig>
#include <KConfigGroup>
@@ -132,7 +128,16 @@ Controller::Controller(QObject *parent)
if (AccountRegistry::instance().size() > oldAccountCount) {
auto connection = AccountRegistry::instance().accounts()[AccountRegistry::instance().size() - 1];
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();
@@ -141,16 +146,19 @@ Controller::Controller(QObject *parent)
}
#ifdef QUOTIENT_07
void Controller::handleNotifications(QPointer<Quotient::Connection> connection)
void Controller::handleNotifications()
{
static QStringList initial;
static bool initial = true;
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();
if (!initial.contains(connection->user()->id())) {
initial.append(connection->user()->id());
if (initial) {
initial = false;
for (const auto &n : notifications) {
oldNotifications += n.toObject()["event"].toObject()["event_id"].toString();
}
@@ -166,7 +174,7 @@ void Controller::handleNotifications(QPointer<Quotient::Connection> connection)
continue;
}
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 && !(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") {
#ifdef Quotient_E2EE_ENABLED
auto decrypted = connection->decryptNotification(notification);
auto decrypted = m_connection->decryptNotification(notification);
body = decrypted["content"].toObject()["body"].toString();
#endif
if (body.isEmpty()) {
@@ -373,11 +381,6 @@ void Controller::invokeLogin()
if (error == "Unrecognised access token") {
Q_EMIT errorOccured(i18n("Login Failed: Access Token invalid or revoked"));
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 {
Q_EMIT errorOccured(i18n("Login Failed: %1", error));
logout(connection, true);

View File

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

View File

@@ -4,8 +4,8 @@
#pragma once
#include <QAbstractListModel>
#include <QRegularExpression>
#include <memory>
#include <QRegularExpression>
struct CustomEmoji {
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);
connect(job, &BaseJob::result, this, [this, job, password, index] {
auto onSuccess = [this, index]() {
beginRemoveRows(QModelIndex(), index, index);
m_devices.remove(index);
endRemoveRows();
};
if (job->error() != BaseJob::Success) {
if (job->error() != 0) {
QJsonObject replyData = job->jsonData();
QJsonObject authData;
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()}};
authData["identifier"] = identifier;
auto *innerJob = Controller::instance().activeConnection()->callApi<NeochatDeleteDeviceJob>(m_devices[index].deviceId, authData);
connect(innerJob, &BaseJob::success, this, onSuccess);
} else {
onSuccess();
connect(innerJob, &BaseJob::success, this, [this, index]() {
beginRemoveRows(QModelIndex(), index, index);
m_devices.remove(index);
endRemoveRows();
});
}
});
}

View File

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

View File

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

View File

@@ -67,7 +67,6 @@ QHash<int, QByteArray> MessageEventModel::roleNames() const
roles[IsNameChangeRole] = "isNameChange";
roles[IsAvatarChangeRole] = "isAvatarChange";
roles[IsRedactedRole] = "isRedacted";
roles[GenericDisplayRole] = "genericDisplay";
return roles;
}
@@ -440,7 +439,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
case EventTypeRole:
return DelegateType::ReadMarker;
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;
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);
}
if (role == GenericDisplayRole) {
if (evt.isRedacted()) {
return i18n("<i>[This message was deleted]</i>");
}
return m_currentRoom->eventToGenericString(evt);
}
if (role == FormattedBodyRole) {
if (auto e = eventCast<const RoomMessageEvent>(&evt)) {
if (e->hasTextContent() && e->mimeType().name() != "text/plain") {
@@ -946,12 +937,24 @@ QVariant MessageEventModel::getLastLocalUserMessageEventId()
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
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 {
// For any message that isn't an edit return the id of the current message
eventId = (*it)->id();
}
targetMessage.insert("event_id", eventId);
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
QModelIndex idx = index(eventIDToIndex(eventId), 0);
targetMessage.insert("message", idx.data(Qt::UserRole + 2));

View File

@@ -45,7 +45,6 @@ public:
AnnotationRole,
UserMarkerRole,
FormattedBodyRole,
GenericDisplayRole,
MimeTypeRole,
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>
<default>0.3</default>
</entry>
<entry name="ShowNotifications" type="bool">
<label>Show notifications</label>
<default>true</default>
</entry>
<entry name="MergeRoomList" type="bool">
<label>Merge Room Lists</label>
<default>false</default>

View File

@@ -658,135 +658,6 @@ QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format,
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)
{
const auto job = connection()->uploadFile(localFile.toLocalFile());
@@ -868,13 +739,25 @@ void NeoChatRoom::postHtmlMessage(const QString &text, const QString &html, Mess
}
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{
{"type", "m.room.message"},
{"msgtype", msgTypeToString(type)},
{"body", "* " + text},
{"format", "org.matrix.custom.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}}}};
postJson("m.room.message", json);
@@ -1626,17 +1509,6 @@ void NeoChatRoom::setChatBoxText(const QString &text)
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
{
return m_chatBoxReplyId;
@@ -1713,11 +1585,6 @@ QVector<Mention> *NeoChatRoom::mentions()
return &m_mentions;
}
QVector<Mention> *NeoChatRoom::editMentions()
{
return &m_editMentions;
}
QString NeoChatRoom::savedText() const
{
return m_savedText;

View File

@@ -12,8 +12,8 @@
#include <qcoro/task.h>
#include "neochatuser.h"
#include "pollhandler.h"
class PollHandler;
class NeoChatUser;
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
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 chatBoxEditId READ chatBoxEditId WRITE setChatBoxEditId NOTIFY chatBoxEditIdChanged)
Q_PROPERTY(NeoChatUser *chatBoxReplyUser READ chatBoxReplyUser NOTIFY chatBoxReplyIdChanged)
@@ -254,7 +249,6 @@ public:
[[nodiscard]] QString avatarMediaId() 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 isUserBanned(const QString &user) const;
@@ -276,9 +270,6 @@ public:
QString chatBoxText() const;
void setChatBoxText(const QString &text);
QString editText() const;
void setEditText(const QString &text);
QString chatBoxReplyId() const;
void setChatBoxReplyId(const QString &replyId);
@@ -296,11 +287,6 @@ public:
QVector<Mention> *mentions();
/**
* @brief Vector of mentions in the current edit text.
*/
QVector<Mention> *editMentions();
QString savedText() const;
void setSavedText(const QString &savedText);
@@ -350,12 +336,10 @@ private:
QCoro::Task<void> doUploadFile(QUrl url, QString body = QString());
QString m_chatBoxText;
QString m_editText;
QString m_chatBoxReplyId;
QString m_chatBoxEditId;
QString m_chatBoxAttachmentPath;
QVector<Mention> m_mentions;
QVector<Mention> m_editMentions;
QString m_savedText;
#ifdef QUOTIENT_07
QCache<QString, PollHandler> m_polls;
@@ -378,7 +362,6 @@ Q_SIGNALS:
void pushNotificationStateChanged(PushNotificationState::State state);
void showMessage(MessageType messageType, const QString &message);
void chatBoxTextChanged();
void editTextChanged();
void chatBoxReplyIdChanged();
void chatBoxEditIdChanged();
void chatBoxAttachmentPathChanged();

View File

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

View File

@@ -33,7 +33,6 @@ class NotificationsManager : public QObject
{
Q_OBJECT
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
oneToOneNotificationActionChanged)
Q_PROPERTY(PushNotificationAction::Action encryptedOneToOneNotificationAction MEMBER m_encryptedOneToOneNotificationAction WRITE
@@ -74,8 +73,7 @@ private:
QMultiMap<QString, KNotification *> m_notifications;
QHash<QString, QPointer<KNotification>> m_invitations;
bool m_globalNotificationsEnabled = false;
bool m_globalNotificationsSet = false;
bool m_globalNotificationsEnabled;
PushNotificationAction::Action m_oneToOneNotificationAction = PushNotificationAction::Unknown;
PushNotificationAction::Action m_encryptedOneToOneNotificationAction = PushNotificationAction::Unknown;
PushNotificationAction::Action m_groupChatNotificationAction = PushNotificationAction::Unknown;
@@ -109,7 +107,6 @@ private Q_SLOTS:
Q_SIGNALS:
void globalNotificationsEnabledChanged(bool newState);
void globalNotificationsSetChanged(bool newState);
void oneToOneNotificationActionChanged(PushNotificationAction::Action action);
void encryptedOneToOneNotificationActionChanged(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[pt]=Procurar salas no NeoChat
Comment[pt_BR]=Encontrar salas no NeoChat
Comment[ru]=Поиск комнат NeoChat
Comment[ru]=Поиск комнаты NeoChat
Comment[sl]=Najdi sobe v NeoChatu
Comment[sv]=Sök efter rum i NeoChat
Comment[ta]=நியோச்சாட்டில் அரங்குகளை கண்டுபிடிக்கும்

View File

@@ -10,115 +10,188 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
ColumnLayout {
id: root
Loader {
id: attachmentPaneLoader
signal attachmentCancelled()
property string attachmentPath
readonly property var attachmentMimetype: FileType.mimeTypeForUrl(attachmentPath)
readonly property var attachmentMimetype: FileType.mimeTypeForUrl(attachmentPaneLoader.attachmentPath)
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)
RowLayout {
spacing: Kirigami.Units.smallSpacing
active: visible
sourceComponent: Component {
QQC2.Pane {
id: attachmentPane
Kirigami.Theme.colorSet: Kirigami.Theme.View
QQC2.Label {
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
text: i18n("Attachment:")
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
}
QQC2.ToolButton {
id: editImageButton
visible: hasImage
icon.name: "document-edit"
text: i18n("Edit")
display: QQC2.AbstractButton.IconOnly
contentItem: Item {
property real spacing: attachmentPane.spacing > 0 ? attachmentPane.spacing : toolBar.spacing
implicitWidth: Math.max(image.implicitWidth, imageBusyIndicator.implicitWidth, fileInfoLayout.implicitWidth, toolBar.implicitWidth)
implicitHeight: Math.max(
(hasImage ? Math.max(image.preferredHeight, imageBusyIndicator.implicitHeight) + spacing : 0)
+ fileInfoLayout.implicitHeight,
toolBar.implicitHeight
)
Component {
id: imageEditorPage
ImageEditorPage {
imagePath: root.attachmentPath
Image {
id: image
property real preferredHeight: Math.min(implicitHeight, Kirigami.Units.gridUnit * 8)
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: {
let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage);
imageEditor.newPathChanged.connect(function(newPath) {
applicationWindow().pageStack.layers.pop();
root.attachmentPath = newPath;
});
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
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.Layouts 1.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Window 2.15
import org.kde.kirigami 2.18 as Kirigami
import org.kde.neochat 1.0
QQC2.Control {
id: root
property alias textField: textField
property bool isReplying: currentRoom.chatBoxReplyId.length > 0
property NeoChatUser replyUser: currentRoom.chatBoxReplyUser
property bool attachmentPaneVisible: currentRoom.chatBoxAttachmentPath.length > 0
QQC2.ToolBar {
id: chatBar
property alias inputFieldText: inputField.text
property alias textField: inputField
property alias cursorPosition: inputField.cursorPosition
signal inputFieldForceActiveFocusTriggered()
signal messageSent()
property list<Kirigami.Action> actions : [
Kirigami.Action {
id: attachmentAction
onInputFieldForceActiveFocusTriggered: {
inputField.forceActiveFocus();
// 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
visible: currentRoom.chatBoxReplyId.length === 0 && currentRoom.chatBoxAttachmentPath.length === 0
icon.name: "mail-attachment"
text: i18n("Attach an image or file")
displayHint: Kirigami.DisplayHint.IconOnly
Kirigami.Theme.colorSet: Kirigami.Theme.View
onTriggered: {
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()
}
// Using a custom background because some styles like Material
// or Fusion might have ugly colors for a TextArea placed inside
// of a toolbar. ToolBar is otherwise the closest QQC2 component
// to what we want because of the padding and spacing values.
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
contentItem: RowLayout {
spacing: chatBar.spacing
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
},
Kirigami.Action {
id: emojiAction
QQC2.TextArea {
id: inputField
focus: true
/* 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"
text: i18n("Add an Emoji")
displayHint: Kirigami.DisplayHint.IconOnly
display: QQC2.AbstractButton.IconOnly
checkable: true
onTriggered: {
onClicked: {
if (emojiDialog.visible) {
emojiDialog.close()
} else {
@@ -66,264 +214,29 @@ QQC2.Control {
}
}
tooltip: text
},
Kirigami.Action {
id: sendAction
property bool isBusy: false
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
QQC2.ToolButton {
id: sendButton
icon.name: "document-send"
text: i18n("Send message")
displayHint: Kirigami.DisplayHint.IconOnly
checkable: true
display: QQC2.AbstractButton.IconOnly
onTriggered: {
root.postMessage()
onClicked: {
chatBar.postMessage()
}
tooltip: text
}
]
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
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
}
EmojiDialog {
id: emojiDialog
x: parent.width - implicitWidth
y: -implicitHeight // - Kirigami.Units.smallSpacing
y: -implicitHeight - Kirigami.Units.smallSpacing
modal: false
includeCustom: true
@@ -333,10 +246,6 @@ QQC2.Control {
onClosed: if (emojiButton.checked) emojiButton.checked = false
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
CompletionMenu {
id: completionMenu
height: implicitHeight
@@ -352,29 +261,26 @@ QQC2.Control {
}
}
Connections {
target: currentRoom
function onChatBoxEditIdChanged() {
if (currentRoom.chatBoxEditMessage.length > 0) {
chatBar.inputFieldText = currentRoom.chatBoxEditMessage
}
}
}
ChatDocumentHandler {
id: documentHandler
document: textField.textDocument
cursorPosition: textField.cursorPosition
selectionStart: textField.selectionStart
selectionEnd: textField.selectionEnd
document: inputField.textDocument
cursorPosition: inputField.cursorPosition
selectionStart: inputField.selectionStart
selectionEnd: inputField.selectionEnd
Component.onCompleted: {
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() {
let localPath = Clipboard.saveImage();
@@ -385,11 +291,12 @@ QQC2.Control {
}
function postMessage() {
actionsHandler.handleNewMessage();
actionsHandler.handleMessage();
repeatTimer.stop()
currentRoom.markAllMessagesAsRead();
textField.clear();
inputField.clear();
currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxEditId = "";
messageSent()
}
}

View File

@@ -5,52 +5,108 @@
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
ColumnLayout {
id: chatBox
property alias inputFieldText: chatBar.inputFieldText
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
Kirigami.InlineMessage {
Kirigami.Separator {
id: connectionPaneSeparator
visible: connectionPane.visible
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
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 {
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
}
ChatBar {
id: chatBar
visible: currentRoom.canSendEvent("m.room.message")
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: {
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
}
property var chatDocumentHandler
required property var chatDocumentHandler
Component.onCompleted: {
chatDocumentHandler.completionModel.roomListModel = roomListModel;
}

View File

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

View File

@@ -16,11 +16,4 @@ Kirigami.LoadingPlaceholder {
QQC2.Label {
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 var maxWidth: Kirigami.Units.gridUnit * 30
readonly property var maxHeight: Kirigami.Units.gridUnit * 30
innerObject: AnimatedImage {
id: img
property var imageWidth: {
if (imageDelegate.info && imageDelegate.info.w && imageDelegate.info.w > 0) {
if (imageDelegate.info.w > 0) {
return imageDelegate.info.w;
} else if (sourceSize.width && sourceSize.width > 0) {
} else if (sourceSize.width > 0) {
return sourceSize.width;
} else {
return imageDelegate.contentMaxWidth;
}
}
property var imageHeight: {
if (imageDelegate.info && imageDelegate.info.h && imageDelegate.info.h > 0) {
if (imageDelegate.info.h > 0) {
return imageDelegate.info.h;
} else if (sourceSize.height && sourceSize.height > 0) {
} else if (sourceSize.height > 0) {
return sourceSize.height;
} else {
// Default to a 16:9 placeholder
@@ -52,29 +51,8 @@ TimelineContainer {
}
}
readonly property var aspectRatio: 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.maximumWidth: Math.min(imageDelegate.contentMaxWidth, imageDelegate.maxWidth)
Layout.maximumHeight: Math.min(imageDelegate.contentMaxWidth / imageWidth * imageHeight, imageDelegate.maxWidth / imageWidth * imageHeight)
Layout.preferredWidth: imageWidth
Layout.preferredHeight: imageHeight
source: model.mediaUrl
@@ -91,8 +69,6 @@ TimelineContainer {
QQC2.ToolTip.visible: hoverHandler.hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
paused: !applicationWindow().active
HoverHandler {
id: hoverHandler
}

View File

@@ -1,5 +1,4 @@
// 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
import QtQuick 2.15
@@ -10,140 +9,58 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
Loader {
id: root
RowLayout {
id: row
/**
* @brief Get a list of hyperlinks in the text.
*
* User links i.e. anything starting with https://matrix.to are ignored.
*/
property var links: {
let matches = model.display.match(/\bhttps?:\/\/[^\s\<\>\"\']+/g)
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 []
}
property var links: model.display.match(/\bhttps?:\/\/[^\s\<\>\"\']+/g)
// don't show previews for room links or user mentions or custom emojis
.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)
LinkPreviewer {
id: linkPreviewer
url: root.links && root.links.length > 0 ? root.links[0] : ""
id: lp
url: links.length > 0 ? links[0] : ""
}
/**
* @brief Standard height for the link preview.
*
* When the content of the link preview is larger than this it will be
* elided/hidden until maximized.
*/
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;
visible: lp.loaded && lp.title
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
visible: lp.loaded && lp.title
color: Kirigami.Theme.highlightColor
}
</style>
<a href=\"" + root.links[0] + "\">" + (maximizeButton.checked ? linkPreviewer.title : titleTextMetrics.elidedText).replace("&ndash;", "—") + "</a>"
onLinkActivated: RoomManager.openResource(link)
TextMetrics {
id: titleTextMetrics
text: linkPreviewer.title
font: linkPreviewTitle.font
elide: Text.ElideRight
elideWidth: (linkPreviewTitle.width - Kirigami.Units.largeSpacing * 2.5) * 3
}
}
QQC2.Label {
id: linkPreviewDescription
Layout.fillWidth: true
Layout.maximumHeight: maximizeButton.checked ? -1 : root.defaultHeight - linkPreviewTitle.height - column.spacing
visible: linkPreviewTitle.height + column.spacing <= root.defaultHeight || maximizeButton.checked
text: linkPreviewer.description
wrapMode: Text.Wrap
elide: Text.ElideRight
}
}
}
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
}
Image {
visible: lp.imageSource
Layout.maximumHeight: Kirigami.Units.gridUnit * 5
Layout.maximumWidth: Kirigami.Units.gridUnit * 5
source: lp.imageSource.replace("mxc://", "image://mxc/")
fillMode: Image.PreserveAspectFit
}
ColumnLayout {
id: column
spacing: Kirigami.Units.smallSpacing
Kirigami.Heading {
Layout.maximumWidth: messageDelegate.bubbleMaxWidth
Layout.fillWidth: true
level: 4
wrapMode: Text.Wrap
textFormat: Text.RichText
text: "<style>
a {
text-decoration: none;
}
</style>
<a href=\"" + links[0] + "\">" + lp.title.replace("&ndash;", "—") + "</a>"
visible: lp.loaded
onLinkActivated: RoomManager.openResource(link)
}
}
Component {
id: loadingComponent
RowLayout {
property bool hovered: false
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")
}
QQC2.Label {
text: lp.description
Layout.maximumWidth: messageDelegate.bubbleMaxWidth
Layout.fillWidth: true
wrapMode: Text.Wrap
visible: lp.loaded && lp.description
}
}
}

View File

@@ -21,24 +21,16 @@ TimelineContainer {
RichLabel {
id: label
Layout.fillWidth: true
visible: currentRoom.chatBoxEditId !== model.eventId
isEmote: messageDelegate.isEmote
}
MessageEditComponent {
Loader {
id: linkPreviewLoader
Layout.fillWidth: true
messageId: model.eventId
visible: currentRoom.chatBoxEditId === model.eventId
onVisibleChanged: {
if (visible) {
editChatDocumentHandler.document = textDocument
editChatDocumentHandler.cursorPosition = cursorPosition
editChatDocumentHandler.selectionStart = selectionStart
editChatDocumentHandler.selectionEnd = selectionEnd
}
active: !currentRoom.usesEncryption && model.display && model.display.includes("http")
visible: Config.showLinkPreview && active
sourceComponent: LinkPreviewDelegate {
anchors.verticalCenter: parent.verticalCenter
}
}
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 {
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
property bool isEmote: 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")
? model.display.replace(linkRegex, function() {
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 {
id: columnLayout
property bool folded: true
spacing: sectionVisible ? Kirigami.Units.largeSpacing : 0
anchors.top: parent.top
anchors.topMargin: sectionVisible ? 0 : Kirigami.Units.largeSpacing
@@ -66,73 +63,45 @@ QQC2.Control {
visible: sectionVisible
labelText: sectionVisible ? section : ""
}
RowLayout {
id: rowLayout
implicitHeight: label.contentHeight
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: stateEventRepeater.count !== 1
Flow {
visible: columnLayout.folded
spacing: -Kirigami.Units.iconSizes.small / 2
Repeater {
model: authorList
delegate: Kirigami.Avatar {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
Kirigami.Avatar {
id: icon
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
name: modelData.displayName
source: modelData.avatarMediaId ? ("image://mxc/" + modelData.avatarMediaId) : ""
color: modelData.color
}
name: author.displayName
source: author.avatarMediaId ? ("image://mxc/" + author.avatarMediaId) : ""
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
elide: Qt.ElideRight
textFormat: Text.RichText
QQC2.Label {
id: label
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
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)
}
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.topMargin: showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing)
Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing
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