Compare commits

..

4 Commits

Author SHA1 Message Date
Torrie Fischer
e8f72a44fb page: welcomepage: add some icons for style points 2022-10-10 14:12:54 +02:00
Torrie Fischer
6e0aa7f683 page: welcomepage: animations++ 2022-10-10 14:12:54 +02:00
Torrie Fischer
afa1ec6a4d component: login: loading: Use a fancy LoadingPlaceholder instead of boring static text 2022-10-10 14:12:23 +02:00
Torrie Fischer
6e20a46525 page: welcomepage: display the users avatar when logging in 2022-10-10 14:12:18 +02:00
461 changed files with 69784 additions and 178309 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": [
@@ -22,12 +22,6 @@
"--own-name=org.kde.StatusNotifierItem-2-2"
],
"modules": [
{
"name": "kirigamiaddons",
"config-opts": [ "-DBUILD_TESTING=OFF" ],
"buildsystem": "cmake-ninja",
"sources": [ { "type": "git", "url": "https://invent.kde.org/libraries/kirigami-addons.git" } ]
},
{
"name": "kquickimageeditor",
"buildsystem": "cmake-ninja",
@@ -41,7 +35,6 @@
{
"name": "olm",
"buildsystem": "cmake-ninja",
"config-opts": [ "-DOLM_TESTS=OFF" ],
"sources": [
{
"type": "git",
@@ -107,19 +100,16 @@
{
"type": "git",
"url": "https://github.com/quotient-im/libQuotient.git",
"branch": "dev",
"disable-submodules": true
"branch": "dev"
}
],
"config-opts": [
"-DQuotient_ENABLE_E2EE=ON",
"-DBUILD_TESTING=OFF"
"-DQuotient_ENABLE_E2EE=ON"
]
},
{
"name": "cmark",
"buildsystem": "cmake-ninja",
"config-opts": [ "-DCMARK_TESTS=OFF" ],
"sources": [
{
"type": "git",
@@ -136,12 +126,11 @@
{
"name": "qcoro",
"buildsystem": "cmake-ninja",
"config-opts": [ "-DQCORO_BUILD_EXAMPLES=OFF", "-DBUILD_TESTING=OFF" ],
"sources": [
{
"type": "archive",
"url": "https://github.com/danvratil/qcoro/archive/refs/tags/v0.7.0.tar.gz",
"sha256": "23ef0217926e67c8d2eb861cf91617da2f7d8d5a9ae6c62321b21448b1669210",
"url": "https://github.com/danvratil/qcoro/archive/refs/tags/v0.4.0.tar.gz",
"sha256": "0e68b3f0ce7bf521ffbdd731464d2d60d8d7a39a749b551ed26855a1707d86d1",
"x-checker-data": {
"type": "anitya",
"project-id": 236236,

7
.gitignore vendored
View File

@@ -1,4 +1,4 @@
build*
build
.clang-format
.DS_Store
.kdev4/
@@ -7,8 +7,3 @@ compile_commands.json
.cache/
.vscode/
kate.project.ctags.*
*.user
.flatpak-builder/
.idea/
cmake-build-*
src/res.generated.qrc

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/freebsd.yml
- https://invent.kde.org/sysadmin/ci-utilities/raw/master/gitlab-templates/flatpak.yml

View File

@@ -1,8 +1,8 @@
# SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
# SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
# SPDX-License-Identifier: BSD-2-Clause
Dependencies:
- 'on': ['Linux/Qt5', 'Android/Qt5', 'FreeBSD/Qt5', 'Windows/Qt5']
- 'on': ['@all']
'require':
'frameworks/extra-cmake-modules': '@stable'
'frameworks/kcoreaddons': '@stable'
@@ -11,58 +11,18 @@ Dependencies:
'frameworks/kconfig': '@stable'
'frameworks/syntax-highlighting': '@stable'
'frameworks/kitemmodels': '@stable'
'frameworks/kquickcharts': '@stable'
'frameworks/knotifications': '@stable'
'frameworks/kconfigwidgets': '@stable'
'libraries/kquickimageeditor': '@stable'
'frameworks/sonnet': '@stable'
'libraries/kirigami-addons': '@latest'
'third-party/libquotient': '@latest'
'third-party/qtkeychain': '@latest'
'third-party/cmark': '@latest'
'third-party/qcoro': '@latest'
- 'on': ['Windows/Qt5', 'Linux/Qt5', 'FreeBSD/Qt5']
- 'on': ['Windows', 'Linux', 'FreeBSD']
'require':
'frameworks/qqc2-desktop-style': '@stable'
'frameworks/kio': '@stable'
'frameworks/kwindowsystem': '@stable'
- 'on': ['Linux/Qt5', 'FreeBSD/Qt5']
'frameworks/sonnet': '@stable'
'frameworks/kconfigwidgets': '@stable'
- 'on': ['Linux', 'FreeBSD']
'require':
'frameworks/kdbusaddons': '@stable'
- 'on': ['Linux/Qt6', 'Android/Qt6', 'FreeBSD/Qt6', 'Windows/Qt6']
'require':
'frameworks/extra-cmake-modules': '@latest-kf6'
'frameworks/kcoreaddons': '@latest-kf6'
'frameworks/kirigami': '@latest-kf6'
'frameworks/ki18n': '@latest-kf6'
'frameworks/kconfig': '@latest-kf6'
'frameworks/syntax-highlighting': '@latest-kf6'
'frameworks/kitemmodels': '@latest-kf6'
'frameworks/kquickcharts': '@latest-kf6'
'frameworks/knotifications': '@latest-kf6'
'libraries/kquickimageeditor': '@latest-kf6'
'frameworks/sonnet': '@latest-kf6'
'libraries/kirigami-addons': '@latest-kf6'
'third-party/libquotient': '@latest'
'third-party/qtkeychain': '@latest'
'third-party/cmark': '@latest'
'third-party/qcoro': '@latest'
- 'on': ['Windows/Qt6', 'Linux/Qt6', 'FreeBSD/Qt6']
'require':
'frameworks/qqc2-desktop-style': '@latest-kf6'
'frameworks/kio': '@latest-kf6'
'frameworks/kwindowsystem': '@latest-kf6'
'frameworks/kconfigwidgets': '@latest-kf6'
- 'on': ['Linux/Qt6', 'FreeBSD/Qt6']
'require':
'frameworks/kdbusaddons': '@latest-kf6'
'frameworks/kstatusnotifieritem': '@latest-kf6'
- 'on': ['Linux/Qt6', 'Linux/Qt5']
'require':
'sdk/selenium-webdriver-at-spi': '@latest-kf6'
Options:
per-test-timeout: 90
require-passing-tests-on: [ 'Linux/Qt5', 'FreeBSD', 'Windows' ]
require-passing-tests-on: [ 'Linux', 'Windows' ]

View File

@@ -6,11 +6,15 @@ Files: 128-logo.png icons/* logo.png org.kde.neochat.svg org.kde.neochat.tray.sv
Copyright: 2020 Carson Black <uhhadd@gmail.com>
License: LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
Files: qtquickcontrols2.conf
Copyright: 2020 Tobias Fella <fella@posteo.de>
License: CC0-1.0
Files: android/res/drawable/splash.xml
Copyright: 2020 Tobias Fella <tobias.fella@kde.org>
Copyright: 2020 Tobias Fella <fella@posteo.de>
License: BSD-2-Clause
Files: .gitignore
Files: */qmldir .gitignore
Copyright: None
License: CC0-1.0
@@ -18,7 +22,7 @@ Files: .gitlab/issue_templates/bug.md
Copyright: 2021 Carl Schwan <carlschwan@kde.org>
License: CC0-1.0
Files: src/res.qrc src/res_android.qrc src/res_desktop.qrc
Files: res.qrc res_android.qrc res_desktop.qrc
Copyright: None
License: CC0-1.0
@@ -27,17 +31,17 @@ Copyright: 2021 Carl Schwan <carlschwan@kde.org>
License: BSD-2-Clause
Files: src/neochatconfig.kcfg
Copyright: 2020-2021 Carl Schwan <carlschwan@kde.org>, Tobias Fella <tobias.fella@kde.org>
Copyright: 2020-2021 Carl Schwan <carlschwan@kde.org>, Tobias Fella <fella@posteo.de>
License: BSD-2-Clause
Files: src/neochat.notifyrc
Copyright: 2020 Tobias Fella <tobias.fella@kde.org>
Copyright: 2020 Tobias Fella <fella@posteo.de>
License: BSD-2-Clause
Files: src/qml/Component/confetti.png src/qml/Component/glowdot.png
Files: imports/NeoChat/Component/confetti.png imports/NeoChat/Component/glowdot.png
Copyright: 2021 Alexey Andreyev <aa13q@ya.ru>
License: CC0-1.0
Files: .flatpak-manifest.json
Copyright: 2020-2022 Tobias Fella <tobias.fella@kde.org>
Copyright: 2020-2022 Tobias Fella <fella@posteo.de>
License: BSD-2-Clause

View File

@@ -1,45 +1,35 @@
# SPDX-FileCopyrightText: 2020-2021 Carl Schwan <carl@carlschwan.eu>
# SPDX-FileCopyrightText: 2020-2021 Nicolas Fella <nicolas.fella@gmx.de>
# SPDX-FileCopyrightText: 2020-2021 Tobias Fella <tobias.fella@kde.org>
# SPDX-FileCopyrightText: 2020-2021 Tobias Fella <fella@posteo.de>
# SPDX-FileCopyrightText: 2021 Adriaan de Groot <groot@kde.org>
# SPDX-License-Identifier: BSD-2-Clause
cmake_minimum_required(VERSION 3.16)
# KDE Applications version, managed by release script.
set(RELEASE_SERVICE_VERSION_MAJOR "23")
set(RELEASE_SERVICE_VERSION_MINOR "11")
set(RELEASE_SERVICE_VERSION_MICRO "70")
set(RELEASE_SERVICE_VERSION "${RELEASE_SERVICE_VERSION_MAJOR}.${RELEASE_SERVICE_VERSION_MINOR}.${RELEASE_SERVICE_VERSION_MICRO}")
project(NeoChat)
set(PROJECT_VERSION "22.09")
project(NeoChat VERSION ${RELEASE_SERVICE_VERSION})
set(KF_MIN_VERSION "5.105.0")
set(KF5_MIN_VERSION "5.91.0")
set(QT_MIN_VERSION "5.15.2")
if (ANDROID)
set(QT_MIN_VERSION "5.15.10")
endif()
find_package(ECM ${KF_MIN_VERSION} REQUIRED NO_MODULE)
find_package(ECM ${KF5_MIN_VERSION} REQUIRED NO_MODULE)
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH} ${CMAKE_SOURCE_DIR}/cmake)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(KDE_COMPILERSETTINGS_LEVEL 5.105)
set(KDE_COMPILERSETTINGS_LEVEL 5.84)
include(FeatureSummary)
include(ECMSetupVersion)
include(KDEInstallDirs)
include(ECMFindQmlModule)
include(KDECMakeSettings)
include(ECMAddTests)
include(KDECompilerSettings NO_POLICY_SCOPE)
include(ECMAddAppIcon)
include(KDEGitCommitHooks)
include(ECMCheckOutboundLicense)
include(ECMQtDeclareLoggingCategory)
if (NOT ANDROID)
include(KDEClangFormat)
endif()
@@ -48,54 +38,31 @@ if(NEOCHAT_FLATPAK)
include(cmake/Flatpak.cmake)
endif()
if(QT_MAJOR_VERSION STREQUAL "6")
set(BASICLISTITEM_BOLD "font.bold")
set(OVERLAYSHEET_OPEN "onOpened")
set(QTQUICK_MODULE_QML_VERSION "")
set(QTLOCATION_MODULE_QML_VERSION "")
set(QTMULTIMEDIA_MODULE_QML_VERSION "")
set(QTMULTIMEDIA_AUDIO "MediaPlayer")
# in Audio qt6 we don't have it but we disable it in qt5 => it seems ok
set(QTMULTIMEDIA_AUDIO_AUTOLOAD "")
# In Video qml qt6 we don't have it.
set(QTMULTIMEDIA_VIDEO_FLUSHMODE "")
else()
set(BASICLISTITEM_BOLD "bold")
set(OVERLAYSHEET_OPEN "onSheetOpenChanged")
set(QTQUICK_MODULE_QML_VERSION "2.15")
set(QTLOCATION_MODULE_QML_VERSION "5.15")
set(QTMULTIMEDIA_MODULE_QML_VERSION "5.15")
set(QTMULTIMEDIA_AUDIO "Audio")
set(QTMULTIMEDIA_AUDIO_AUTOLOAD "autoLoad: false")
set(QTMULTIMEDIA_VIDEO_FLUSHMODE "flushMode: VideoOutput.FirstFrame")
endif()
set(QUOTIENT_FORCE_NAMESPACED_INCLUDES TRUE)
ecm_setup_version(${PROJECT_VERSION}
VARIABLE_PREFIX NEOCHAT
VERSION_HEADER ${CMAKE_CURRENT_BINARY_DIR}/neochat-version.h
)
find_package(Qt${QT_MAJOR_VERSION} ${QT_MIN_VERSION} NO_MODULE COMPONENTS Core Quick Gui QuickControls2 Multimedia Svg WebView)
find_package(Qt${QT_MAJOR_VERSION} ${QT_MIN_VERSION} NO_MODULE COMPONENTS Core Quick Gui QuickControls2 Multimedia Svg)
set_package_properties(Qt${QT_MAJOR_VERSION} PROPERTIES
TYPE REQUIRED
PURPOSE "Basic application components"
)
find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} COMPONENTS Kirigami2 I18n Notifications Config CoreAddons Sonnet ItemModels ConfigWidgets)
set_package_properties(KF${QT_MAJOR_VERSION} PROPERTIES
find_package(KF5 ${KF5_MIN_VERSION} COMPONENTS Kirigami2 I18n Notifications Config CoreAddons)
set_package_properties(KF5 PROPERTIES
TYPE REQUIRED
PURPOSE "Basic application components"
)
set_package_properties(KF${QT_MAJOR_VERSION}Kirigami2 PROPERTIES
TYPE REQUIRED
PURPOSE "Kirigami application UI framework"
)
find_package(KF${QT_MAJOR_VERSION}KirigamiAddons 0.7.2 REQUIRED)
set_package_properties(KF5Kirigami2 PROPERTIES
TYPE REQUIRED
PURPOSE "Kirigami application UI framework"
)
if(QT_MAJOR_VERSION STREQUAL "6")
find_package(KF6StatusNotifierItem ${KF_MIN_VERSION} REQUIRED)
endif()
find_package(Qt${QT_MAJOR_VERSION}Keychain)
set_package_properties(Qt${QT_MAJOR_VERSION}Keychain PROPERTIES
TYPE REQUIRED
PURPOSE "Secure storage of account secrets"
)
if(ANDROID)
find_package(OpenSSL)
@@ -105,34 +72,26 @@ if(ANDROID)
)
else()
find_package(Qt${QT_MAJOR_VERSION} ${QT_MIN_VERSION} COMPONENTS Widgets)
find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle KIO WindowSystem)
set_package_properties(KF${QT_MAJOR_VERSION}QQC2DesktopStyle PROPERTIES
find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle ConfigWidgets KIO WindowSystem)
set_package_properties(KF5QQC2DesktopStyle PROPERTIES
TYPE RUNTIME
)
ecm_find_qmlmodule(org.kde.sonnet 1.0)
ecm_find_qmlmodule(org.kde.syntaxhighlighting 1.0)
endif()
if (NOT ANDROID AND NOT WIN32 AND NOT APPLE)
find_package(KF${QT_MAJOR_VERSION}DBusAddons ${KF_MIN_VERSION} REQUIRED)
find_package(KF5DBusAddons ${KF5_MIN_VERSION} REQUIRED)
endif()
if(QT_MAJOR_VERSION STREQUAL "6" AND NOT ANDROID AND NOT WIN32)
set(QUOTIENT_SUFFIX "Qt6")
endif()
find_package(Quotient${QUOTIENT_SUFFIX} 0.7)
set_package_properties(Quotient${QUOTIENT_SUFFIX} PROPERTIES
find_package(Quotient 0.6)
set_package_properties(Quotient PROPERTIES
TYPE REQUIRED
DESCRIPTION "Qt wrapper around Matrix API"
URL "https://github.com/quotient-im/libQuotient/"
PURPOSE "Talk with matrix server"
)
if (NOT TARGET Olm::Olm)
message(FATAL_ERROR "NeoChat requires Quotient with the E2EE feature enabled")
endif()
find_package(cmark)
set_package_properties(cmark PROPERTIES
TYPE REQUIRED
@@ -143,8 +102,6 @@ set_package_properties(cmark PROPERTIES
ecm_find_qmlmodule(org.kde.kquickimageeditor 1.0)
ecm_find_qmlmodule(org.kde.kitemmodels 1.0)
ecm_find_qmlmodule(org.kde.quickcharts 1.0)
ecm_find_qmlmodule(QtLocation ${QTLOCATION_MODULE_QML_VERSION})
find_package(KQuickImageEditor COMPONENTS)
set_package_properties(KQuickImageEditor PROPERTIES
@@ -154,18 +111,18 @@ set_package_properties(KQuickImageEditor PROPERTIES
PURPOSE "Add image editing capability to image attachments"
)
find_package(QCoro${QT_MAJOR_VERSION} 0.4 COMPONENTS Core REQUIRED)
find_package(QCoro${QT_MAJOR_VERSION} COMPONENTS Core QUIET)
if(NOT QCoro${QT_MAJOR_VERSION}_FOUND)
find_package(QCoro REQUIRED)
endif()
qcoro_enable_coroutines()
find_package(KF${QT_MAJOR_VERSION}DocTools ${KF_MIN_VERSION})
set_package_properties(KF${QT_MAJOR_VERSION}DocTools PROPERTIES DESCRIPTION
"Tools to generate documentation"
TYPE OPTIONAL
)
if(NOT Quotient_VERSION_MINOR GREATER 6)
cmake_policy(SET CMP0063 OLD)
endif()
if(ANDROID)
find_package(Sqlite3)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/android/version.gradle.in ${CMAKE_BINARY_DIR}/version.gradle)
endif()
@@ -180,17 +137,6 @@ add_definitions(-DQT_NO_FOREACH)
add_subdirectory(src)
if (BUILD_TESTING)
find_package(Qt${QT_MAJOR_VERSION} ${QT_MIN_VERSION} NO_MODULE COMPONENTS Test)
add_subdirectory(autotests)
add_subdirectory(appiumtests)
endif()
if(KF${QT_MAJOR_VERSION}DocTools_FOUND)
kdoctools_install(po)
add_subdirectory(doc)
endif()
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
if (NOT ANDROID)
@@ -204,9 +150,3 @@ file(GLOB_RECURSE ALL_SOURCE_FILES *.cpp *.h *.qml)
# Fixes the test by excluding this directory
list(FILTER ALL_SOURCE_FILES EXCLUDE REGEX [[_(install|build)/.*]])
ecm_check_outbound_license(LICENSES GPL-3.0-only FILES ${ALL_SOURCE_FILES})
ecm_qt_install_logging_categories(
EXPORT NEOCHAT
FILE neochat.categories
DESTINATION ${KDE_INSTALL_LOGGINGCATEGORIESDIR}
)

View File

@@ -1,170 +0,0 @@
Creative Commons Attribution-ShareAlike 4.0 International
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensors permission is not necessary for any reasonfor example, because of any applicable exception or limitation to copyrightthen that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
Creative Commons Attribution-ShareAlike 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 Definitions.
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part; and
B. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. Term. The term of this Public License is specified in Section 6(a).
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. Downstream recipients.
A. Offer from the Licensor Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. Additional offer from the Licensor Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapters License You apply.
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
Section 3 License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapters License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
Section 5 Disclaimer of Warranties and Limitation of Liability.
a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
Creative Commons may be contacted at creativecommons.org.

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

102
README.md
View File

@@ -1,51 +1,24 @@
<!--
SPDX-FileCopyrightText: 2020-2021 Carl Schwan <carlschwan@kde.org>
SPDX-FileCopyrightText: 2020-2021 Tobias Fella <tobias.fella@kde.org>
SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
SPDX-FileCopyrightText: 2020-2021 Tobias Fella <fella@posteo.de>
SPDX-License-Identifier: CC0-1.0
-->
# NeoChat
A Qt/QML based Matrix client.
NeoChat is a client for Matrix, the decentralized communication protocol for instant
messaging. It is a fork of Spectral, using KDE frameworks, most notably Kirigami,
KConfig and KI18n.
<a href='https://matrix.org'><img src='https://matrix.org/docs/legacy/made-for-matrix.png' alt='Made for Matrix' height=64 target=_blank /></a>
<a href='https://flathub.org/apps/details/org.kde.neochat'><img width='190px' alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-i-en.png'/></a>
## Introduction
NeoChat is a client for [Matrix](https://matrix.org), the decentralized communication protocol for instant
messaging. It is a fork of Spectral, using KDE frameworks, most notably [Kirigami](https://invent.kde.org/frameworks/kirigami)
to provide a convergent experience across multiple platforms.
NeoChat also make use of other KDE Frameworks as well as [libQuotient](https://github.com/quotient-im/libQuotient), a
Qt-based SDK for the [Matrix Protocol](https://spec.matrix.org/).
![Timeline](https://cdn.kde.org/screenshots/neochat/application.png)
## Features
NeoChat aims to be a fully featured application for the Matrix specification. As such everything in the current stable specification with the notable exceptions
of VoIP, threads and some aspects of End-to-End Encryption are supported. There are a few other smaller omissions due to the fact that the Matrix spec is constantly
evolving but the aim remains to provide eventual support for the entire spec.
Due to the nature of the Matrix specification development NeoChat also supports numerous unstable features. Currently these are:
- Polls - MSC3381
- Sticker Packs - MSC2545
- Location Events - MSC3488
## Get it
Details where to find stable releases for NeoChat can be found on its [homepage](https://apps.kde.org/neochat).
A stable release [is available](https://apps.kde.org/neochat) for download for Linux distributions.
In addition to the stable builds, unstable nightly builds are available for all platforms. These can be downloaded
from the [binary factory](https://binary-factory.kde.org/). There are unstable versions for the following platforms
in addition to stable ones:
- Android
- MacOS
- Windows
Additionally the nightly Flatpak version can be obtained from the nightly Flatpak repo using the following commands in your terminal:
Along with the stable release, a Flatpak version is available for the nightly
version:
```
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
@@ -53,53 +26,42 @@ flatpak remote-add --if-not-exists kdeapps --from https://distribute.kde.org/kde
flatpak install kdeapps org.kde.neochat
```
The unstable Android version can also be obtained from the [KDE nightly F-Droid repo](https://community.kde.org/Android/FDroid).
A nightly build is also available for Android in the [KDE nightly F-Droid repo](https://community.kde.org/Android/FDroid)
and can also directly be downloaded from the [binary factory](https://binary-factory.kde.org/view/Android/job/NeoChat_Nightly_android-arm64/).
## Running
Nightly builds for [Windows](https://binary-factory.kde.org/job/NeoChat_Nightly_win64/), [MacOS](https://binary-factory.kde.org/job/NeoChat_Nightly_macos/) and [AppImages](https://binary-factory.kde.org/job/NeoChat_Nightly_appimage/) can also be downloaded from the [binary factory](https://binary-factory.kde.org/search/?q=neochat).
Just start the executable in your preferred way - either from the build directory or from the installed location.
![Timeline](https://cdn.kde.org/screenshots/neochat/application.png)
## Building NeoChat
## Features
The best way to build KDE apps during development is to use `kdesrc-build`. The full instructions for this can be found on
the KDE community website's get involved section under [development](https://community.kde.org/Get_Involved/development). This
is primarily aimed at Linux development.
* Sending messages
* Sending files from clipboard and filesystem
* Reply to message (right-click on a message to access menu)
* Start a private chat (but not encrypted)
* Show notifications, for the moment there is only a global switch
to disable it. We plan to implement the configuration part of the
specification soon.
* Autocompletion of usernames in chat
* Emoji picker
* Basic room setting page
* Send and accept invitations
* /rainbow <message> (very important)
* /me <message>
For Windows and Android [Craft](https://invent.kde.org/packaging/craft) is the primary choice. There are guides for setting up
development environments for [Windows](https://community.kde.org/Get_Involved/development/Windows) and [Android](https://develop.kde.org/docs/packaging/android/building_applications/).
## Tests
Tests are in the repository under [autotests](autotests) and should all pass for any contribution.
The project has CI setup to test new commits to the repository. All tests are expected to pass for a merge request to
be complete.
Current build status
![coverage](https://invent.kde.org/network/neochat/badges/master/pipeline.svg)
Currently the number of tests is limited, but growing. If anyone wants to help improve this, those
contributions would be especially welcome.
## Contributing
As is the case throughout the KDE ecosystem contributions are welcome from all. The code base is managed in the
[NeoChat repository](https://invent.kde.org/network/neochat) of the KDE Gitlab instance.
- [Code of Conduct](https://kde.org/code-of-conduct)
- [Report a Bug](https://bugs.kde.org/enter_bug.cgi?format=guided&product=neochat)
- [Feature Request](https://community.kde.org/Infrastructure/GitLab#Submitting_a_merge_request)
- [Create a Merge Request](https://community.kde.org/Infrastructure/GitLab#Submitting_a_merge_request)
- [Translation](https://community.kde.org/Get_Involved/translation)
NeoChat is still missing a few features to become a full-featured
Matrix client (most notably encryption support and video chat support).
We welcome contributions in this direction.
## Contact
The best place to reach the maintainers is on the KDE Matrix instance in the NeoChat channel, [#neochat:kde.org](https://matrix.to/#/#neochat:kde.org).
You can reach the maintainers at [#neochat:kde.org](https://matrix.to/#/#neochat:kde.org), if you are already on Matrix.
Development happens in http://invent.kde.org/network/neochat (not in GitHub).
## Acknowledgement
This program utilizes [libQuotient](https://github.com/quotient-im/libQuotient/) as its Matrix SDK.
This program utilizes [libQuotient](https://github.com/quotient-im/libQuotient/)
library and some C++ models from [Quaternion](https://github.com/quotient-im/Quaternion/).
This program is a fork of [Spectral](https://gitlab.com/spectral-im/spectral/).

View File

@@ -9,13 +9,12 @@
android:versionName="${versionName}"
android:versionCode="${versionCode}"
android:installLocation="auto">
<application android:name="org.qtproject.qt5.android.bindings.QtApplication" android:label="NeoChat" android:icon="@drawable/neochat" android:usesCleartextTraffic="true">
<application android:name="org.qtproject.qt5.android.bindings.QtApplication" android:label="NeoChat" android:icon="@drawable/neochat">
<activity android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|layoutDirection|locale|fontScale|keyboard|keyboardHidden|navigation"
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'
}
}
@@ -47,7 +47,6 @@ android {
compileSdkVersion androidCompileSdkVersion.toInteger()
buildToolsVersion androidBuildToolsVersion
ndkVersion androidNdkVersion
sourceSets {
main {
@@ -74,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

@@ -1,23 +0,0 @@
# SPDX-License-Identifier: BSD-3-Clause
# SPDX-FileCopyrightText: 2022 Harald Sitter <sitter@kde.org>
if(NOT BUILD_TESTING OR NOT CMAKE_SYSTEM_NAME MATCHES "Linux")
return()
endif()
find_package(SeleniumWebDriverATSPI)
set_package_properties(SeleniumWebDriverATSPI PROPERTIES
DESCRIPTION "Server component for selenium tests using Linux accessibility infrastructure"
PURPOSE "Needed for GUI tests"
URL "https://invent.kde.org/sdk/selenium-webdriver-at-spi"
TYPE OPTIONAL
)
if(NOT SeleniumWebDriverATSPI_FOUND)
return()
endif()
add_test(
NAME logintest
COMMAND selenium-webdriver-at-spi-run ${CMAKE_CURRENT_SOURCE_DIR}/logintest.py
)

View File

@@ -1,42 +0,0 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
from flask import Flask, request, abort
app = Flask(__name__)
@app.route("/_matrix/client/v3/login", methods=["GET"])
def login_get():
result = dict()
result["flows"] = [dict()]
result["flows"][0]["type"] = "m.login.password"
return result
@app.route("/_matrix/client/v3/login", methods=["POST"])
def login_post():
data = request.get_json()
if data["identifier"]["user"] != "user" or data["password"] != "1234":
abort(403)
print(data)
result = dict()
result["access_token"] = "token_1234"
result["device_id"] = "device_1234"
result["user_id"] = "@user:localhost:1234"
return result
@app.route("/_matrix/client/r0/sync")
def sync():
result = dict()
result["next_batch"] = "batch1234"
return result
@app.route("/.well-known/matrix/client")
def well_known():
reply = dict()
reply["m.homeserver"] = dict()
reply["m.homeserver"]["base_url"] = "https://localhost:1234"
return reply
if __name__ == "__main__":
app.run(ssl_context='adhoc', port=1234)

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2021-2022 Harald Sitter <sitter@kde.org>
# SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
import unittest
from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
import os
import time
import subprocess
import sys
class LoginTest(unittest.TestCase):
@classmethod
def setUpClass(self):
desired_caps = {}
desired_caps["app"] = "neochat --ignore-ssl-errors"
desired_caps["timeouts"] = {'implicit': 10000}
self.driver = webdriver.Remote(
command_executor='http://127.0.0.1:4723',
desired_capabilities=desired_caps)
self.mockServerProcess = subprocess.Popen([sys.executable, os.path.join(os.path.dirname(__file__), "login-server.py")])
def setUp(self):
pass
def tearDown(self):
if not self._outcome.result.wasSuccessful():
self.driver.get_screenshot_as_file("failed_test_shot_{}.png".format(self.id()))
@classmethod
def tearDownClass(self):
self.mockServerProcess.terminate()
self.driver.quit()
def test_login(self):
self.driver.find_element(by=AppiumBy.NAME, value="Login").click()
self.driver.find_element(by=AppiumBy.NAME, value="Matrix ID").send_keys("@user:localhost:1234")
self.driver.find_element(by=AppiumBy.NAME, value="Continue").click()
self.driver.find_element(by=AppiumBy.NAME, value="Password").send_keys("1234")
self.driver.find_element(by=AppiumBy.NAME, value="Login").click()
self.driver.find_element(by=AppiumBy.NAME, value="Join some rooms to get started").click()
if __name__ == '__main__':
unittest.main()

View File

@@ -1,34 +0,0 @@
# SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
# SPDX-License-Identifier: BSD-2-Clause
enable_testing()
ecm_add_test(
neochatroomtest.cpp
LINK_LIBRARIES neochat Qt::Test
TEST_NAME neochatroomtest
)
ecm_add_test(
texthandlertest.cpp
LINK_LIBRARIES neochat Qt::Test
TEST_NAME texthandlertest
)
ecm_add_test(
delegatesizehelpertest.cpp
LINK_LIBRARIES neochat Qt::Test
TEST_NAME delegatesizehelpertest
)
ecm_add_test(
mediasizehelpertest.cpp
LINK_LIBRARIES neochat Qt::Test
TEST_NAME mediasizehelpertest
)
ecm_add_test(
eventhandlertest.cpp
LINK_LIBRARIES neochat Qt::Test
TEST_NAME eventhandlertest
)

View File

@@ -1,156 +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
#include <QObject>
#include <QTest>
#include "delegatesizehelper.h"
class DelegateSizeHelperTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void risingPercentage_data();
void risingPercentage();
void fallingPercentage_data();
void fallingPercentage();
void equalPercentage_data();
void equalPercentage();
void equalBreakpoint_data();
void equalBreakpoint();
};
void DelegateSizeHelperTest::risingPercentage_data()
{
QTest::addColumn<qreal>("parentWidth");
QTest::addColumn<int>("currentPercentageWidth");
QTest::addColumn<qreal>("currentWidth");
QTest::newRow("zero") << qreal(0) << int(0) << qreal(0);
QTest::newRow("one hundred") << qreal(100) << int(0) << qreal(0);
QTest::newRow("one fifty") << qreal(150) << int(50) << qreal(75);
QTest::newRow("two hundred") << qreal(200) << int(100) << qreal(200);
QTest::newRow("three hundred") << qreal(300) << int(100) << qreal(300);
}
void DelegateSizeHelperTest::risingPercentage()
{
QFETCH(qreal, parentWidth);
QFETCH(int, currentPercentageWidth);
QFETCH(qreal, currentWidth);
DelegateSizeHelper delegateSizeHelper;
delegateSizeHelper.setStartBreakpoint(100);
delegateSizeHelper.setEndBreakpoint(200);
delegateSizeHelper.setStartPercentWidth(0);
delegateSizeHelper.setEndPercentWidth(100);
delegateSizeHelper.setParentWidth(parentWidth);
QCOMPARE(delegateSizeHelper.currentPercentageWidth(), currentPercentageWidth);
QCOMPARE(delegateSizeHelper.currentWidth(), currentWidth);
}
void DelegateSizeHelperTest::fallingPercentage_data()
{
QTest::addColumn<qreal>("parentWidth");
QTest::addColumn<int>("currentPercentageWidth");
QTest::addColumn<qreal>("currentWidth");
QTest::newRow("zero") << qreal(0) << int(100) << qreal(0);
QTest::newRow("one hundred") << qreal(100) << int(100) << qreal(100);
QTest::newRow("one fifty") << qreal(150) << int(50) << qreal(75);
QTest::newRow("two hundred") << qreal(200) << int(0) << qreal(0);
QTest::newRow("three hundred") << qreal(300) << int(0) << qreal(0);
}
void DelegateSizeHelperTest::fallingPercentage()
{
QFETCH(qreal, parentWidth);
QFETCH(int, currentPercentageWidth);
QFETCH(qreal, currentWidth);
DelegateSizeHelper delegateSizeHelper;
delegateSizeHelper.setStartBreakpoint(100);
delegateSizeHelper.setEndBreakpoint(200);
delegateSizeHelper.setStartPercentWidth(100);
delegateSizeHelper.setEndPercentWidth(0);
delegateSizeHelper.setParentWidth(parentWidth);
QCOMPARE(delegateSizeHelper.currentPercentageWidth(), currentPercentageWidth);
QCOMPARE(delegateSizeHelper.currentWidth(), currentWidth);
}
void DelegateSizeHelperTest::equalPercentage_data()
{
QTest::addColumn<qreal>("parentWidth");
QTest::addColumn<int>("currentPercentageWidth");
QTest::addColumn<qreal>("currentWidth");
QTest::newRow("zero") << qreal(0) << int(50) << qreal(0);
QTest::newRow("one hundred") << qreal(100) << int(50) << qreal(50);
QTest::newRow("one fifty") << qreal(150) << int(50) << qreal(75);
QTest::newRow("two hundred") << qreal(200) << int(50) << qreal(100);
QTest::newRow("three hundred") << qreal(300) << int(50) << qreal(150);
}
void DelegateSizeHelperTest::equalPercentage()
{
QFETCH(qreal, parentWidth);
QFETCH(int, currentPercentageWidth);
QFETCH(qreal, currentWidth);
DelegateSizeHelper delegateSizeHelper;
delegateSizeHelper.setStartBreakpoint(100);
delegateSizeHelper.setEndBreakpoint(200);
delegateSizeHelper.setStartPercentWidth(50);
delegateSizeHelper.setEndPercentWidth(50);
delegateSizeHelper.setParentWidth(parentWidth);
QCOMPARE(delegateSizeHelper.currentPercentageWidth(), currentPercentageWidth);
QCOMPARE(delegateSizeHelper.currentWidth(), currentWidth);
}
void DelegateSizeHelperTest::equalBreakpoint_data()
{
QTest::addColumn<int>("startPercentageWidth");
QTest::addColumn<int>("endPercentageWidth");
QTest::addColumn<int>("currentPercentageWidth");
QTest::addColumn<qreal>("currentWidth");
QTest::newRow("start higher") << int(100) << int(0) << int(-1) << qreal(0);
QTest::newRow("equal") << int(50) << int(50) << int(50) << qreal(500);
QTest::newRow("end higher") << int(0) << int(100) << int(-1) << qreal(0);
}
/**
* We expect a default return except in the case where the the two percentages are
* equal as that case can be calculated without dividing by zero.
*/
void DelegateSizeHelperTest::equalBreakpoint()
{
QFETCH(int, startPercentageWidth);
QFETCH(int, endPercentageWidth);
QFETCH(int, currentPercentageWidth);
QFETCH(qreal, currentWidth);
DelegateSizeHelper delegateSizeHelper;
delegateSizeHelper.setStartBreakpoint(100);
delegateSizeHelper.setEndBreakpoint(100);
delegateSizeHelper.setStartPercentWidth(startPercentageWidth);
delegateSizeHelper.setEndPercentWidth(endPercentageWidth);
delegateSizeHelper.setParentWidth(1000);
QCOMPARE(delegateSizeHelper.currentPercentageWidth(), currentPercentageWidth);
QCOMPARE(delegateSizeHelper.currentWidth(), currentWidth);
}
QTEST_GUILESS_MAIN(DelegateSizeHelperTest)
#include "delegatesizehelpertest.moc"

View File

@@ -1,729 +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
#include <QObject>
#include <QTest>
#include "eventhandler.h"
#include <KFormat>
#include <Quotient/connection.h>
#include <Quotient/quotient_common.h>
#include <Quotient/syncdata.h>
#include "enums/delegatetype.h"
#include "linkpreviewer.h"
#include "models/reactionmodel.h"
#include "neochatroom.h"
#include "utils.h"
using namespace Quotient;
class TestRoom : public NeoChatRoom
{
public:
using NeoChatRoom::NeoChatRoom;
void update(SyncRoomData &&data, bool fromCache = false)
{
Room::updateData(std::move(data), fromCache);
}
};
class EventHandlerTest : public QObject
{
Q_OBJECT
private:
Connection *connection = nullptr;
TestRoom *room = nullptr;
EventHandler eventHandler;
private Q_SLOTS:
void initTestCase();
void eventId();
void delegateType_data();
void delegateType();
void author();
void authorDisplayName();
void time();
void timeString();
void highlighted();
void hidden();
void body();
void genericBody_data();
void genericBody();
void mediaInfo();
void linkPreviewer();
void reactions();
void hasReply();
void replyId();
void replyDelegateType();
void replyAuthor();
void replyBody();
void replyMediaInfo();
void location();
void readMarkers();
};
void EventHandlerTest::initTestCase()
{
connection = Connection::makeMockConnection(QStringLiteral("@bob:kde.org"));
room = new TestRoom(connection, QStringLiteral("#myroom:kde.org"), JoinState::Join);
const auto json = QJsonDocument::fromJson(R"EVENT({
"account_data": {
"events": [
{
"content": {
"tags": {
"u.work": {
"order": 0.9
}
}
},
"type": "m.tag"
},
{
"content": {
"custom_config_key": "custom_config_value"
},
"type": "org.example.custom.room.config"
}
]
},
"ephemeral": {
"events": [
{
"content": {
"user_ids": [
"@alice:matrix.org",
"@bob:example.com"
]
},
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"type": "m.typing"
},
{
"content": {
"$153456789:example.org": {
"m.read": {
"@alice:matrix.org": {
"ts": 1436451550453
}
}
}
},
"type": "m.receipt"
},
{
"content": {
"$1532735824654:example.org": {
"m.read": {
"@bob:example.com": {
"ts": 1436451550453
}
}
}
},
"type": "m.receipt"
},
{
"content": {
"$1532735824654:example.org": {
"m.read": {
"@tim:example.com": {
"ts": 1436451550454
}
}
}
},
"type": "m.receipt"
},
{
"content": {
"$1532735824654:example.org": {
"m.read": {
"@jeff:example.com": {
"ts": 1436451550455
}
}
}
},
"type": "m.receipt"
},
{
"content": {
"$1532735824654:example.org": {
"m.read": {
"@tina:example.com": {
"ts": 1436451550456
}
}
}
},
"type": "m.receipt"
},
{
"content": {
"$1532735824654:example.org": {
"m.read": {
"@sally:example.com": {
"ts": 1436451550457
}
}
}
},
"type": "m.receipt"
},
{
"content": {
"$1532735824654:example.org": {
"m.read": {
"@fred:example.com": {
"ts": 1436451550458
}
}
}
},
"type": "m.receipt"
}
]
},
"state": {
"events": [
{
"content": {
"avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF",
"displayname": "Alice Margatroid",
"membership": "join",
"reason": "Looking for support"
},
"event_id": "$143273582443PhrSn:example.org",
"origin_server_ts": 1432735824653,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"state_key": "@alice:example.org",
"type": "m.room.member",
"unsigned": {
"age": 1234
}
}
]
},
"summary": {
"m.heroes": [
"@alice:example.com",
"@bob:example.com"
],
"m.invited_member_count": 0,
"m.joined_member_count": 2
},
"timeline": {
"events": [
{
"content": {
"body": "This is an example\ntext message",
"format": "org.matrix.custom.html",
"formatted_body": "<b>This is an example<br>text message</b>",
"msgtype": "m.text"
},
"event_id": "$153456789:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1232
}
},
{
"content": {
"avatar_url": "mxc://kde.org/123456",
"displayname": "after",
"membership": "join"
},
"origin_server_ts": 1690651134736,
"sender": "@example:example.org",
"state_key": "@example:example.org",
"type": "m.room.member",
"unsigned": {
"replaces_state": "$1234567890:example.org",
"prev_content": {
"avatar_url": "mxc://kde.org/12345",
"displayname": "before",
"membership": "join"
},
"prev_sender": "@example:example.orgg",
"age": 1234
},
"event_id": "$143273583553PhrSn:example.org",
"room_id": "!jEsUZKDJdhlrceRyVU:example.org"
},
{
"content": {
"body": "This is a highlight @bob:kde.org and this is a link https://kde.org",
"format": "org.matrix.custom.html",
"msgtype": "m.text"
},
"event_id": "$1532735824654:example.org",
"origin_server_ts": 1532735824654,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1233
}
},
{
"content": {
"m.relates_to": {
"event_id": "$153456789:example.org",
"key": "👍",
"rel_type": "m.annotation"
}
},
"origin_server_ts": 1690322545182,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@alice:matrix.org",
"type": "m.reaction",
"unsigned": {
"age": 390159120
},
"event_id": "$163456789:example.org",
"age": 390159120
},
{
"age": 4926305285,
"content": {
"body": "video caption",
"filename": "video.mp4",
"info": {
"duration": 10,
"h": 1080,
"mimetype": "video/mp4",
"size": 62650636,
"w": 1920,
"thumbnail_info": {
"h": 450,
"mimetype": "image/jpeg",
"size": 382249,
"w": 800
},
"thumbnail_url": "mxc://kde.org/2234567"
},
"msgtype": "m.video",
"url": "mxc://kde.org/1234567"
},
"event_id": "$263456789:example.org",
"origin_server_ts": 1685793783330,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 4926305285
},
"user_id": "@example:example.org"
},
{
"content": {
"body": "> <@example:example.org> This is an example\ntext message\n\nreply",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!jEsUZKDJdhlrceRyVU:example.org/$153456789:example.org?via=kde.org&via=matrix.org\">In reply to</a> <a href=\"https://matrix.to/#/@example:example.org\">@example:example.org</a><br><b>This is an example<br>text message</b></blockquote></mx-reply>reply",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$153456789:example.org"
}
},
"msgtype": "m.text"
},
"origin_server_ts": 1690725965572,
"sender": "@alice:matrix.org",
"type": "m.room.message",
"unsigned": {
"age": 98
},
"event_id": "$154456789:example.org",
"room_id": "!jEsUZKDJdhlrceRyVU:example.org"
},
{
"content": {
"body": "> <@example:example.org> video caption\n\nreply",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$263456789:example.org"
}
},
"msgtype": "m.text"
},
"origin_server_ts": 1690725965573,
"sender": "@alice:matrix.org",
"type": "m.room.message",
"unsigned": {
"age": 98
},
"event_id": "$154456799:example.org",
"room_id": "!jEsUZKDJdhlrceRyVU:example.org"
},
{
"age": 96845207,
"content": {
"body": "Lat: 51.7035, Lon: -1.14394",
"geo_uri": "geo:51.7035,-1.14394",
"msgtype": "m.location",
"org.matrix.msc1767.text": "Lat: 51.7035, Lon: -1.14394",
"org.matrix.msc3488.asset": {
"type": "m.pin"
},
"org.matrix.msc3488.location": {
"uri": "geo:51.7035,-1.14394"
}
},
"event_id": "$1544567999:example.org",
"origin_server_ts": 1690821582876,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 96845207
}
}
],
"limited": true,
"prev_batch": "t34-23535_0_0"
}
})EVENT");
SyncRoomData roomData(QStringLiteral("@bob:kde.org"), JoinState::Join, json.object());
room->update(std::move(roomData));
eventHandler.setRoom(room);
}
void EventHandlerTest::eventId()
{
eventHandler.setEvent(room->messageEvents().at(0).get());
QCOMPARE(eventHandler.getId(), QStringLiteral("$153456789:example.org"));
}
void EventHandlerTest::delegateType_data()
{
QTest::addColumn<int>("eventNum");
QTest::addColumn<DelegateType::Type>("delegateType");
QTest::newRow("message") << 0 << DelegateType::Message;
QTest::newRow("state") << 1 << DelegateType::State;
QTest::newRow("message 2") << 2 << DelegateType::Message;
QTest::newRow("reaction") << 3 << DelegateType::Other;
QTest::newRow("video") << 4 << DelegateType::Video;
QTest::newRow("location") << 7 << DelegateType::Location;
}
void EventHandlerTest::delegateType()
{
QFETCH(int, eventNum);
QFETCH(DelegateType::Type, delegateType);
eventHandler.setEvent(room->messageEvents().at(eventNum).get());
QCOMPARE(eventHandler.getDelegateType(), delegateType);
}
void EventHandlerTest::author()
{
auto event = room->messageEvents().at(0).get();
auto author = room->user(event->senderId());
eventHandler.setEvent(event);
auto eventHandlerAuthor = eventHandler.getAuthor();
QCOMPARE(eventHandlerAuthor["isLocalUser"_ls], author->id() == room->localUser()->id());
QCOMPARE(eventHandlerAuthor["id"_ls], author->id());
QCOMPARE(eventHandlerAuthor["displayName"_ls], author->displayname(room));
QCOMPARE(eventHandlerAuthor["avatarSource"_ls], room->avatarForMember(author));
QCOMPARE(eventHandlerAuthor["avatarMediaId"_ls], author->avatarMediaId(room));
QCOMPARE(eventHandlerAuthor["color"_ls], Utils::getUserColor(author->hueF()));
QCOMPARE(eventHandlerAuthor["object"_ls], QVariant::fromValue(author));
}
void EventHandlerTest::authorDisplayName()
{
auto event = room->messageEvents().at(1).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getAuthorDisplayName(), QStringLiteral("before"));
}
void EventHandlerTest::time()
{
auto event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getTime(), QDateTime::fromMSecsSinceEpoch(1432735824654, Qt::UTC));
QCOMPARE(eventHandler.getTime(true, QDateTime::fromMSecsSinceEpoch(1234, Qt::UTC)), QDateTime::fromMSecsSinceEpoch(1234, Qt::UTC));
}
void EventHandlerTest::timeString()
{
auto event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
KFormat format;
QCOMPARE(eventHandler.getTimeString(false),
QLocale().toString(QDateTime::fromMSecsSinceEpoch(1432735824654, Qt::UTC).toLocalTime().time(), QLocale::ShortFormat));
QCOMPARE(eventHandler.getTimeString(true),
format.formatRelativeDate(QDateTime::fromMSecsSinceEpoch(1432735824654, Qt::UTC).toLocalTime().date(), QLocale::ShortFormat));
QCOMPARE(eventHandler.getTimeString(false, QLocale::ShortFormat, true, QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC)),
QLocale().toString(QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC).toLocalTime().time(), QLocale::ShortFormat));
QCOMPARE(eventHandler.getTimeString(true, QLocale::ShortFormat, true, QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC)),
format.formatRelativeDate(QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC).toLocalTime().date(), QLocale::ShortFormat));
QCOMPARE(eventHandler.getTimeString(false, QLocale::LongFormat, true, QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC)),
QLocale().toString(QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC).toLocalTime().time(), QLocale::LongFormat));
QCOMPARE(eventHandler.getTimeString(true, QLocale::LongFormat, true, QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC)),
format.formatRelativeDate(QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC).toLocalTime().date(), QLocale::LongFormat));
}
void EventHandlerTest::highlighted()
{
auto event = room->messageEvents().at(2).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.isHighlighted(), true);
event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.isHighlighted(), false);
}
void EventHandlerTest::hidden()
{
auto event = room->messageEvents().at(3).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.isHidden(), true);
event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.isHidden(), false);
}
void EventHandlerTest::body()
{
auto event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getRichBody(), QStringLiteral("<b>This is an example<br>text message</b>"));
QCOMPARE(eventHandler.getRichBody(true), QStringLiteral("<b>This is an example text message</b>"));
QCOMPARE(eventHandler.getPlainBody(), QStringLiteral("This is an example\ntext message"));
QCOMPARE(eventHandler.getPlainBody(true), QStringLiteral("This is an example text message"));
}
void EventHandlerTest::genericBody_data()
{
QTest::addColumn<int>("eventNum");
QTest::addColumn<QString>("output");
QTest::newRow("message") << 0 << QStringLiteral("sent a message");
QTest::newRow("member") << 1 << QStringLiteral("changed their display name and updated their avatar");
QTest::newRow("message 2") << 2 << QStringLiteral("sent a message");
QTest::newRow("reaction") << 3 << QStringLiteral("Unknown event");
QTest::newRow("video") << 4 << QStringLiteral("sent a message");
}
void EventHandlerTest::genericBody()
{
QFETCH(int, eventNum);
QFETCH(QString, output);
eventHandler.setEvent(room->messageEvents().at(eventNum).get());
QCOMPARE(eventHandler.getGenericBody(), output);
}
void EventHandlerTest::mediaInfo()
{
auto event = room->messageEvents().at(4).get();
eventHandler.setEvent(event);
auto mediaInfo = eventHandler.getMediaInfo();
auto thumbnailInfo = mediaInfo["tempInfo"_ls].toMap();
QCOMPARE(mediaInfo["source"_ls], room->makeMediaUrl(event->id(), QUrl("mxc://kde.org/1234567"_ls)));
QCOMPARE(mediaInfo["mimeType"_ls], QStringLiteral("video/mp4"));
QCOMPARE(mediaInfo["mimeIcon"_ls], QStringLiteral("video-mp4"));
QCOMPARE(mediaInfo["size"_ls], 62650636);
QCOMPARE(mediaInfo["duration"_ls], 10);
QCOMPARE(mediaInfo["width"_ls], 1920);
QCOMPARE(mediaInfo["height"_ls], 1080);
QCOMPARE(thumbnailInfo["source"_ls], room->makeMediaUrl(event->id(), QUrl("mxc://kde.org/2234567"_ls)));
QCOMPARE(thumbnailInfo["mimeType"_ls], QStringLiteral("image/jpeg"));
QCOMPARE(thumbnailInfo["mimeIcon"_ls], QStringLiteral("image-jpeg"));
QCOMPARE(thumbnailInfo["size"_ls], 382249);
QCOMPARE(thumbnailInfo["width"_ls], 800);
QCOMPARE(thumbnailInfo["height"_ls], 450);
}
void EventHandlerTest::linkPreviewer()
{
auto event = room->messageEvents().at(2).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getLinkPreviewer()->url(), QUrl("https://kde.org"_ls));
event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getLinkPreviewer(), nullptr);
}
void EventHandlerTest::reactions()
{
auto event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getReactions()->rowCount(), 1);
}
void EventHandlerTest::hasReply()
{
auto event = room->messageEvents().at(5).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.hasReply(), true);
event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.hasReply(), false);
}
void EventHandlerTest::replyId()
{
auto event = room->messageEvents().at(5).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getReplyId(), QStringLiteral("$153456789:example.org"));
event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getReplyId(), QStringLiteral(""));
}
void EventHandlerTest::replyDelegateType()
{
auto event = room->messageEvents().at(5).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getReplyDelegateType(), DelegateType::Message);
event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getReplyDelegateType(), DelegateType::Other);
}
void EventHandlerTest::replyAuthor()
{
auto event = room->messageEvents().at(5).get();
auto replyEvent = room->messageEvents().at(0).get();
auto replyAuthor = room->user(replyEvent->senderId());
eventHandler.setEvent(event);
auto eventHandlerReplyAuthor = eventHandler.getReplyAuthor();
QCOMPARE(eventHandlerReplyAuthor["isLocalUser"_ls], replyAuthor->id() == room->localUser()->id());
QCOMPARE(eventHandlerReplyAuthor["id"_ls], replyAuthor->id());
QCOMPARE(eventHandlerReplyAuthor["displayName"_ls], replyAuthor->displayname(room));
QCOMPARE(eventHandlerReplyAuthor["avatarSource"_ls], room->avatarForMember(replyAuthor));
QCOMPARE(eventHandlerReplyAuthor["avatarMediaId"_ls], replyAuthor->avatarMediaId(room));
QCOMPARE(eventHandlerReplyAuthor["color"_ls], Utils::getUserColor(replyAuthor->hueF()));
QCOMPARE(eventHandlerReplyAuthor["object"_ls], QVariant::fromValue(replyAuthor));
event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getReplyAuthor(), room->getUser(nullptr));
}
void EventHandlerTest::replyBody()
{
auto event = room->messageEvents().at(5).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getReplyRichBody(), QStringLiteral("<b>This is an example<br>text message</b>"));
QCOMPARE(eventHandler.getReplyRichBody(true), QStringLiteral("<b>This is an example text message</b>"));
QCOMPARE(eventHandler.getReplyPlainBody(), QStringLiteral("This is an example\ntext message"));
QCOMPARE(eventHandler.getReplyPlainBody(true), QStringLiteral("This is an example text message"));
}
void EventHandlerTest::replyMediaInfo()
{
auto event = room->messageEvents().at(6).get();
auto replyEvent = room->messageEvents().at(4).get();
eventHandler.setEvent(event);
auto mediaInfo = eventHandler.getReplyMediaInfo();
auto thumbnailInfo = mediaInfo["tempInfo"_ls].toMap();
QCOMPARE(mediaInfo["source"_ls], room->makeMediaUrl(replyEvent->id(), QUrl("mxc://kde.org/1234567"_ls)));
QCOMPARE(mediaInfo["mimeType"_ls], QStringLiteral("video/mp4"));
QCOMPARE(mediaInfo["mimeIcon"_ls], QStringLiteral("video-mp4"));
QCOMPARE(mediaInfo["size"_ls], 62650636);
QCOMPARE(mediaInfo["duration"_ls], 10);
QCOMPARE(mediaInfo["width"_ls], 1920);
QCOMPARE(mediaInfo["height"_ls], 1080);
QCOMPARE(thumbnailInfo["source"_ls], room->makeMediaUrl(replyEvent->id(), QUrl("mxc://kde.org/2234567"_ls)));
QCOMPARE(thumbnailInfo["mimeType"_ls], QStringLiteral("image/jpeg"));
QCOMPARE(thumbnailInfo["mimeIcon"_ls], QStringLiteral("image-jpeg"));
QCOMPARE(thumbnailInfo["size"_ls], 382249);
QCOMPARE(thumbnailInfo["width"_ls], 800);
QCOMPARE(thumbnailInfo["height"_ls], 450);
}
void EventHandlerTest::location()
{
auto event = room->messageEvents().at(7).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.getLatitude(), QStringLiteral("51.7035").toFloat());
QCOMPARE(eventHandler.getLongitude(), QStringLiteral("-1.14394").toFloat());
QCOMPARE(eventHandler.getLocationAssetType(), QStringLiteral("m.pin"));
}
void EventHandlerTest::readMarkers()
{
auto event = room->messageEvents().at(0).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.hasReadMarkers(), true);
auto readMarkers = eventHandler.getReadMarkers();
QCOMPARE(readMarkers.size(), 1);
QCOMPARE(readMarkers[0].toMap()["id"_ls], QStringLiteral("@alice:matrix.org"));
QCOMPARE(eventHandler.getNumberExcessReadMarkers(), QString());
QCOMPARE(eventHandler.getReadMarkersString(), QStringLiteral("1 user: @alice:matrix.org"));
event = room->messageEvents().at(2).get();
eventHandler.setEvent(event);
QCOMPARE(eventHandler.hasReadMarkers(), true);
readMarkers = eventHandler.getReadMarkers();
QCOMPARE(readMarkers.size(), 5);
QCOMPARE(eventHandler.getNumberExcessReadMarkers(), QStringLiteral("+ 1"));
// There are no guarantees on the order of the users it will be different every time so don't match the whole string.
QCOMPARE(eventHandler.getReadMarkersString().startsWith(QStringLiteral("6 users:")), true);
}
QTEST_MAIN(EventHandlerTest)
#include "eventhandlertest.moc"

View File

@@ -1,72 +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
#include <QObject>
#include <QTest>
#include <qglobal.h>
#include <qtestcase.h>
#include <cmath>
#include "mediasizehelper.h"
#include "neochatconfig.h"
class MediaSizeHelperTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void uninitialized();
void limits_data();
void limits();
};
void MediaSizeHelperTest::uninitialized()
{
MediaSizeHelper mediasizehelper;
QCOMPARE(mediasizehelper.currentSize(), QSize(540, qRound(qreal(NeoChatConfig::self()->mediaMaxWidth()) / qreal(16.0) * qreal(9.0))));
}
void MediaSizeHelperTest::limits_data()
{
QTest::addColumn<qreal>("mediaWidth");
QTest::addColumn<qreal>("mediaHeight");
QTest::addColumn<qreal>("contentMaxWidth");
QTest::addColumn<qreal>("contentMaxHeight");
QTest::addColumn<QSize>("currentSize");
QTest::newRow("media smaller than content limits") << qreal(200) << qreal(150) << qreal(400) << qreal(900) << QSize(200, 150);
QTest::newRow("media smaller than max limits") << qreal(200) << qreal(150) << qreal(-1) << qreal(-1) << QSize(200, 150);
QTest::newRow("limit by max width") << qreal(600) << qreal(50) << qreal(-1) << qreal(-1) << QSize(540, qRound(qreal(540) / (qreal(600) / qreal(50))));
QTest::newRow("limit by max height") << qreal(50) << qreal(600) << qreal(-1) << qreal(-1) << QSize(qRound(qreal(540) * (qreal(50) / qreal(600))), 540);
QTest::newRow("limit by content width") << qreal(600) << qreal(50) << qreal(300) << qreal(-1) << QSize(300, qRound(qreal(300) / (qreal(600) / qreal(50))));
QTest::newRow("limit by content height") << qreal(50) << qreal(600) << qreal(-1) << qreal(300) << QSize(qRound(qreal(300) * (qreal(50) / qreal(600))), 300);
QTest::newRow("limit by content width tall media")
<< qreal(400) << qreal(600) << qreal(100) << qreal(400) << QSize(100, qRound(qreal(100) / (qreal(400) / qreal(600))));
QTest::newRow("limit by content height wide media")
<< qreal(1000) << qreal(600) << qreal(400) << qreal(100) << QSize(qRound(qreal(100) * (qreal(1000) / qreal(600))), 100);
}
void MediaSizeHelperTest::limits()
{
QFETCH(qreal, mediaWidth);
QFETCH(qreal, mediaHeight);
QFETCH(qreal, contentMaxWidth);
QFETCH(qreal, contentMaxHeight);
QFETCH(QSize, currentSize);
MediaSizeHelper mediasizehelper;
mediasizehelper.setMediaWidth(mediaWidth);
mediasizehelper.setMediaHeight(mediaHeight);
mediasizehelper.setContentMaxWidth(contentMaxWidth);
mediasizehelper.setContentMaxHeight(contentMaxHeight);
QCOMPARE(mediasizehelper.currentSize(), currentSize);
}
QTEST_GUILESS_MAIN(MediaSizeHelperTest)
#include "mediasizehelpertest.moc"

View File

@@ -1,148 +0,0 @@
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QObject>
#include <QSignalSpy>
#include <QTest>
#include "neochatroom.h"
#include <Quotient/connection.h>
#include <Quotient/quotient_common.h>
#include <Quotient/syncdata.h>
using namespace Quotient;
class TestRoom : public NeoChatRoom
{
public:
using NeoChatRoom::NeoChatRoom;
void update(SyncRoomData &&data, bool fromCache = false)
{
Room::updateData(std::move(data), fromCache);
}
};
class NeoChatRoomTest : public QObject {
Q_OBJECT
private:
Connection *connection = nullptr;
TestRoom *room = nullptr;
private Q_SLOTS:
void initTestCase();
void subtitleTextTest();
void eventTest();
};
void NeoChatRoomTest::initTestCase()
{
connection = Connection::makeMockConnection(QStringLiteral("@bob:kde.org"));
room = new TestRoom(connection, QStringLiteral("#myroom:kde.org"), JoinState::Join);
auto json = QJsonDocument::fromJson(R"EVENT({
"account_data": {
"events": [
{
"content": {
"tags": {
"u.work": {
"order": 0.9
}
}
},
"type": "m.tag"
},
{
"content": {
"custom_config_key": "custom_config_value"
},
"type": "org.example.custom.room.config"
}
]
},
"ephemeral": {
"events": [
{
"content": {
"user_ids": [
"@alice:matrix.org",
"@bob:example.com"
]
},
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"type": "m.typing"
}
]
},
"state": {
"events": [
{
"content": {
"avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF",
"displayname": "Alice Margatroid",
"membership": "join",
"reason": "Looking for support"
},
"event_id": "$143273582443PhrSn:example.org",
"origin_server_ts": 1432735824653,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"state_key": "@alice:example.org",
"type": "m.room.member",
"unsigned": {
"age": 1234
}
}
]
},
"summary": {
"m.heroes": [
"@alice:example.com",
"@bob:example.com"
],
"m.invited_member_count": 0,
"m.joined_member_count": 2
},
"timeline": {
"events": [
{
"content": {
"body": "This is an **example** text message",
"format": "org.matrix.custom.html",
"formatted_body": "<b>This is an example text message</b>",
"msgtype": "m.text"
},
"event_id": "$143273582443PhrSn:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1235
}
}
],
"limited": true,
"prev_batch": "t34-23535_0_0"
}
})EVENT");
SyncRoomData roomData(QStringLiteral("@bob:kde.org"), JoinState::Join, json.object());
room->update(std::move(roomData));
}
void NeoChatRoomTest::subtitleTextTest()
{
QCOMPARE(room->timelineSize(), 1);
QCOMPARE(room->lastEventToString(), QStringLiteral("@example:example.org: This is an example text message"));
}
void NeoChatRoomTest::eventTest()
{
QCOMPARE(room->timelineSize(), 1);
}
QTEST_GUILESS_MAIN(NeoChatRoomTest)
#include "neochatroomtest.moc"

View File

@@ -1,651 +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
#include <QObject>
#include <QTest>
#include "texthandler.h"
#include <Quotient/quotient_common.h>
#include <Quotient/syncdata.h>
#include <qnamespace.h>
#include "utils.h"
using namespace Quotient;
class TestRoom : public NeoChatRoom
{
public:
using NeoChatRoom::NeoChatRoom;
void update(SyncRoomData &&data, bool fromCache = false)
{
Room::updateData(std::move(data), fromCache);
}
};
class TextHandlerTest : public QObject
{
Q_OBJECT
private:
Connection *connection = nullptr;
TestRoom *room = nullptr;
private Q_SLOTS:
void initTestCase();
void allowedAttributes();
void stripDisallowedTags();
void stripDisallowedAttributes();
void emptyCodeTags();
void sendSimpleStringCase();
void sendSingleParaMarkup();
void sendMultipleSectionMarkup();
void sendBadLinks();
void sendEscapeCode();
void sendCodeClass();
void receiveStripReply();
void receivePlainTextIn();
void receiveRichInPlainOut_data();
void receiveRichInPlainOut();
void receivePlainStripHtml();
void receivePlainStripMarkup();
void receiveStripNewlines();
void receiveRichUserPill();
void receiveRichStrikethrough();
void receiveRichtextIn();
void receiveRichMxcUrl();
void receiveRichPlainUrl();
void receiveRichEmote();
void receiveRichEdited_data();
void receiveRichEdited();
void receiveLineSeparator();
void linkPreviewsMatch_data();
void linkPreviewsMatch();
void linkPreviewsReject_data();
void linkPreviewsReject();
};
void TextHandlerTest::initTestCase()
{
connection = Connection::makeMockConnection(QStringLiteral("@bob:kde.org"));
room = new TestRoom(connection, QStringLiteral("#myroom:kde.org"), JoinState::Join);
const auto json = QJsonDocument::fromJson(R"EVENT({
"account_data": {
"events": [
{
"content": {
"tags": {
"u.work": {
"order": 0.9
}
}
},
"type": "m.tag"
},
{
"content": {
"custom_config_key": "custom_config_value"
},
"type": "org.example.custom.room.config"
}
]
},
"ephemeral": {
"events": [
{
"content": {
"user_ids": [
"@alice:matrix.org",
"@bob:example.com"
]
},
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"type": "m.typing"
}
]
},
"state": {
"events": [
{
"content": {
"avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF",
"displayname": "Alice Margatroid",
"membership": "join",
"reason": "Looking for support"
},
"event_id": "$143273582443PhrSn:example.org",
"origin_server_ts": 1432735824653,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"state_key": "@alice:example.org",
"type": "m.room.member",
"unsigned": {
"age": 1234
}
}
]
},
"summary": {
"m.heroes": [
"@alice:example.com",
"@bob:example.com"
],
"m.invited_member_count": 0,
"m.joined_member_count": 2
},
"timeline": {
"events": [
{
"content": {
"body": "This is an **example** text message",
"format": "org.matrix.custom.html",
"formatted_body": "<b>This is an example text message</b>",
"msgtype": "m.text"
},
"event_id": "$143273582443PhrSn:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1232
}
},
{
"content": {
"body": "/me This is an emote.",
"format": "org.matrix.custom.html",
"formatted_body": "This is an emote.",
"msgtype": "m.emote"
},
"event_id": "$153273582443PhrSn:example.org",
"origin_server_ts": 1532735824654,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1231
}
},
{
"content": {
"body": "tested",
"msgtype": "m.text"
},
"event_id": "$zrCiBxBnqqTn0Z5FY78qSZAszno_w8nJJXzfBULG-3E",
"origin_server_ts": 1680948575928,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1747776,
"m.relations": {
"m.replace": {
"event_id": "$UX0PlpyI7vYO32iHMuuYEP7ECMh4sX3XLGiB2SwM4mQ",
"origin_server_ts": 1680948580992,
"sender": "@example:example.org"
}
}
}
}
],
"limited": true,
"prev_batch": "t34-23535_0_0"
}
})EVENT");
SyncRoomData roomData(QStringLiteral("@bob:kde.org"), JoinState::Join, json.object());
room->update(std::move(roomData));
}
void TextHandlerTest::allowedAttributes()
{
const QString testInputString1 = QStringLiteral("<p><span data-mx-spoiler><font color=#FFFFFF>Test</font><span></p>");
const QString testOutputString1 = QStringLiteral("<p><span data-mx-spoiler><font color=#FFFFFF>Test</font><span></p>");
// Handle urls where the href has either single (') or double (") quotes.
const QString testInputString2 = QStringLiteral("<p><a href=\"https://kde.org\">link</a><a href='https://kde.org'>link</a></p>");
const QString testOutputString2 = QStringLiteral("<p><a href=\"https://kde.org\">link</a><a href='https://kde.org'>link</a></p>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString1);
QCOMPARE(testTextHandler.handleSendText(), testOutputString1);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString1);
testTextHandler.setData(testInputString2);
QCOMPARE(testTextHandler.handleSendText(), testOutputString2);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString2);
}
void TextHandlerTest::stripDisallowedTags()
{
const QString testInputString = QStringLiteral("<p>Allowed</p> <span>Allowed</span> <body>Disallowed</body>");
const QString testOutputString = QStringLiteral("<p>Allowed</p> <span>Allowed</span> Disallowed");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
}
void TextHandlerTest::stripDisallowedAttributes()
{
const QString testInputString = QStringLiteral("<p style=\"font-size:50px;\" color=#FFFFFF>Test</p>");
const QString testOutputString = QStringLiteral("<p>Test</p>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
}
/**
* Make sure that empty code tags are handled.
* (this was a bug during development hence the test)
*/
void TextHandlerTest::emptyCodeTags()
{
const QString testInputString = QStringLiteral("<pre><code></code></pre>");
const QString testOutputString = QStringLiteral("<pre><code></code></pre>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
}
void TextHandlerTest::sendSimpleStringCase()
{
const QString testInputString = QStringLiteral("This data should just be put in a paragraph.");
const QString testOutputString = QStringLiteral("<p>This data should just be put in a paragraph.</p>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
}
void TextHandlerTest::sendSingleParaMarkup()
{
const QString testInputString = QStringLiteral(
"Text para with **bold**, *italic*, [link](https://kde.org), ![image](mxc://kde.org/aebd3ffd40503e1ef0525bf8f0d60282fec6183e), `inline code`.");
const QString testOutputString = QStringLiteral(
"<p>Text para with <strong>bold</strong>, <em>italic</em>, <a href=\"https://kde.org\">link</a>, <img "
"src=\"mxc://kde.org/aebd3ffd40503e1ef0525bf8f0d60282fec6183e\" alt=\"image\">, <code>inline code</code>.</p>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
}
void TextHandlerTest::sendMultipleSectionMarkup()
{
const QString testInputString =
QStringLiteral("Text para\n> blockquote\n* List 1\n* List 2\n1. one\n2. two\n# Heading 1\n## Heading 2\nhorizontal rule\n\n---\n```\ncodeblock\n```");
const QString testOutputString = QStringLiteral(
"<p>Text para</p>\n<blockquote>\n<p>blockquote</p>\n</blockquote>\n<ul>\n<li>List 1</li>\n<li>List "
"2</li>\n</ul>\n<ol>\n<li>one</li>\n<li>two</li>\n</ol>\n<h1>Heading 1</h1>\n<h2>Heading 2</h2>\n<p>horizontal "
"rule</p>\n<hr>\n<pre><code>codeblock\n</code></pre>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
}
void TextHandlerTest::sendBadLinks()
{
const QString testInputString = QStringLiteral("[link](kde.org), ![image](https://kde.org/aebd3ffd40503e1ef0525bf8f0d60282fec6183e)");
const QString testOutputString = QStringLiteral("<p><a>link</a>, <img alt=\"image\"></p>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
}
/**
* All text between code tags is treated as plain so it should get escaped.
*/
void TextHandlerTest::sendEscapeCode()
{
const QString testInputString = QStringLiteral("```\n<p>Test <span style=\"font-size:50px;\">some</span> code</p>\n```");
const QString testOutputString =
QStringLiteral("<pre><code>&lt;p&gt;Test &lt;span style=&quot;font-size:50px;&quot;&gt;some&lt;/span&gt; code&lt;/p&gt;\n</code></pre>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
}
void TextHandlerTest::sendCodeClass()
{
const QString testInputString = QStringLiteral("```html\nsome code\n```\n<pre><code class=\"code-underline\">some more code</code></pre>");
const QString testOutputString = QStringLiteral("<pre><code class=\"language-html\">some code\n</code></pre>\n<pre><code>some more code</code></pre>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleSendText(), testOutputString);
}
void TextHandlerTest::receiveStripReply()
{
const QString testInputString = QStringLiteral(
"<mx-reply><blockquote><a href=\"https://matrix.to/#/!somewhere:example.org/$event:example.org\">In reply to</a><a "
"href=\"https://matrix.to/#/@alice:example.org\">@alice:example.org</a><br />Message replied to.</blockquote></mx-reply>Reply message.");
const QString testOutputString = QStringLiteral("Reply message.");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
QCOMPARE(testTextHandler.handleRecievePlainText(), testOutputString);
}
void TextHandlerTest::receiveRichInPlainOut_data()
{
QTest::addColumn<QString>("testInputString");
QTest::addColumn<QString>("testOutputString");
QTest::newRow("ampersand") << QStringLiteral("a &amp; b") << QStringLiteral("a & b");
QTest::newRow("quote") << QStringLiteral("&quot;a and b&quot;") << QStringLiteral("\"a and b\"");
QTest::newRow("new line") << QStringLiteral("new<br>line") << QStringLiteral("new\nline");
}
void TextHandlerTest::receiveRichInPlainOut()
{
QFETCH(QString, testInputString);
QFETCH(QString, testOutputString);
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecievePlainText(Qt::RichText), testOutputString);
}
void TextHandlerTest::receivePlainTextIn()
{
const QString testInputString = QStringLiteral("<plain text in tag bracket>\nTest link https://kde.org.");
const QString testOutputStringRich = QStringLiteral("&lt;plain text in tag bracket&gt;<br>Test link <a href=\"https://kde.org\">https://kde.org</a>.");
QString testOutputStringPlain = QStringLiteral("<plain text in tag bracket>\nTest link https://kde.org.");
// Make sure quotes are maintained in a plain string.
const QString testInputString2 = QStringLiteral("last line is \"Time to switch to a new topic.\"");
const QString testOutputString2 = QStringLiteral("last line is \"Time to switch to a new topic.\"");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::PlainText), testOutputStringRich);
QCOMPARE(testTextHandler.handleRecievePlainText(), testOutputStringPlain);
testTextHandler.setData(testInputString2);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::PlainText), testOutputString2);
QCOMPARE(testTextHandler.handleRecievePlainText(), testOutputString2);
}
void TextHandlerTest::receiveStripNewlines()
{
const QString testInputStringPlain = QStringLiteral("Test\nmany\nnew\nlines.");
const QString testInputStringRich = QStringLiteral("Test<br>many<br />new<br>lines.");
const QString testOutputString = QStringLiteral("Test many new lines.");
const QString testInputStringPlain2 = QStringLiteral("* List\n* Items");
const QString testOutputString2 = QStringLiteral("List Items");
TextHandler testTextHandler;
testTextHandler.setData(testInputStringPlain);
QCOMPARE(testTextHandler.handleRecievePlainText(Qt::PlainText, true), testOutputString);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::PlainText, nullptr, nullptr, true), testOutputString);
testTextHandler.setData(testInputStringRich);
QCOMPARE(testTextHandler.handleRecievePlainText(Qt::RichText, true), testOutputString);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, nullptr, nullptr, true), testOutputString);
testTextHandler.setData(testInputStringPlain2);
QCOMPARE(testTextHandler.handleRecievePlainText(Qt::RichText, true), testOutputString2);
}
/**
* For a plain text output of a received string all html is stripped except for
* code which is unescaped if it's html.
*/
void TextHandlerTest::receivePlainStripHtml()
{
const QString testInputString = QStringLiteral("<p>Test</p> <pre><code>Some code <strong>with tags</strong></code></pre>");
const QString testOutputString = QStringLiteral("Test Some code <strong>with tags</strong>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecievePlainText(Qt::RichText), testOutputString);
}
void TextHandlerTest::receivePlainStripMarkup()
{
const QString testInputString = QStringLiteral("**bold** `<p>inline code</p>` *italic*");
const QString testOutputString = QStringLiteral("bold <p>inline code</p> italic");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecievePlainText(), testOutputString);
}
void TextHandlerTest::receiveRichUserPill()
{
const QString testInputString = QStringLiteral("<p><a href=\"https://matrix.to/#/@alice:example.org\">@alice:example.org</a></p>");
const QString testOutputString = QStringLiteral("<p><b><a href=\"https://matrix.to/#/@alice:example.org\">@alice:example.org</a></b></p>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
}
void TextHandlerTest::receiveRichStrikethrough()
{
const QString testInputString = QStringLiteral("<p><del>Test</del></p>");
const QString testOutputString = QStringLiteral("<p><s>Test</s></p>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
}
void TextHandlerTest::receiveRichtextIn()
{
const QString testInputString = QStringLiteral("<p>Test</p> <pre><code>Some code <strong>with tags</strong></code></pre>");
const QString testOutputString = QStringLiteral("<p>Test</p> <pre><code>Some code &lt;strong&gt;with tags&lt;/strong&gt;</code></pre>");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
}
void TextHandlerTest::receiveRichMxcUrl()
{
const QString testInputString = QStringLiteral(
"<img src=\"mxc://kde.org/aebd3ffd40503e1ef0525bf8f0d60282fec6183e\" alt=\"image\"><img src=\"mxc://kde.org/34c3464b3a1bd7f55af2d559e07d2c773c430e73\" "
"alt=\"image\">");
const QString testOutputString = QStringLiteral(
"<img "
"src=\"mxc://kde.org/aebd3ffd40503e1ef0525bf8f0d60282fec6183e?user_id=@bob:kde.org&room_id=%23myroom:kde.org&event_id=$143273582443PhrSn:example.org\" "
"alt=\"image\"><img "
"src=\"mxc://kde.org/34c3464b3a1bd7f55af2d559e07d2c773c430e73?user_id=@bob:kde.org&room_id=%23myroom:kde.org&event_id=$143273582443PhrSn:example.org\" "
"alt=\"image\">");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, room->messageEvents().at(0).get()), testOutputString);
}
/**
* For when your rich input string has a plain text url left in.
*
* This test is to show that a url that is already rich will be left alone but a
* plain one will be linkified.
*/
void TextHandlerTest::receiveRichPlainUrl()
{
// This is an actual link that caused trouble which is why it's so long. Keeping
// so we can confirm consistent behaviour for complex urls.
const QString testInputStringLink1 = QStringLiteral(
"https://matrix.to/#/!RvzunyTWZGfNxJVQqv:matrix.org/$-9TJVTh5PvW6MvIhFDwteiyLBVGriinueO5eeIazQS8?via=libera.chat&amp;via=matrix.org&amp;via=fedora.im "
"<a "
"href=\"https://matrix.to/#/!RvzunyTWZGfNxJVQqv:matrix.org/"
"$-9TJVTh5PvW6MvIhFDwteiyLBVGriinueO5eeIazQS8?via=libera.chat&amp;via=matrix.org&amp;via=fedora.im\">Link already rich</a>");
const QString testOutputStringLink1 = QStringLiteral(
"<a "
"href=\"https://matrix.to/#/!RvzunyTWZGfNxJVQqv:matrix.org/"
"$-9TJVTh5PvW6MvIhFDwteiyLBVGriinueO5eeIazQS8?via=libera.chat&amp;via=matrix.org&amp;via=fedora.im\">https://matrix.to/#/"
"!RvzunyTWZGfNxJVQqv:matrix.org/$-9TJVTh5PvW6MvIhFDwteiyLBVGriinueO5eeIazQS8?via=libera.chat&amp;via=matrix.org&amp;via=fedora.im</a> <a "
"href=\"https://matrix.to/#/!RvzunyTWZGfNxJVQqv:matrix.org/"
"$-9TJVTh5PvW6MvIhFDwteiyLBVGriinueO5eeIazQS8?via=libera.chat&amp;via=matrix.org&amp;via=fedora.im\">Link already rich</a>");
// Another real case. The linkification wasn't handling it when a single link
// contains what looks like and email. It was been broken into 3 but needs to
// be just single link.
const QString testInputStringLink2 = QStringLiteral("https://lore.kernel.org/lkml/CAHk-=wio46vC4t6xXD-sFqjoPwFm_u515jm3suzmkGxQTeA1_A@mail.gmail.com/");
const QString testOutputStringLink2 = QStringLiteral(
"<a "
"href=\"https://lore.kernel.org/lkml/CAHk-=wio46vC4t6xXD-sFqjoPwFm_u515jm3suzmkGxQTeA1_A@mail.gmail.com/\">https://lore.kernel.org/lkml/"
"CAHk-=wio46vC4t6xXD-sFqjoPwFm_u515jm3suzmkGxQTeA1_A@mail.gmail.com/</a>");
QString testInputStringEmail = QStringLiteral(R"(email@example.com <a href="mailto:email@example.com">Link already rich</a>)");
QString testOutputStringEmail =
QStringLiteral(R"(<a href="mailto:email@example.com">email@example.com</a> <a href="mailto:email@example.com">Link already rich</a>)");
QString testInputStringMxId = QStringLiteral("@user:kde.org <a href=\"https://matrix.to/#/@user:kde.org\">Link already rich</a>");
QString testOutputStringMxId = QStringLiteral(
"<b><a href=\"https://matrix.to/#/@user:kde.org\">@user:kde.org</a></b> <b><a href=\"https://matrix.to/#/@user:kde.org\">Link already rich</a></b>");
TextHandler testTextHandler;
testTextHandler.setData(testInputStringLink1);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringLink1);
testTextHandler.setData(testInputStringLink2);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringLink2);
testTextHandler.setData(testInputStringEmail);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringEmail);
testTextHandler.setData(testInputStringMxId);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringMxId);
}
// Test that user pill is add to an emote message.
// N.B. The second message in the test timeline is marked as an emote.
void TextHandlerTest::receiveRichEmote()
{
auto event = room->messageEvents().at(1).get();
auto author = room->user(event->senderId());
const QString testInputString = QStringLiteral("This is an emote.");
const QString testOutputString = QStringLiteral("* <a href=\"https://matrix.to/#/@example:example.org\" style=\"color:")
+ Utils::getUserColor(author->hueF()).name() + QStringLiteral("\">@example:example.org</a> This is an emote.");
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, event), testOutputString);
}
void TextHandlerTest::receiveRichEdited_data()
{
QTest::addColumn<QString>("testInputString");
QTest::addColumn<QString>("testOutputString");
QTest::newRow("basic") << QStringLiteral("Edited") << QStringLiteral("Edited <span style=\"color:#000000\">(edited)</span>");
QTest::newRow("multiple paragraphs") << QStringLiteral("<p>Edited</p>\n<p>Edited</p>")
<< QStringLiteral("<p>Edited</p>\n<p>Edited <span style=\"color:#000000\">(edited)</span></p>");
QTest::newRow("blockquote") << QStringLiteral("<blockquote>Edited</blockquote>")
<< QStringLiteral("<blockquote>Edited</blockquote><p> <span style=\"color:#000000\">(edited)</span></p>");
}
void TextHandlerTest::receiveRichEdited()
{
QFETCH(QString, testInputString);
QFETCH(QString, testOutputString);
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, room->messageEvents().at(2).get()), testOutputString);
}
void TextHandlerTest::receiveLineSeparator()
{
auto text = QStringLiteral("foo\u2028bar");
TextHandler textHandler;
textHandler.setData(text);
QCOMPARE(textHandler.handleRecievePlainText(Qt::PlainText, true), QStringLiteral("foo bar"));
}
void TextHandlerTest::linkPreviewsMatch_data()
{
QTest::addColumn<QString>("testInputString");
QTest::addColumn<QList<QUrl>>("testOutputLinks");
QTest::newRow("plainHttps") << QStringLiteral("https://kde.org") << QList<QUrl>({QUrl("https://kde.org"_ls)});
QTest::newRow("richHttps") << QStringLiteral("<a href=\"https://kde.org\">Rich Link</a>") << QList<QUrl>({QUrl("https://kde.org"_ls)});
QTest::newRow("plainWww") << QStringLiteral("www.example.org") << QList<QUrl>({QUrl("www.example.org"_ls)});
QTest::newRow("multipleHttps") << QStringLiteral("https://kde.org www.example.org")
<< QList<QUrl>({
QUrl("https://kde.org"_ls),
QUrl("www.example.org"_ls),
});
}
void TextHandlerTest::linkPreviewsMatch()
{
QFETCH(QString, testInputString);
QFETCH(QList<QUrl>, testOutputLinks);
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.getLinkPreviews(), testOutputLinks);
}
void TextHandlerTest::linkPreviewsReject_data()
{
QTest::addColumn<QString>("testInputString");
QTest::addColumn<QList<QUrl>>("testOutputLinks");
QTest::newRow("mxc") << QStringLiteral("mxc://example.org/SEsfnsuifSDFSSEF") << QList<QUrl>();
QTest::newRow("matrixTo") << QStringLiteral("https://matrix.to/#/@alice:example.org") << QList<QUrl>();
QTest::newRow("noSpace") << QStringLiteral("testhttps://kde.org") << QList<QUrl>();
}
void TextHandlerTest::linkPreviewsReject()
{
QFETCH(QString, testInputString);
QFETCH(QList<QUrl>, testOutputLinks);
TextHandler testTextHandler;
testTextHandler.setData(testInputString);
QCOMPARE(testTextHandler.getLinkPreviews(), testOutputLinks);
}
QTEST_MAIN(TextHandlerTest)
#include "texthandlertest.moc"

View File

@@ -1,5 +0,0 @@
# SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
#
# SPDX-License-Identifier: GPL-3.0-or-later
kdoctools_create_manpage(man-neochat.1.docbook 1 INSTALL_DESTINATION ${MAN_INSTALL_DIR})

View File

@@ -1,85 +0,0 @@
<?xml version="1.0" ?>
<!DOCTYPE refentry PUBLIC "-//KDE//DTD DocBook XML V4.5-Based Variant V1.1//EN" "dtd/kdedbx45.dtd" [
<!ENTITY % English "INCLUDE">
]>
<!--
SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
SPDX-License-Identifier: CC-BY-SA-4.0
-->
<refentry lang="&language;">
<refentryinfo>
<title>NeoChat User's Manual</title>
<author><firstname>Carl</firstname><surname>Schwan</surname>
<contrib>NeoChat man page.</contrib>
<email>carl@carlschwan.eu</email></author>
<date>2022-11-01</date>
<releaseinfo>22.09</releaseinfo>
<productname>NeoChat</productname>
</refentryinfo>
<refmeta>
<refentrytitle>
<command>neochat</command>
</refentrytitle>
<manvolnum>1</manvolnum>
</refmeta>
<refnamediv>
<refname>neochat</refname>
<refpurpose>Client for interacting with the matrix messaging protocol</refpurpose>
</refnamediv>
<!-- body begins here -->
<refsynopsisdiv id='synopsis'>
<cmdsynopsis>
<command>neochat</command>
<arg choice="opt"><replaceable>URI</replaceable></arg>
</cmdsynopsis>
</refsynopsisdiv>
<refsect1 id="description">
<title>Description</title>
<para>
<command>neochat</command> is a chat application for the matrix protocol
that work on both desktop and mobile.
</para>
</refsect1>
<refsect1 id="options"><title>Options</title>
<variablelist>
<varlistentry>
<term><option>URI</option></term>
<listitem>
<para>
The matrix uri for an user or a room. e.g matrix:u/user:example.org and
matrix:r/root:example.org. This will makes NeoChat try to open the given
room or conversation.
</para>
</listitem>
</varlistentry>
</variablelist>
</refsect1>
<refsect1 id="bug">
<title>Reporting bugs</title>
<para>You can report bugs and feature requests at <ulink url="https://bugs.kde.org/enter_bug.cgi?product=NeoChat&amp;component=General">https://bugs.kde.org/enter_bug.cgi?product=NeoChat&amp;component=General</ulink></para>
</refsect1>
<refsect1>
<title>See Also</title>
<simplelist>
<member>
A list of frequently asked questions about Matrix <ulink url="https://matrix.org/faq/">https://matrix.org/faq/</ulink>
</member>
<member>kf5options(7)</member>
<member>qt5options(7)</member>
</simplelist>
</refsect1>
<refsect1 id="copyright"><title>Copyright</title>
<para>Copyright &copy; 2020-2022 Tobias Fella </para>
<para>Copyright &copy; 2020-2022 Carl Schwan </para>
<para>License: GNU General Public Version 3 or later &lt;<ulink url="https://www.gnu.org/licenses/gpl-3.0.html">https://www.gnu.org/licenses/gpl-3.0.html</ulink>&gt;</para>
</refsect1>
</refentry>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,197 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Page 1.0
Loader {
id: root
property var attachmentMimetype: FileType.mimeTypeForUrl(chatBoxHelper.attachmentPath)
readonly property bool hasImage: attachmentMimetype.valid && FileType.supportedImageFormats.includes(attachmentMimetype.preferredSuffix)
active: visible
sourceComponent: Component {
Pane {
id: attachmentPane
property string baseFileName: chatBoxHelper.attachmentPath.toString().substring(chatBoxHelper.attachmentPath.toString().lastIndexOf('/') + 1, chatBoxHelper.attachmentPath.length)
Kirigami.Theme.colorSet: Kirigami.Theme.View
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
)
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 ? chatBoxHelper.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
}
}
}
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
}
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
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
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
}
Button {
id: editImageButton
visible: hasImage
icon.name: "document-edit"
text: i18n("Edit")
display: AbstractButton.IconOnly
Component {
id: imageEditorPage
ImageEditorPage {
imagePath: chatBoxHelper.attachmentPath
}
}
onClicked: {
let imageEditor = applicationWindow().pageStack.layers.push(imageEditorPage);
imageEditor.newPathChanged.connect(function(newPath) {
applicationWindow().pageStack.layers.pop();
chatBoxHelper.attachmentPath = newPath;
});
}
ToolTip.text: text
ToolTip.visible: hovered
}
Button {
id: cancelAttachmentButton
icon.name: "dialog-cancel"
text: i18n("Cancel")
display: AbstractButton.IconOnly
onClicked: chatBoxHelper.clearAttachment();
ToolTip.text: text
ToolTip.visible: hovered
}
}
background: null
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
}
}
}

View File

@@ -0,0 +1,422 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import QtQuick.Templates 2.15 as T
import Qt.labs.platform 1.1 as Platform
import QtQuick.Window 2.15
import org.kde.kirigami 2.18 as Kirigami
import org.kde.neochat 1.0
ToolBar {
id: chatBar
property string replyEventId: ""
property string editEventId: ""
property alias inputFieldText: inputField.text
property alias textField: inputField
property alias emojiPaneOpened: emojiButton.checked
// store each user we autoComplete here, this will be helpful later to generate
// the matrix.to links.
// This use an hack to define: https://doc.qt.io/qt-5/qml-var.html#property-value-initialization-semantics
property var userAutocompleted: ({})
signal closeAllTriggered()
signal inputFieldForceActiveFocusTriggered()
signal messageSent()
signal pasteImageTriggered()
signal editLastUserMessage()
signal replyPreviousUserMessage()
property alias isCompleting: completionMenu.visible
onInputFieldForceActiveFocusTriggered: {
inputField.forceActiveFocus();
// set the cursor to the end of the text
inputField.cursorPosition = inputField.length;
}
position: ToolBar.Footer
Kirigami.Theme.colorSet: Kirigami.Theme.View
// 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
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)
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
FontMetrics {
id: fontMetrics
font: inputField.font
}
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
}
leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing
rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing
topPadding: 0
bottomPadding: 0
property real progress: 0
property bool autoAppeared: false
//property int lineHeight: contentHeight / lineCount
text: inputFieldText
placeholderText: readOnly ? i18n("This room is encrypted. Sending encrypted messages is not yet supported.") : editEventId.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
Kirigami.SpellChecking.enabled: true
color: Kirigami.Theme.textColor
selectionColor: Kirigami.Theme.highlightColor
selectedTextColor: Kirigami.Theme.highlightedTextColor
hoverEnabled: !Kirigami.Settings.tabletMode
selectByMouse: !Kirigami.Settings.tabletMode
ChatDocumentHandler {
id: documentHandler
document: inputField.textDocument
cursorPosition: inputField.cursorPosition
selectionStart: inputField.selectionStart
selectionEnd: inputField.selectionEnd
room: currentRoom ?? null
}
Timer {
id: repeatTimer
interval: 5000
}
function sendMessage(event) {
if (isCompleting && completionMenu.count > 0) {
chatBar.complete();
} else if (event.modifiers & Qt.ShiftModifier) {
inputField.insert(cursorPosition, "\n")
} else {
currentRoom.sendTypingNotification(false)
chatBar.postMessage()
}
isCompleting = false;
}
Keys.onReturnPressed: { sendMessage(event) }
Keys.onEnterPressed: { sendMessage(event) }
Keys.onEscapePressed: {
closeAllTriggered()
}
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) {
replyPreviousUserMessage();
} else if (event.key === Qt.Key_Up && inputField.text.length === 0) {
editLastUserMessage();
}
}
Keys.onBacktabPressed: {
if (event.modifiers & Qt.ControlModifier) {
switchRoomUp();
return;
}
if (!isCompleting) {
nextItemInFocusChain(false).forceActiveFocus(Qt.TabFocusReason)
return
}
if (!autoAppeared) {
let decrementedIndex = completionMenu.currentIndex - 1
// Wrap around to the last item
if (decrementedIndex < 0) {
decrementedIndex = Math.max(completionMenu.count - 1, 0) // 0 if count == 0
}
completionMenu.currentIndex = decrementedIndex
} else {
autoAppeared = false;
}
chatBar.complete();
}
// yes, decrement goes up and increment goes down visually.
Keys.onUpPressed: (event) => {
if (chatBar.isCompleting) {
event.accepted = true
completionMenu.listView.decrementCurrentIndex()
autoAppeared = true;
}
event.accepted = false
}
Keys.onDownPressed: (event) => {
if (chatBar.isCompleting) {
event.accepted = true
completionMenu.listView.incrementCurrentIndex()
autoAppeared = true;
}
event.accepted = false
}
Keys.onTabPressed: {
if (event.modifiers & Qt.ControlModifier) {
switchRoomDown();
return;
}
if (!isCompleting) {
nextItemInFocusChain().forceActiveFocus(Qt.TabFocusReason);
return;
}
// TODO detect moved cursor
// ignore first time tab was clicked so that user can select
// first emoji/user
if (!autoAppeared) {
let incrementedIndex = completionMenu.currentIndex + 1;
// Wrap around to the first item
if (incrementedIndex > completionMenu.count - 1) {
incrementedIndex = 0
}
completionMenu.currentIndex = incrementedIndex;
} else {
autoAppeared = false;
}
chatBar.complete();
}
onTextChanged: {
if (!repeatTimer.running && Config.typingNotifications) {
currentRoom.sendTypingNotification(true)
}
repeatTimer.start()
currentRoom.cachedInput = text
autoAppeared = false;
const completionInfo = documentHandler.getAutocompletionInfo(isCompleting);
if (completionInfo.type === ChatDocumentHandler.Ignore) {
if (completionInfo.keyword) {
// custom emojis
const idx = completionMenu.currentIndex;
completionMenu.model = Array.from(chatBar.customEmojiModel.filterModel(completionInfo.keyword)).concat(EmojiModel.filterModel(completionInfo.keyword))
completionMenu.currentIndex = idx;
}
return;
}
if (completionInfo.type === ChatDocumentHandler.None) {
isCompleting = false;
return;
}
completionMenu.completionType = completionInfo.type
if (completionInfo.type === ChatDocumentHandler.User) {
completionMenu.model = currentRoom.getUsers(completionInfo.keyword, 10);
} else if (completionInfo.type === ChatDocumentHandler.Command) {
completionMenu.model = CommandModel.filterModel(completionInfo.keyword);
} else {
completionMenu.model = Array.from(chatBar.customEmojiModel.filterModel(completionInfo.keyword)).concat(EmojiModel.filterModel(completionInfo.keyword))
}
if (completionMenu.model.length === 0) {
isCompleting = false;
return;
}
if (!isCompleting) {
isCompleting = true
autoAppeared = true;
completionMenu.endPosition = cursorPosition
}
}
}
}
Item {
visible: !chatBoxHelper.isReplying && (!chatBoxHelper.hasAttachment || uploadingBusySpinner.running)
implicitWidth: uploadButton.implicitWidth
implicitHeight: uploadButton.implicitHeight
ToolButton {
id: uploadButton
anchors.fill: parent
// Matrix does not allow sending attachments in replies
visible: !chatBoxHelper.isReplying && !chatBoxHelper.hasAttachment && !uploadingBusySpinner.running
icon.name: "mail-attachment"
text: i18n("Attach an image or file")
display: AbstractButton.IconOnly
onClicked: {
if (Clipboard.hasImage) {
attachDialog.open()
} else {
var fileDialog = openFileDialog.createObject(ApplicationWindow.overlay)
fileDialog.chosen.connect((path) => {
if (!path) { return }
chatBoxHelper.attachmentPath = path;
})
fileDialog.open()
}
}
ToolTip.text: text
ToolTip.visible: hovered
}
BusyIndicator {
id: uploadingBusySpinner
anchors.fill: parent
visible: running
running: currentRoom && currentRoom.hasFileUploading
}
}
ToolButton {
id: emojiButton
icon.name: "preferences-desktop-emoticons"
text: i18n("Add an Emoji")
display: AbstractButton.IconOnly
checkable: true
ToolTip.text: text
ToolTip.visible: hovered
}
ToolButton {
id: sendButton
icon.name: "document-send"
text: i18n("Send message")
display: AbstractButton.IconOnly
onClicked: {
chatBar.postMessage()
}
ToolTip.text: text
ToolTip.visible: hovered
}
}
Action {
id: pasteAction
shortcut: StandardKey.Paste
onTriggered: {
if (Clipboard.hasImage) {
pasteImageTriggered();
}
activeFocusItem.paste();
}
}
CompletionMenu {
id: completionMenu
width: parent.width
//height: 80 //Math.min(implicitHeight, delegate.implicitHeight * 6)
height: implicitHeight
y: -height - 1
z: 1
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
onCompleteTriggered: {
complete()
isCompleting = false;
}
}
property CustomEmojiModel customEmojiModel: CustomEmojiModel {
connection: Controller.activeConnection
}
function pasteImage() {
let localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png";
if (!Clipboard.saveImage(localPath)) {
return;
}
chatBoxHelper.attachmentPath = localPath;
}
function postMessage() {
checkForFancyEffectsReason();
if (chatBoxHelper.hasAttachment) {
// send attachment but don't reset the text
actionsHandler.postMessage("", chatBoxHelper.attachmentPath,
chatBoxHelper.replyEventId, chatBoxHelper.editEventId, {}, this.customEmojiModel);
currentRoom.markAllMessagesAsRead();
messageSent();
return;
}
const re = /^s\/([^\/]*)\/([^\/]*)/;
if (Config.allowQuickEdit && re.test(inputField.text)) {
// send edited messages
actionsHandler.postEdit(inputField.text);
} else {
// send normal message
actionsHandler.postMessage(inputField.text.trim(), chatBoxHelper.attachmentPath,
chatBoxHelper.replyEventId, chatBoxHelper.editEventId, userAutocompleted, this.customEmojiModel);
}
currentRoom.markAllMessagesAsRead();
inputField.clear();
inputField.text = Qt.binding(function() {
return currentRoom ? currentRoom.cachedInput : "";
});
messageSent()
}
function complete() {
documentHandler.replaceAutoComplete(completionMenu.currentDisplayText);
if (completionMenu.completionType === ChatDocumentHandler.User
&& completionMenu.currentDisplayText.length > 0
&& completionMenu.currentItem.userId.length > 0) {
userAutocompleted[completionMenu.currentDisplayText] = completionMenu.currentItem.userId;
}
}
}

View File

@@ -0,0 +1,283 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import Qt.labs.platform 1.1 as Platform
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component.ChatBox 1.0
import NeoChat.Component.Emoji 1.0
Item {
id: root
property alias inputFieldText: chatBar.inputFieldText
signal fancyEffectsReasonFound(string fancyEffect)
signal messageSent()
signal editLastUserMessage()
signal replyPreviousUserMessage()
Kirigami.Theme.colorSet: Kirigami.Theme.View
implicitWidth: {
let w = 0
for(let i = 0; i < visibleChildren.length; ++i) {
w = Math.max(w, Math.ceil(visibleChildren[i].implicitWidth))
}
return w
}
implicitHeight: {
let h = 0
for(let i = 0; i < visibleChildren.length; ++i) {
h += Math.ceil(visibleChildren[i].implicitHeight)
}
return h
}
// For some reason, this is needed to make the height animation work even though
// it used to work and height should be directly affected by implicitHeight
height: implicitHeight
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
Kirigami.Separator {
id: connectionPaneSeparator
visible: connectionPane.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: connectionPane.top
z: 1
}
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
width: parent.width
QQC2.Label {
id: networkLabel
text: i18n("NeoChat is offline. Please check your network connection.")
}
anchors.bottom: emojiPickerLoaderSeparator.top
}
Kirigami.Separator {
id: emojiPickerLoaderSeparator
visible: emojiPickerLoader.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: emojiPickerLoader.top
z: 1
}
Loader {
id: emojiPickerLoader
active: visible
visible: chatBar.emojiPaneOpened
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: replySeparator.top
sourceComponent: QQC2.Pane {
topPadding: 0
bottomPadding: 0
rightPadding: 0
leftPadding: 0
Kirigami.Theme.colorSet: Kirigami.Theme.View
contentItem: EmojiPicker {
textArea: chatBar.textField
onChosen: addText(emoji)
}
}
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
Kirigami.Separator {
id: replySeparator
visible: replyPane.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: replyPane.top
}
ReplyPane {
id: replyPane
visible: chatBoxHelper.isReplying || chatBoxHelper.isEditing
user: chatBoxHelper.replyUser
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: attachmentSeparator.top
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
onReplyCancelled: {
root.focusInputField()
}
}
Kirigami.Separator {
id: attachmentSeparator
visible: attachmentPane.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: attachmentPane.top
}
AttachmentPane {
id: attachmentPane
visible: chatBoxHelper.hasAttachment
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: chatBarSeparator.top
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
Kirigami.Separator {
id: chatBarSeparator
visible: chatBar.visible
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: chatBar.top
}
ChatBar {
id: chatBar
visible: currentRoom.canSendEvent("m.room.message")
width: parent.width
height: visible ? implicitHeight : 0
anchors.bottom: parent.bottom
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
onCloseAllTriggered: closeAll()
onMessageSent: {
closeAll()
checkForFancyEffectsReason()
root.messageSent();
}
onEditLastUserMessage: {
root.editLastUserMessage();
}
onReplyPreviousUserMessage: {
root.replyPreviousUserMessage();
}
}
function checkForFancyEffectsReason() {
if (!Config.showFancyEffects) {
return
}
let text = root.inputFieldText.trim()
if (text.includes('\u{2744}')) {
root.fancyEffectsReasonFound("snowflake")
}
if (text.includes('\u{1F386}')) {
root.fancyEffectsReasonFound("fireworks")
}
if (text.includes('\u{1F387}')) {
root.fancyEffectsReasonFound("fireworks")
}
if (text.includes('\u{1F389}')) {
root.fancyEffectsReasonFound("confetti")
}
if (text.includes('\u{1F38A}')) {
root.fancyEffectsReasonFound("confetti")
}
}
function addText(text) {
root.inputFieldText = inputFieldText + text
}
function insertText(str) {
root.inputFieldText = inputFieldText.substr(0, inputField.cursorPosition) + str + inputFieldText.substr(inputField.cursorPosition)
}
function focusInputField() {
chatBar.inputFieldForceActiveFocusTriggered()
}
Connections {
target: RoomManager
function onCurrentRoomChanged() {
chatBar.userAutocompleted = {};
}
}
Connections {
target: chatBoxHelper
function onShouldClearText() {
root.inputFieldText = "";
}
function onEditing(editContent, editFormatedContent) {
// Set the input field in edit mode
root.inputFieldText = editContent;
// clean autocompletion list
chatBar.userAutocompleted = {};
// Fill autocompletion list with values extracted from message.
// We can't just iterate on every user in the list and try to
// find matching display name since some users have display name
// matching frequent words and this will marks too many words as
// mentions.
const regex = /<a href=\"https:\/\/matrix.to\/#\/(@[a-zA-Z09]*:[a-zA-Z09.]*)\">([^<]*)<\/a>/g;
let match;
while ((match = regex.exec(editFormatedContent.toString())) !== null) {
chatBar.userAutocompleted[match[2]] = match[1];
}
chatBox.forceActiveFocus();
}
}
function closeAll() {
chatBoxHelper.clear();
chatBar.emojiPaneOpened = false;
}
}

View File

@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import Qt.labs.qmlmodels 1.0
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
Popup {
id: control
// Expose internal ListView properties.
property alias model: completionListView.model
property alias listView: completionListView
property alias currentIndex: completionListView.currentIndex
property alias currentItem: completionListView.currentItem
property alias count: completionListView.count
property alias delegate: completionListView.delegate
// Autocomplee text
property string currentDisplayText: currentItem && (currentItem.displayName ?? "")
property int completionType: ChatDocumentHandler.Emoji
property int beginPosition: 0
property int endPosition: 0
signal completeTriggered()
Kirigami.Theme.colorSet: Kirigami.Theme.View
bottomPadding: 0
leftPadding: 0
rightPadding: 0
topPadding: 0
clip: true
onVisibleChanged: if (!visible) {
completionListView.currentIndex = 0;
}
implicitHeight: Math.min(completionListView.contentHeight, Kirigami.Units.gridUnit * 10)
contentItem: ScrollView {
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ListView {
id: completionListView
implicitWidth: contentWidth
delegate: {
if (completionType === ChatDocumentHandler.Emoji) {
emojiDelegate
} else if (completionType === ChatDocumentHandler.Command) {
commandDelegate
} else if (completionType === ChatDocumentHandler.User) {
usernameDelegate
}
}
keyNavigationWraps: true
//interactive: Window.window ? contentHeight + control.topPadding + control.bottomPadding > Window.window.height : false
clip: true
currentIndex: control.currentIndex || 0
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
Component {
id: usernameDelegate
Kirigami.BasicListItem {
id: usernameItem
width: ListView.view.width ?? implicitWidth
property string displayName: modelData.displayName
property string userId: modelData.id
leading: Kirigami.Avatar {
implicitHeight: Kirigami.Units.gridUnit
implicitWidth: implicitHeight
source: modelData.avatarMediaId ? ("image://mxc/" + modelData.avatarMediaId) : ""
color: modelData.color ? Qt.darker(modelData.color, 1.1) : null
}
labelItem.textFormat: Text.PlainText
text: modelData.displayName
onClicked: completeTriggered();
}
}
Component {
id: emojiDelegate
Kirigami.BasicListItem {
id: emojiItem
width: ListView.view.width ?? implicitWidth
property string displayName: modelData.isCustom ? modelData.shortname : modelData.unicode
text: modelData.shortname
height: Kirigami.Units.gridUnit * 2
leading: Image {
source: modelData.isCustom ? modelData.unicode : ""
width: height
sourceSize.width: width
sourceSize.height: height
Rectangle {
anchors.fill: parent
visible: parent.status === Image.Loading
radius: height/2
gradient: ShimmerGradient { }
}
Label {
id: unicodeLabel
visible: !modelData.isCustom
font.family: 'emoji'
font.pixelSize: height - 2
text: modelData.unicode
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.fill: parent
}
}
onClicked: completeTriggered();
}
}
Component {
id: commandDelegate
Kirigami.BasicListItem {
id: commandItem
width: ListView.view.width ?? implicitWidth
text: "<i>" + modelData.parameter.replace("<", "&lt;").replace(">", "&gt;") + "</i> " + modelData.help
property string displayName: modelData.command
leading: Label {
id: commandLabel
Layout.preferredHeight: Kirigami.Units.gridUnit
Layout.preferredWidth: textMetrics.tightBoundingRect.width
text: modelData.command
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
TextMetrics {
id: textMetrics
text: modelData.command
font: commandLabel.font
}
onClicked: completeTriggered();
}
}
}

View File

@@ -0,0 +1,121 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import org.kde.kirigami 2.14 as Kirigami
import org.kde.neochat 1.0
Loader {
id: root
readonly property bool isEdit: chatBoxHelper.isEditing
property var user: null
property string avatarMediaUrl: user ? "image://mxc/" + user.avatarMediaId : ""
signal replyCancelled()
active: visible
sourceComponent: Pane {
id: replyPane
Kirigami.Theme.colorSet: Kirigami.Theme.View
spacing: leftPadding
contentItem: RowLayout {
Layout.fillWidth: true
spacing: replyPane.spacing
FontMetrics {
id: fontMetrics
font: textArea.font
}
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: root.avatarMediaUrl
name: user ? user.displayName : ""
color: user ? user.color : "transparent"
visible: Boolean(user)
}
ColumnLayout {
id: textContentLayout
Layout.alignment: Qt.AlignCenter
Layout.fillWidth: true
spacing: fontMetrics.leading
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 (isEdit) {
heading = heading.arg(i18n("Editing message:")) + "<br/>"
} else {
heading = heading.arg(i18n("Replying to %1:", userName))
}
return heading
}
}
ScrollView {
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
Layout.maximumHeight: fontMetrics.lineSpacing * 8 - fontMetrics.leading
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
TextArea {
id: textArea
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
text: {
const stylesheet = "<style> a{color:"+Kirigami.Theme.linkColor+";}.user-pill{}</style>";
const content = chatBoxHelper.isReplying ? chatBoxHelper.replyEventContent : chatBoxHelper.editContent;
return stylesheet + content;
}
selectByMouse: true
selectByKeyboard: true
readOnly: true
wrapMode: Label.Wrap
textFormat: TextEdit.RichText
background: Item {}
HoverHandler {
cursorShape: textArea.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
}
}
}
}
Button {
id: cancelReplyButton
Layout.alignment: avatar.Layout.alignment
icon.name: "dialog-cancel"
text: i18n("Cancel")
display: AbstractButton.IconOnly
onClicked: {
chatBoxHelper.clear();
root.replyCancelled();
}
ToolTip.text: text
ToolTip.visible: hovered
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
}
}

View File

@@ -0,0 +1,7 @@
module NeoChat.Component.ChatBox
ChatBox 1.0 ChatBox.qml
ChatBar 1.0 ChatBar.qml
ReplyPane 1.0 ReplyPane.qml
AttachmentPane 1.0 AttachmentPane.qml
CompletionMenu 1.0 CompletionMenu.qml
EmojiPickerPane 1.0 EmojiPickerPane.qml

View File

@@ -0,0 +1,169 @@
// SPDX-FileCopyrightText: 2018-2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 as NeoChat
import NeoChat.Component 1.0
ColumnLayout {
id: _picker
property string emojiCategory: "history"
property var textArea
readonly property var emojiModel: NeoChat.EmojiModel
property NeoChat.CustomEmojiModel customModel: NeoChat.CustomEmojiModel {
connection: NeoChat.Controller.activeConnection
}
signal chosen(string emoji)
spacing: 0
ListView {
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + 2 // for the focus line
boundsBehavior: Flickable.DragOverBounds
clip: true
orientation: ListView.Horizontal
model: ListModel {
ListElement { label: "custom"; category: "custom" }
ListElement { label: "⌛️"; category: "history" }
ListElement { label: "😏"; category: "people" }
ListElement { label: "🌲"; category: "nature" }
ListElement { label: "🍛"; category: "food"}
ListElement { label: "🚁"; category: "activity" }
ListElement { label: "🚅"; category: "travel" }
ListElement { label: "💡"; category: "objects" }
ListElement { label: "🔣"; category: "symbols" }
ListElement { label: "🏁"; category: "flags" }
}
delegate: ItemDelegate {
id: del
required property string label
required property string category
width: contentItem.Layout.preferredWidth
height: Kirigami.Units.gridUnit * 2
contentItem: Kirigami.Heading {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
level: del.label === "custom" ? 4 : 1
Layout.preferredWidth: del.label === "custom" ? implicitWidth + Kirigami.Units.largeSpacing : Kirigami.Units.gridUnit * 2
font.family: del.label === "custom" ? Kirigami.Theme.defaultFont.family : 'emoji'
text: del.label === "custom" ? i18n("Custom") : del.label
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 2
visible: emojiCategory === category
color: Kirigami.Theme.focusColor
}
onClicked: emojiCategory = category
}
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.preferredHeight: 1
}
GridView {
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 8
Layout.fillHeight: true
cellWidth: Kirigami.Units.gridUnit * 2
cellHeight: Kirigami.Units.gridUnit * 2
boundsBehavior: Flickable.DragOverBounds
clip: true
model: {
switch (emojiCategory) {
case "custom":
return _picker.customModel
case "history":
return emojiModel.history
case "people":
return emojiModel.people
case "nature":
return emojiModel.nature
case "food":
return emojiModel.food
case "activity":
return emojiModel.activity
case "travel":
return emojiModel.travel
case "objects":
return emojiModel.objects
case "symbols":
return emojiModel.symbols
case "flags":
return emojiModel.flags
}
return null
}
delegate: ItemDelegate {
width: Kirigami.Units.gridUnit * 2
height: Kirigami.Units.gridUnit * 2
contentItem: Kirigami.Heading {
level: 1
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: 'emoji'
text: modelData.isCustom ? "" : modelData.unicode
}
Image {
visible: modelData.isCustom
source: visible ? modelData.unicode : ""
anchors.fill: parent
anchors.margins: 2
sourceSize.width: width
sourceSize.height: height
Rectangle {
anchors.fill: parent
visible: parent.status === Image.Loading
radius: height/2
gradient: ShimmerGradient { }
}
}
onClicked: {
if (modelData.isCustom) {
chosen(modelData.shortname)
} else {
chosen(modelData.unicode)
}
emojiModel.emojiUsed(modelData)
}
}
ScrollBar.vertical: ScrollBar {}
}
}

View File

@@ -0,0 +1,2 @@
module NeoChat.Component.Emoji
EmojiPicker 1.0 EmojiPicker.qml

View File

@@ -2,13 +2,14 @@
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
import QtQuick 2.15
import QtQuick.Controls 2.15 as Controls
import QtQuick.Layouts 1.15
import QtQuick.Particles 2.15
import org.kde.kirigami 2.15 as Kirigami
Item {
id: root
id: item
property bool enabled: false
property int effectInterval: Kirigami.Units.veryLongDuration*10;
property color darkSnowColor: "grey"
@@ -30,12 +31,12 @@ Item {
Timer {
id: confettiTimer
interval: root.effectInterval;
interval: item.effectInterval;
running: false;
repeat: false;
triggeredOnStart: true;
onTriggered: {
if (root.enabled) {
if (item.enabled) {
confettiSystem.running = !confettiSystem.running
}
}
@@ -61,7 +62,7 @@ Item {
}
ImageParticle {
source: "qrc:/confetti.png"
source: "qrc:/imports/NeoChat/Component/confetti.png"
entryEffect: ImageParticle.Scale
rotationVariation: 360
rotationVelocity: 90
@@ -92,12 +93,12 @@ Item {
Timer {
id: snowTimer
interval: root.effectInterval;
interval: item.effectInterval;
running: false;
repeat: false;
triggeredOnStart: true;
onTriggered: {
if (root.enabled) {
if (item.enabled) {
snowSystem.running = !snowSystem.running
}
}
@@ -127,7 +128,7 @@ Item {
width: 10
height: width
radius: width
color: root.isThemeDark ? "white" : darkSnowColor
color: item.isThemeDark ? "white" : darkSnowColor
scale: Math.random()
opacity: Math.random()
}
@@ -157,12 +158,12 @@ Item {
Timer {
id: fireworksTimer
interval: root.effectInterval;
interval: item.effectInterval;
running: false;
repeat: false;
triggeredOnStart: true;
onTriggered: {
if (root.enabled) {
if (item.enabled) {
fireworksInternalTimer.running = !fireworksInternalTimer.running
}
}
@@ -205,9 +206,9 @@ Item {
ImageParticle {
id: fireworksParticleA
system: fireworksSystem
source: "qrc:/glowdot.png"
alphaVariation: root.isThemeDark ? 0.1 : 0.1
alpha: root.isThemeDark ? 0.5 : 1
source: "qrc:/imports/NeoChat/Component/glowdot.png"
alphaVariation: item.isThemeDark ? 0.1 : 0.1
alpha: item.isThemeDark ? 0.5 : 1
groups: ["a"]
opacity: fireworksSystem.opacity
entryEffect: ImageParticle.Scale
@@ -216,10 +217,10 @@ Item {
ImageParticle {
system: fireworksSystem
source: "qrc:/glowdot.png"
color: root.isThemeDark ? "white" : "gold"
alphaVariation: root.isThemeDark ? 0.1 : 0.1
alpha: root.isThemeDark ? 0.5 : 1
source: "qrc:/imports/NeoChat/Component/glowdot.png"
color: item.isThemeDark ? "white" : "gold"
alphaVariation: item.isThemeDark ? 0.1 : 0.1
alpha: item.isThemeDark ? 0.5 : 1
groups: ["light"]
opacity: fireworksSystem.opacity
entryEffect: ImageParticle.Scale
@@ -229,9 +230,9 @@ Item {
ImageParticle {
id: fireworksParticleB
system: fireworksSystem
source: "qrc:/glowdot.png"
alphaVariation: root.isThemeDark ? 0.1 : 0.1
alpha: root.isThemeDark ? 0.5 : 1
source: "qrc:/imports/NeoChat/Component/glowdot.png"
alphaVariation: item.isThemeDark ? 0.1 : 0.1
alpha: item.isThemeDark ? 0.5 : 1
groups: ["b"]
opacity: fireworksSystem.opacity
entryEffect: ImageParticle.Scale
@@ -257,7 +258,7 @@ Item {
onTriggered: {
container.destroy();
var randomHue = Math.random()
var lightness = root.isThemeDark ? 0.8 : 0.7
var lightness = item.isThemeDark ? 0.8 : 0.7
fireworksParticleA.color = Qt.hsla(randomHue, 0.8, lightness, 1)
fireworksParticleB.color = Qt.hsla(1-randomHue, 0.8, lightness, 1)
}

View File

@@ -0,0 +1,308 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1
import org.kde.kirigami 2.15 as Kirigami
Popup {
id: root
property alias source: image.source
property string filename
property string blurhash: ""
property int imageWidth: -1
property int imageHeight: -1
property var modelData
parent: Overlay.overlay
closePolicy: Popup.CloseOnEscape
width: parent.width
height: parent.height
modal: true
padding: 0
background: null
ColumnLayout {
anchors.fill: parent
spacing: Kirigami.Units.largeSpacing
Control {
Layout.fillWidth: true
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.Avatar {
id: avatar
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
name: modelData.author.name ?? modelData.author.displayName
source: modelData.author.avatarMediaId ? ("image://mxc/" + modelData.author.avatarMediaId) : ""
color: modelData.author.color
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Label {
id: nameLabel
text: modelData.author.displayName
textFormat: Text.PlainText
font.weight: Font.Bold
color: author.color
}
Label {
id: timeLabel
text: time.toLocaleString(Qt.locale(), Locale.ShortFormat)
}
}
Label {
id: imageLabel
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
text: modelData.display
font.weight: Font.Bold
elide: Text.ElideRight
}
ToolButton {
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
text: i18n("Zoom in")
Accessible.name: text
icon.name: "zoom-in"
display: AbstractButton.IconOnly
onClicked: {
image.scaleFactor = image.scaleFactor + 0.25
if (image.scaleFactor > 3) {
image.scaleFactor = 3
}
}
ToolTip.text: text
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.visible: hovered
}
ToolButton {
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
text: i18n("Zoom out")
Accessible.name: text
icon.name: "zoom-out"
display: AbstractButton.IconOnly
onClicked: {
image.scaleFactor = image.scaleFactor - 0.25
if (image.scaleFactor < 0.25) {
image.scaleFactor = 0.25
}
}
ToolTip.text: text
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.visible: hovered
}
ToolButton {
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
text: i18n("Rotate left")
Accessible.name: text
icon.name: "image-rotate-left-symbolic"
display: AbstractButton.IconOnly
onClicked: image.rotationAngle = image.rotationAngle - 90
ToolTip.text: text
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.visible: hovered
}
ToolButton {
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
text: i18n("Rotate right")
Accessible.name: text
icon.name: "image-rotate-right-symbolic"
display: AbstractButton.IconOnly
onClicked: image.rotationAngle = image.rotationAngle + 90
ToolTip.text: text
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.visible: hovered
}
ToolButton {
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
text: i18n("Save as")
Accessible.name: text
icon.name: "document-save"
display: AbstractButton.IconOnly
onClicked: {
var dialog = saveAsDialog.createObject(ApplicationWindow.overlay)
dialog.open()
dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(eventId)
}
ToolTip.text: text
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.visible: hovered
}
ToolButton {
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
text: i18n("Close")
Accessible.name: text
icon.name: "dialog-close"
display: AbstractButton.IconOnly
onClicked: {
root.close()
}
ToolTip.text: text
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.visible: hovered
}
}
background: Rectangle {
color: Kirigami.Theme.alternateBackgroundColor
}
Kirigami.Separator {
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
}
height: 1
}
}
BusyIndicator {
Layout.fillWidth: true
visible: image.status !== Image.Ready && root.blurhash === ""
running: visible
}
// Provides container to fill the space that isn't taken up by the top controls and clips the image when zooming makes it larger than the available area.
Item {
id: imageContainer
Layout.fillWidth: true
Layout.fillHeight: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.largeSpacing
clip: true
Image {
id: image
property var scaleFactor: 1
property int rotationAngle: 0
property var rotationInsensitiveWidth: Math.min(root.imageWidth !== -1 ? root.imageWidth : sourceSize.width, imageContainer.width - Kirigami.Units.largeSpacing * 2)
property var rotationInsensitiveHeight: Math.min(root.imageHeight !== -1 ? root.imageHeight : sourceSize.height, imageContainer.height - Kirigami.Units.largeSpacing * 2)
anchors.centerIn: parent
width: rotationAngle % 180 === 0 ? rotationInsensitiveWidth : rotationInsensitiveHeight
height: rotationAngle % 180 === 0 ? rotationInsensitiveHeight : rotationInsensitiveWidth
fillMode: Image.PreserveAspectFit
clip: true
Behavior on width {
NumberAnimation {duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic}
}
Behavior on height {
NumberAnimation {duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic}
}
Image {
anchors.centerIn: parent
width: image.width
height: image.height
source: root.blurhash !== "" ? ("image://blurhash/" + root.blurhash) : ""
visible: root.blurhash !== "" && parent.status !== Image.Ready
}
transform: [
Rotation {
origin.x: image.width / 2
origin.y: image.height / 2
angle: image.rotationAngle
Behavior on angle {
RotationAnimation {duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic}
}
},
Scale {
origin.x: image.width / 2
origin.y: image.height / 2
xScale: image.scaleFactor
yScale: image.scaleFactor
Behavior on xScale {
NumberAnimation {duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic}
}
Behavior on yScale {
NumberAnimation {duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic}
}
}
]
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: {
const contextMenu = fileDelegateContextMenu.createObject(parent, {
author: modelData.author,
message: modelData.message,
eventId: modelData.eventId,
source: modelData.source,
file: root.parent,
mimeType: modelData.mimeType,
progressInfo: modelData.progressInfo,
plainMessage: modelData.message,
});
contextMenu.closeFullscreen.connect(root.close)
contextMenu.open();
}
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
onClicked: {
root.close()
}
}
}
}
Component {
id: saveAsDialog
FileDialog {
fileMode: FileDialog.SaveFile
folder: StandardPaths.writableLocation(StandardPaths.DownloadLocation)
onAccepted: {
if (!currentFile) {
return;
}
currentRoom.downloadFile(eventId, currentFile)
}
}
}
onClosed: {
image.scaleFactor = 1
image.rotationAngle = 0
}
}

View File

@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
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
LoginStep {
id: root
readonly property var homeserver: customHomeserver.visible ? customHomeserver.text : serverCombo.currentText
property bool loading: false
title: i18nc("@title", "Select a Homeserver")
action: Kirigami.Action {
enabled: LoginHelper.homeserverReachable && !customHomeserver.visible || customHomeserver.acceptableInput
onTriggered: {
// TODO
console.log("register todo")
}
}
onHomeserverChanged: {
LoginHelper.testHomeserver("@user:" + homeserver)
}
Kirigami.FormLayout {
Component.onCompleted: Controller.testHomeserver(homeserver)
QQC2.ComboBox {
id: serverCombo
Kirigami.FormData.label: i18n("Homeserver:")
model: ["matrix.org", "kde.org", "tchncs.de", i18n("Other...")]
}
QQC2.TextField {
id: customHomeserver
Kirigami.FormData.label: i18n("Url:")
visible: serverCombo.currentIndex === 3
onTextChanged: {
Controller.testHomeserver(text)
}
validator: RegularExpressionValidator {
regularExpression: /([a-zA-Z0-9\-]+\.)*[a-zA-Z0-9]+(:[0-9]+)?/
}
}
QQC2.Button {
id: continueButton
text: i18nc("@action:button", "Continue")
action: root.action
}
}
}

View File

@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.19 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
Kirigami.LoadingPlaceholder {
property var showContinueButton: false
property var showBackButton: false
text: i18n("Synchronizing with your homeserver…")
icon.name: "cloud-download"
anchors.centerIn: parent
explanation: i18n("Please wait. This might take a little while.")
}

View File

@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
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
import NeoChat.Component 1.0
LoginStep {
id: login
showContinueButton: LoginHelper.homeserverReachable
showBackButton: false
title: i18nc("@title", "Login")
message: i18n("Welcome to NeoChat!")
Component.onCompleted: {
LoginHelper.matrixId = ""
}
QQC2.Label {
text: i18n("To get started, enter your matrix ID:")
}
Kirigami.FormLayout {
QQC2.TextField {
id: matrixIdField
placeholderText: "@user:matrix.org"
onTextChanged: {
if(acceptableInput) {
LoginHelper.matrixId = text
} else {
LoginHelper.matrixId = ""
}
}
Component.onCompleted: {
matrixIdField.forceActiveFocus()
}
Keys.onReturnPressed: {
login.action.trigger()
}
validator: RegularExpressionValidator {
regularExpression: /^\@?[a-zA-Z0-9\._=\-/]+\:[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)*(\:[0-9]+)?$/
}
}
}
action: Kirigami.Action {
text: LoginHelper.testing && matrixIdField.acceptableInput ? i18n("Loading…") : i18nc("@action:button", "Continue")
onTriggered: {
if (LoginHelper.supportsSso && LoginHelper.supportsPassword) {
processed("qrc:/imports/NeoChat/Component/Login/LoginMethod.qml");
} else if (LoginHelper.supportsPassword) {
processed("qrc:/imports/NeoChat/Component/Login/Password.qml");
} else {
processed("qrc:/imports/NeoChat/Component/Login/Sso.qml");
}
}
enabled: LoginHelper.homeserverReachable
iconName: "go-next"
}
}

View File

@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15 as Controls
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component.Login 1.0
LoginStep {
id: loginMethod
title: i18n("Login Methods")
Layout.alignment: Qt.AlignHCenter
Controls.Button {
Layout.alignment: Qt.AlignHCenter
text: i18n("Login with password")
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onClicked: processed("qrc:/imports/NeoChat/Component/Login/Password.qml")
}
Controls.Button {
Layout.alignment: Qt.AlignHCenter
text: i18n("Login with single sign-on")
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onClicked: processed("qrc:/imports/NeoChat/Component/Login/Sso.qml")
}
}

View File

@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15 as Controls
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component.Login 1.0
LoginStep {
id: loginRegister
Layout.alignment: Qt.AlignHCenter
Controls.Button {
Layout.alignment: Qt.AlignHCenter
text: i18n("Login")
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onClicked: processed("qrc:/imports/NeoChat/Component/Login/Login.qml")
}
Controls.Button {
Layout.alignment: Qt.AlignHCenter
text: i18n("Register")
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onClicked: processed("qrc:/imports/NeoChat/Component/Login/Homeserver.qml")
}
}

View File

@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.14
import QtQuick.Controls 2.14
import QtQuick.Layouts 1.14
/// Step for the login/registration flow
ColumnLayout {
property string title: i18n("Welcome")
property string message: i18n("Welcome")
property bool showContinueButton: false
property bool showBackButton: false
property bool acceptable: false
property string previousUrl: ""
/// Process this module, this is called by the continue button.
/// Should call \sa processed when it finish successfully.
property Action action: null
/// Called when switching to the next step.
signal processed(url nextUrl)
signal showMessage(string message)
}

View File

@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
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
import NeoChat.Component 1.0
LoginStep {
id: password
title: i18nc("@title", "Password")
message: i18n("Enter your password")
showContinueButton: true
showBackButton: true
previousUrl: LoginHelper.isLoggingIn ? "" : LoginHelper.supportsSso ? "qrc:/imports/NeoChat/Component/Login/LoginMethod.qml" : "qrc:/imports/NeoChat/Component/Login/Login.qml"
action: Kirigami.Action {
text: i18nc("@action:button", "Login")
enabled: passwordField.text.length > 0 && !LoginHelper.isLoggingIn
onTriggered: {
LoginHelper.login();
}
iconName: "go-next"
}
Connections {
target: LoginHelper
function onConnected() {
processed("qrc:/imports/NeoChat/Component/Login/Loading.qml")
}
}
Kirigami.FormLayout {
Kirigami.PasswordField {
id: passwordField
onTextChanged: LoginHelper.password = text
enabled: !LoginHelper.isLoggingIn
Component.onCompleted: {
passwordField.forceActiveFocus()
}
Keys.onReturnPressed: {
password.action.trigger()
}
}
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.12 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
LoginStep {
id: root
title: i18nc("@title", "Login")
message: i18n("Login with single sign-on")
Kirigami.FormLayout {
Connections {
target: LoginHelper
function onSsoUrlChanged() {
UrlHelper.openUrl(LoginHelper.ssoUrl)
}
function onConnected() {
processed("qrc:/imports/NeoChat/Component/Login/Loading.qml")
}
}
RowLayout {
QQC2.Button {
text: i18nc("@action:button", "Back")
onClicked: {
module.source = "qrc:/imports/NeoChat/Component/Login/Login.qml"
}
}
QQC2.Button {
text: i18n("Login")
onClicked: {
LoginHelper.loginWithSso()
root.showMessage(i18n("Complete the authentication steps in your browser"))
}
Component.onCompleted: forceActiveFocus()
Keys.onReturnPressed: clicked()
}
}
}
}

View File

@@ -0,0 +1,7 @@
module NeoChat.Component.Login
Login 1.0 Login.qml
Password 1.0 Password.qml
LoginRegister 1.0 LoginRegister.qml
Loading 1.0 Loading.qml
LoginMethod 1.0 LoginMethod.qml
LoginStep 1.0 LoginStep.qml

View File

@@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Layouts 1.10
import QtQuick.Controls 2.12 as QQC2
import org.kde.kirigami 2.14 as Kirigami
import org.kde.kitemmodels 1.0
import org.kde.neochat 1.0
QQC2.Popup {
id: _popup
Shortcut {
sequence: "Ctrl+K"
enabled: !Kirigami.Settings.hasPlatformMenuBar
onActivated: _popup.open()
}
onVisibleChanged: {
if (!visible) {
return
}
quickSearch.forceActiveFocus()
quickSearch.text = ""
}
anchors.centerIn: QQC2.Overlay.overlay
background: Kirigami.Card {}
height: 2 * Math.round(implicitHeight / 2)
padding: Kirigami.Units.largeSpacing * 2
contentItem: ColumnLayout {
spacing: Kirigami.Units.largeSpacing * 2
Kirigami.SearchField {
id: quickSearch
// TODO: get this broken property removed/disabled by default in Kirigami,
// we used to be able to expect that the text field wouldn't attempt to
// perform a mini-DDOS attack using signals.
autoAccept: false
Layout.preferredWidth: Kirigami.Units.gridUnit * 21 // 3 * 7 = 21, roughly 7 avatars on screen
Keys.onLeftPressed: cView.decrementCurrentIndex()
Keys.onRightPressed: cView.incrementCurrentIndex()
onAccepted: {
const item = cView.itemAtIndex(cView.currentIndex)
RoomManager.enterRoom(item.currentRoom)
_popup.close()
}
}
ListView {
id: cView
orientation: Qt.Horizontal
spacing: Kirigami.Units.largeSpacing
model: SortFilterRoomListModel {
id: sortFilterRoomListModel
sourceModel: RoomListModel {
id: roomListModel
connection: Controller.activeConnection
}
filterText: quickSearch.text
roomSortOrder: SortFilterRoomListModel.LastActivity
}
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Layout.fillWidth: true
delegate: Kirigami.Avatar {
id: del
implicitHeight: Kirigami.Units.gridUnit * 3
implicitWidth: Kirigami.Units.gridUnit * 3
required property string avatar
required property var currentRoom
required property int index
// When an item is hovered set the currentIndex of listview to it so that it is highlighted
onHoveredChanged: {
if (!hovered) {
return
}
cView.currentIndex = index
}
actions.main: Kirigami.Action {
id: enterRoomAction
onTriggered: {
RoomManager.enterRoom(currentRoom);
_popup.close()
}
}
source: avatar != "" ? "image://mxc/" + avatar : ""
}
}
}
modal: true
focus: true
}

View File

@@ -8,7 +8,7 @@ import QtQuick 2.15
import org.kde.kirigami 2.15 as Kirigami
Gradient {
id: root
id: gradient
orientation: Gradient.Horizontal
@@ -25,7 +25,7 @@ Gradient {
from: -2.0
to: 2.0
duration: 700
target: root
target: gradient
properties: "pos"
}
PauseAnimation {
@@ -33,7 +33,7 @@ Gradient {
}
}
GradientStop { position: root.pos-root.offset; color: root.translucent }
GradientStop { position: root.pos; color: root.bright }
GradientStop { position: root.pos+root.offset; color: root.translucent }
}
GradientStop { position: gradient.pos-gradient.offset; color: gradient.translucent }
GradientStop { position: gradient.pos; color: gradient.bright }
GradientStop { position: gradient.pos+gradient.offset; color: gradient.translucent }
}

View File

@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtMultimedia 5.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
TimelineContainer {
id: audioDelegate
onReplyClicked: ListView.view.goToEvent(eventID)
onOpenContextMenu: openFileContext(model, audioDelegate)
readonly property bool downloaded: model.progressInfo && model.progressInfo.completed
onDownloadedChanged: audio.play()
hoverComponent: hoverActions
innerObject: Control {
Layout.fillWidth: true
Layout.maximumWidth: audioDelegate.contentMaxWidth
Audio {
id: audio
source: model.progressInfo.localPath
autoLoad: false
}
states: [
State {
name: "notDownloaded"
when: !model.progressInfo.completed && !model.progressInfo.active
PropertyChanges {
target: playButton
icon.name: "media-playback-start"
onClicked: currentRoom.downloadFile(model.eventId)
}
},
State {
name: "downloading"
when: model.progressInfo.active && !model.progressInfo.completed
PropertyChanges {
target: downloadBar
visible: true
}
PropertyChanges {
target: playButton
icon.name: "media-playback-stop"
onClicked: {
currentRoom.cancelFileTransfer(model.eventId)
}
}
},
State {
name: "paused"
when: model.progressInfo.completed && (audio.playbackState === Audio.StoppedState || audio.playbackState === Audio.PausedState)
PropertyChanges {
target: playButton
icon.name: "media-playback-start"
onClicked: {
audio.play()
}
}
},
State {
name: "playing"
when: model.progressInfo.completed && audio.playbackState === Audio.PlayingState
PropertyChanges {
target: playButton
icon.name: "media-playback-pause"
onClicked: audio.pause()
}
}
]
contentItem: ColumnLayout {
RowLayout {
ToolButton {
id: playButton
}
Label {
text: model.display
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
ProgressBar {
id: downloadBar
visible: false
Layout.fillWidth: true
from: 0
to: model.content.info.size
value: model.progressInfo.progress
}
RowLayout {
visible: audio.hasAudio
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Slider {
from: 0
to: audio.duration
value: audio.position
onMoved: audio.seek(value)
}
Label {
text: Controller.formatDuration(audio.position) + "/" + Controller.formatDuration(audio.duration)
}
}
}
}
}

View File

@@ -1,17 +1,13 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
/**
* @brief A timeline delegate for an encrypted message that can't be decrypted.
*
* @inherit TimelineContainer
*/
TimelineContainer {
id: encryptedDelegate

View File

@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import Qt.labs.qmlmodels 1.0
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
DelegateChooser {
role: "eventType"
DelegateChoice {
roleValue: "state"
delegate: StateDelegate {}
}
DelegateChoice {
roleValue: "emote"
delegate: MessageDelegate {
isEmote: true
}
}
DelegateChoice {
roleValue: "message"
delegate: MessageDelegate {}
}
DelegateChoice {
roleValue: "notice"
delegate: MessageDelegate {}
}
DelegateChoice {
roleValue: "image"
delegate: ImageDelegate {}
}
DelegateChoice {
roleValue: "sticker"
delegate: ImageDelegate {}
}
DelegateChoice {
roleValue: "audio"
delegate: AudioDelegate {}
}
DelegateChoice {
roleValue: "video"
delegate: VideoDelegate {}
}
DelegateChoice {
roleValue: "file"
delegate: FileDelegate {}
}
DelegateChoice {
roleValue: "encrypted"
delegate: EncryptedDelegate {}
}
DelegateChoice {
roleValue: "readMarker"
delegate: ReadMarkerDelegate {}
}
DelegateChoice {
roleValue: "other"
delegate: Item {}
}
}

View File

@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2018-2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
import NeoChat.Dialog 1.0
import NeoChat.Menu.Timeline 1.0
TimelineContainer {
id: fileDelegate
onReplyClicked: ListView.view.goToEvent(eventID)
hoverComponent: hoverActions
onOpenContextMenu: openFileContext(model, fileDelegate)
readonly property bool downloaded: progressInfo && progressInfo.completed
function saveFileAs() {
const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay)
dialog.open()
dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(eventId)
}
function openSavedFile() {
if (UrlHelper.openUrl(progressInfo.localPath)) return;
if (UrlHelper.openUrl(progressInfo.localDir)) return;
}
innerObject: RowLayout {
Layout.fillWidth: true
Layout.maximumWidth: fileDelegate.contentMaxWidth
Layout.margins: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
states: [
State {
name: "downloaded"
when: progressInfo.completed
PropertyChanges {
target: downloadButton
icon.name: "document-open"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: openSavedFile()
}
},
State {
name: "downloading"
when: progressInfo.active
PropertyChanges {
target: sizeLabel
text: i18nc("file download progress", "%1 / %2", Controller.formatByteSize(progressInfo.progress), Controller.formatByteSize(progressInfo.total))
}
PropertyChanges {
target: downloadButton
icon.name: "media-playback-stop"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; stops downloading the message's file", "Stop Download")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: currentRoom.cancelFileTransfer(eventId)
}
},
State {
name: "raw"
when: true
PropertyChanges {
target: downloadButton
onClicked: fileDelegate.saveFileAs()
}
}
]
Kirigami.Icon {
id: ikon
source: model.fileMimetypeIcon
fallback: "unknown"
}
ColumnLayout {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
spacing: 0
QQC2.Label {
text: model.display
wrapMode: Text.Wrap
Layout.fillWidth: true
}
QQC2.Label {
id: sizeLabel
text: Controller.formatByteSize(content.info ? content.info.size : 0)
opacity: 0.7
Layout.fillWidth: true
}
}
QQC2.Button {
id: downloadButton
icon.name: "download"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download")
QQC2.ToolTip.visible: hovered
}
Component {
id: fileDialog
FileDialog {
fileMode: FileDialog.SaveFile
folder: StandardPaths.writableLocation(StandardPaths.DownloadLocation)
onAccepted: {
currentRoom.downloadFile(eventId, file)
}
}
}
}
}

View File

@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
import NeoChat.Dialog 1.0
import NeoChat.Menu.Timeline 1.0
TimelineContainer {
id: imageDelegate
onReplyClicked: ListView.view.goToEvent(eventID)
hoverComponent: hoverActions
onOpenContextMenu: openFileContext(model, imageDelegate)
property var content: model.content
readonly property bool isAnimated: contentType === "image/gif"
property bool openOnFinished: false
readonly property bool downloaded: progressInfo && progressInfo.completed
readonly property bool isThumbnail: !(content.info.thumbnail_info == null || content.thumbnailMediaId == null)
// readonly property var info: isThumbnail ? content.info.thumbnail_info : content.info
readonly property var info: content.info
readonly property string mediaId: isThumbnail ? content.thumbnailMediaId : content.mediaId
innerObject: Image {
id: img
Layout.maximumWidth: imageDelegate.contentMaxWidth
Layout.maximumHeight: imageDelegate.contentMaxWidth / imageDelegate.info.w * imageDelegate.info.h
Layout.preferredWidth: imageDelegate.info.w
Layout.preferredHeight: imageDelegate.info.h
source: model.mediaUrl
Image {
anchors.fill: parent
source: content.info["xyz.amorgan.blurhash"] ? ("image://blurhash/" + content.info["xyz.amorgan.blurhash"]) : ""
visible: parent.status !== Image.Ready
}
fillMode: Image.PreserveAspectFit
ToolTip.text: model.display
ToolTip.visible: hoverHandler.hovered
ToolTip.delay: Kirigami.Units.toolTipDelay
HoverHandler {
id: hoverHandler
}
Rectangle {
anchors.fill: parent
visible: progressInfo.active && !downloaded
color: "#BB000000"
ProgressBar {
anchors.centerIn: parent
width: parent.width * 0.8
from: 0
to: progressInfo.total
value: progressInfo.progress
}
}
Component {
id: fileDialog
FileDialog {
fileMode: FileDialog.SaveFile
folder: StandardPaths.writableLocation(StandardPaths.DownloadLocation)
onAccepted: {
currentRoom.downloadFile(eventId, file)
}
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onLongPressed: openFileContext(model, parent)
onTapped: {
img.ToolTip.hide()
fullScreenImage.open()
}
}
FullScreenImage {
id: fullScreenImage
filename: eventId
source: mediaUrl
blurhash: model.content.info["xyz.amorgan.blurhash"]
imageWidth: content.info.w
imageHeight: content.info.h
modelData: model
}
function downloadAndOpen() {
if (downloaded) {
openSavedFile()
} else {
openOnFinished = true
currentRoom.downloadFile(eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId))
}
}
function openSavedFile() {
if (UrlHelper.openUrl(progressInfo.localPath)) return;
if (UrlHelper.openUrl(progressInfo.localDir)) return;
}
}
}

View File

@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
RowLayout {
id: row
property var links: model.display.match(/(\bhttps?:\/\/[^\s\<\>\"\']*[^\s\<\>\"\'])/g)
// don't show previews for room links or user mentions
.filter(link => !link.includes("https://matrix.to"))
// remove ending fullstops and commas
.map(link => (link.length && [".", ","].includes(link[link.length-1])) ? link.substring(0, link.length-1) : link)
LinkPreviewer {
id: lp
url: links[0]
}
visible: lp.loaded && lp.title
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
visible: lp.loaded && lp.title
color: Kirigami.Theme.highlightColor
}
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)
}
Label {
text: lp.description
Layout.maximumWidth: messageDelegate.bubbleMaxWidth
Layout.fillWidth: true
wrapMode: Text.Wrap
visible: lp.loaded && lp.description
}
}
}

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import Qt.labs.qmlmodels 1.0
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
TimelineContainer {
id: messageDelegate
property bool isEmote: false
onOpenContextMenu: openMessageContext(model, label.selectedText, Controller.plainText(label.textDocument))
onReplyClicked: ListView.view.goToEvent(eventID)
hoverComponent: hoverActions
innerObject: ColumnLayout {
Layout.maximumWidth: messageDelegate.contentMaxWidth
RichLabel {
id: label
isEmote: messageDelegate.isEmote
}
Loader {
id: linkPreviewLoader
Layout.fillWidth: true
height: active ? item.implicitHeight : 0
active: !currentRoom.usesEncryption && model.display && model.display.includes("http")
visible: active
sourceComponent: LinkPreviewDelegate {
anchors.verticalCenter: parent.verticalCenter
}
}
}
}

View File

@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
Flow {
spacing: Kirigami.Units.largeSpacing
Repeater {
model: reaction ?? null
delegate: AbstractButton {
width: Math.max(implicitWidth, height)
contentItem: Label {
horizontalAlignment: Text.AlignHCenter
text: modelData.reaction + " " + modelData.count
}
padding: Kirigami.Units.smallSpacing
background: Kirigami.ShadowedRectangle {
color: checked ? Kirigami.Theme.positiveBackgroundColor : Kirigami.Theme.backgroundColor
radius: height / 2
shadow.size: Kirigami.Units.smallSpacing
shadow.color: !model.isHighlighted ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10)
border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15)
border.width: 1
}
checkable: true
checked: modelData.hasLocalUser
onToggled: currentRoom.toggleReaction(eventId, modelData.reaction)
hoverEnabled: true
ToolTip.visible: hovered
ToolTip.text: {
var text = "";
for (var i = 0; i < modelData.authors.length && i < 3; i++) {
if (i !== 0) {
if (i < modelData.authors.length - 1) {
text += ", "
} else {
text += i18nc("Separate the usernames of users", " and ")
}
}
text += modelData.authors[i].displayName
}
if (modelData.authors.length > 3) {
text += i18ncp("%1 is the number of other users", " and %1 other", " and %1 others", modelData.authors.length - 3)
}
text = i18ncp("%2 is the users who reacted and %3 the emoji that was given", "%2 reacted with %3", "%2 reacted with %3", modelData.authors.length, text, modelData.reaction)
return text
}
}
}
}

View File

@@ -11,26 +11,14 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
QQC2.ItemDelegate {
id: root
id: readMarkerDelegate
padding: Kirigami.Units.largeSpacing
topInset: Kirigami.Units.largeSpacing
topPadding: Kirigami.Units.largeSpacing * 2
// extraWidth defines how the delegate can grow after the listView gets very wide
readonly property int extraWidth: parent ? (parent.width >= Kirigami.Units.gridUnit * 46 ? Math.min((parent.width - Kirigami.Units.gridUnit * 46), Kirigami.Units.gridUnit * 20) : 0) : 0
readonly property int delegateMaxWidth: parent ? (Config.compactLayout ? parent.width - Kirigami.Units.largeSpacing * 2 : Math.min(parent.width - Kirigami.Units.largeSpacing * 2, Kirigami.Units.gridUnit * 40 + extraWidth)) : 0
property bool isTemporaryHighlighted: false
onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) temporaryHighlightTimer.start()
Timer {
id: temporaryHighlightTimer
interval: 1500
onTriggered: isTemporaryHighlighted = false
}
readonly property int extraWidth: messageListView.width >= Kirigami.Units.gridUnit * 46 ? Math.min((messageListView.width - Kirigami.Units.gridUnit * 46), Kirigami.Units.gridUnit * 20) : 0
readonly property int delegateMaxWidth: Config.compactLayout ? messageListView.width - Kirigami.Units.largeSpacing * 2 : Math.min(messageListView.width - Kirigami.Units.largeSpacing * 2, Kirigami.Units.gridUnit * 40 + extraWidth)
width: delegateMaxWidth
anchors.leftMargin: Kirigami.Units.largeSpacing
@@ -42,7 +30,7 @@ QQC2.ItemDelegate {
State {
name: "alignLeft"
AnchorChanges {
target: root
target: readMarkerDelegate
anchors.horizontalCenter: undefined
anchors.left: parent ? parent.left : undefined
}
@@ -50,7 +38,7 @@ QQC2.ItemDelegate {
State {
name: "alignCenter"
AnchorChanges {
target: root
target: readMarkerDelegate
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
anchors.left: undefined
}
@@ -71,25 +59,58 @@ QQC2.ItemDelegate {
}
background: Kirigami.ShadowedRectangle {
id: readMarkerBackground
color: {
if (root.isTemporaryHighlighted) {
return Kirigami.Theme.positiveBackgroundColor
} else {
return Kirigami.Theme.backgroundColor
}
}
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.View
opacity: root.isTemporaryHighlighted ? 1 : 0.6
color: Kirigami.Theme.backgroundColor
opacity: 0.6
radius: Kirigami.Units.smallSpacing
shadow.size: Kirigami.Units.smallSpacing
shadow.color: Qt.rgba(0.0, 0.0, 0.0, 0.10)
border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15)
border.width: 1
}
Behavior on color {
ColorAnimation {target: readMarkerBackground; duration: Kirigami.Units.veryLongDuration; easing.type: Easing.InOutCubic}
Timer {
id: makeMeDisapearTimer
interval: Kirigami.Units.humanMoment * 2
onTriggered: if (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden) {
currentRoom.markAllMessagesAsRead();
}
}
ListView.onPooled: makeMeDisapearTimer.stop()
ListView.onAdd: {
const view = ListView.view;
if (view.atYEnd) {
makeMeDisapearTimer.start()
}
}
// When the read marker is visible and we are at the end of the list,
// start the makeMeDisapearTimer
Connections {
target: ListView.view
function onAtYEndChanged() {
makeMeDisapearTimer.start();
}
}
ListView.onRemove: {
const view = ListView.view;
if (view.atYEnd) {
// easy case just mark everything as read
if (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden) {
currentRoom.markAllMessagesAsRead();
}
return;
}
// mark the last visible index
const lastVisibleIdx = lastVisibleIndex();
if (lastVisibleIdx < index) {
currentRoom.readMarkerEventId = sortedMessageEventModel.data(sortedMessageEventModel.index(lastVisibleIdx, 0), MessageEventModel.EventIdRole)
}
}
}

View File

@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 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
import NeoChat.Component.Timeline 1.0
MouseArea {
id: replyButton
Layout.fillWidth: true
implicitHeight: replyName.implicitHeight + (loader.item ? loader.item.height : 0) + Kirigami.Units.largeSpacing
implicitWidth: Math.min(contentMaxWidth, Math.max((loader.item ? loader.item.width : 0), replyName.implicitWidth)) + Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing
Component.onCompleted: {
parent.Layout.fillWidth = true;
parent.Layout.preferredWidth = Qt.binding(function() { return implicitWidth; })
parent.Layout.maximumWidth = Qt.binding(function() { return contentMaxWidth + Kirigami.Units.largeSpacing * 2; })
}
Rectangle {
id: replyLeftBorder
width: Kirigami.Units.smallSpacing
height: parent.height
x: Config.compactLayout ? Kirigami.Units.largeSpacing : 0
color: Kirigami.Theme.highlightColor
}
Kirigami.Avatar {
id: replyAvatar
anchors.left: replyLeftBorder.right
anchors.leftMargin: Kirigami.Units.smallSpacing
width: visible ? Kirigami.Units.gridUnit : 0
height: Kirigami.Units.gridUnit
sourceSize.width: width
sourceSize.height: height
Layout.alignment: Qt.AlignTop
visible: Config.showAvatarInTimeline
source: reply.author.avatarMediaId ? ("image://mxc/" + reply.author.avatarMediaId) : ""
name: reply.author.name || ""
color: reply.author.color
}
QQC2.Label {
id: replyName
anchors {
left: replyAvatar.right
leftMargin: Kirigami.Units.smallSpacing
right: parent.right
rightMargin: Kirigami.Units.smallSpacing
}
text: currentRoom.htmlSafeMemberName(reply.author.id)
color: reply.author.color
elide: Text.ElideRight
}
Loader {
id: loader
anchors.top: replyName.bottom
sourceComponent: {
switch (reply.type) {
case "image":
case "sticker":
return imageComponent;
case "message":
return textComponent;
// TODO support more types
default:
return textComponent;
}
}
Component {
id: textComponent
RichLabel {
id: replyText
textMessage: reply.display
textFormat: Text.RichText
width: Math.min(implicitWidth, contentMaxWidth - Kirigami.Units.largeSpacing * 3)
x: Kirigami.Units.smallSpacing * 3 + replyAvatar.width
}
}
Component {
id: imageComponent
Image {
readonly property var content: reply.content
readonly property bool isThumbnail: !(content.info.thumbnail_info == null || content.thumbnailMediaId == null)
// readonly property var info: isThumbnail ? content.info.thumbnail_info : content.info
readonly property var info: content.info
readonly property string mediaId: isThumbnail ? content.thumbnailMediaId : content.mediaId
source: "image://mxc/" + mediaId
width: contentMaxWidth * 0.75 - Kirigami.Units.smallSpacing * 5 - replyAvatar.width
height: reply.content.info.h / reply.content.info.w * width
x: Kirigami.Units.smallSpacing * 3 + replyAvatar.width
}
}
}
}

View File

@@ -2,55 +2,44 @@
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.neochat 1.0
import org.kde.kirigami 2.15 as Kirigami
/**
* @brief A component to show the rich display text of text message.
*/
TextEdit {
id: root
id: contentLabel
/**
* @brief The rich text message to display.
*/
property string textMessage
/**
* @brief Whether this message is replying to another.
*/
property bool isReply
/**
* @brief Regex for detecting a message with a single emoji.
*/
readonly property var isEmojiRegex: /^(<span style='.*'>)?(\u00a9|\u00ae|[\u20D0-\u2fff]|[\u3190-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+(<\/span>)?$/
/**
* @brief Whether the message is an emoji
*/
readonly property var isEmoji: isEmojiRegex.test(textMessage)
/**
* @brief Regex for detecting a message with a spoiler.
*/
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
/**
* @brief Whether a spoiler should be revealed.
*/
property bool isEmote: false
/* Turn all links which aren't already in <a> tags into <a> hyperlinks */
readonly property var linkRegex: /(href=["'])?(\b(https?):\/\/[^\s\<\>\"\'\\]+)/g
property string textMessage: model.display.includes("http")
? model.display.replace(linkRegex, function() {
if (arguments[1]) {
return arguments[0];
} else {
var l = arguments[2];
if ([".", ","].includes(l[l.length-1])) {
var link = l.substring(0, l.length-1);
var leftover = l[l.length-1];
return "<a href=\"" + link + "\">" + link + "</a>" + leftover;
}
return "<a href=\"" + l + "\">" + l + "</a>";
}
})
: model.display
property bool spoilerRevealed: !hasSpoiler.test(textMessage)
ListView.onReused: Qt.binding(() => !hasSpoiler.test(textMessage))
persistentSelection: true
Layout.fillWidth: true
// Work around QTBUG 93281
Component.onCompleted: if (text.includes("<img")) {
Controller.forceRefreshTextDocument(root.textDocument, root)
}
persistentSelection: true
text: "<style>
table {
@@ -59,9 +48,6 @@ table {
border-collapse: collapse;
border-style: solid;
}
code {
background-color:" + Kirigami.Theme.alternateBackgroundColor + ";
}
table th,
table td {
border: 1px solid black;
@@ -84,21 +70,18 @@ a{
background: " + Kirigami.Theme.textColor + ";
}
" : "") + "
</style>" + textMessage
</style>" + (isEmote ? "* <a href='https://matrix.to/#/" + author.id + "' style='color: " + author.color + "'>" + author.displayName + "</a> " : "") + textMessage + (isEdited ? (" <span style=\"color: " + Kirigami.Theme.disabledTextColor + "\">" + "<span style='font-size: " + Kirigami.Theme.defaultFont.pixelSize +"px'>" + i18n(" (edited)") + "</span>") : "")
color: Kirigami.Theme.textColor
selectedTextColor: Kirigami.Theme.highlightedTextColor
selectionColor: Kirigami.Theme.highlightColor
font {
pointSize: !root.isReply && root.isEmoji ? Kirigami.Theme.defaultFont.pointSize * 4 : Kirigami.Theme.defaultFont.pointSize
family: root.isEmoji ? 'emoji' : Kirigami.Theme.defaultFont.family
}
font.pointSize: model.reply === undefined && isEmoji.test(model.display) ? Kirigami.Theme.defaultFont.pointSize * 4 : Kirigami.Theme.defaultFont.pointSize
selectByMouse: !Kirigami.Settings.isMobile
readOnly: true
wrapMode: Text.Wrap
textFormat: Text.RichText
onLinkActivated: link => {
onLinkActivated: {
spoilerRevealed = true
RoomManager.openResource(link)
}

View File

@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import org.kde.kirigami 2.15 as Kirigami
Kirigami.Heading {
level: 4
text: model.showSection ? section : ""
color: Kirigami.Theme.disabledTextColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
topPadding: Kirigami.Units.largeSpacing * 2
bottomPadding: Kirigami.Units.smallSpacing
}

View File

@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
import NeoChat.Dialog 1.0
Control {
id: stateDelegate
// extraWidth defines how the delegate can grow after the listView gets very wide
readonly property int extraWidth: messageListView.width >= Kirigami.Units.gridUnit * 46 ? Math.min((messageListView.width - Kirigami.Units.gridUnit * 46), Kirigami.Units.gridUnit * 20) : 0
readonly property int delegateMaxWidth: Config.compactLayout ? messageListView.width: Math.min(messageListView.width, Kirigami.Units.gridUnit * 40 + extraWidth)
width: delegateMaxWidth
// anchors.leftMargin: Kirigami.Units.largeSpacing
// anchors.rightMargin: Kirigami.Units.largeSpacing
state: Config.compactLayout ? "alignLeft" : "alignCenter"
// Align left when in compact mode and center when using bubbles
states: [
State {
name: "alignLeft"
AnchorChanges {
target: stateDelegate
anchors.horizontalCenter: undefined
anchors.left: parent ? parent.left : undefined
}
},
State {
name: "alignCenter"
AnchorChanges {
target: stateDelegate
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
anchors.left: undefined
}
}
]
transitions: [
Transition {
AnchorAnimation{duration: Kirigami.Units.longDuration; easing.type: Easing.OutCubic}
}
]
height: sectionDelegate.height + rowLayout.height
SectionDelegate {
id: sectionDelegate
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
visible: model.showSection
height: visible ? implicitHeight : 0
}
RowLayout {
id: rowLayout
height: label.contentHeight
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing + (Config.compactLayout ? Kirigami.Units.largeSpacing * 1.25 : 0)
anchors.rightMargin: Kirigami.Units.largeSpacing
Kirigami.Avatar {
id: icon
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
Layout.alignment: Qt.AlignTop
name: author.displayName
source: author.avatarMediaId ? ("image://mxc/" + author.avatarMediaId) : ""
color: author.color
Component {
id: userDetailDialog
UserDetailDialog {}
}
MouseArea {
anchors.fill: parent
onClicked: userDetailDialog.createObject(ApplicationWindow.overlay, {room: currentRoom, user: author.object, displayName: author.displayName, avatarMediaId: author.avatarMediaId, avatarUrl: author.avatarUrl}).open()
}
}
Label {
id: label
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.preferredHeight: icon.height
wrapMode: Text.WordWrap
textFormat: Text.RichText
text: `<style>a {text-decoration: none;}</style><a href="https://matrix.to/#/${author.id}" style="color: ${author.color}">${currentRoom.htmlSafeMemberName(author.id)}</a> ${aggregateDisplay}`
onLinkActivated: userDetailDialog.createObject(ApplicationWindow.overlay, {room: currentRoom, user: author.object, displayName: author.displayName, avatarMediaId: author.avatarMediaId, avatarUrl: author.avatarUrl}).open()
}
}
}

View File

@@ -0,0 +1,319 @@
// SPDX-FileCopyrightText: 2020 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
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
import NeoChat.Component 1.0
import NeoChat.Dialog 1.0
QQC2.ItemDelegate {
id: timelineContainer
default property alias innerObject : column.children
// readonly property bool failed: marks == EventStatus.SendingFailed
property bool isEmote: false
property bool cardBackground: true
property bool isHighlighted: model.isHighlighted || isTemporaryHighlighted
property bool isTemporaryHighlighted: false
onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) temporaryHighlightTimer.start()
Timer {
id: temporaryHighlightTimer
interval: 1500
onTriggered: isTemporaryHighlighted = false
}
signal openContextMenu
// The bubble and delegate widths are allowed to grow once the ListView gets beyond a certain size
// extraWidth defines this as the excess after a certain ListView width, capped to a max value
readonly property int extraWidth: messageListView.width >= Kirigami.Units.gridUnit * 46 ? Math.min((messageListView.width - Kirigami.Units.gridUnit * 46), Kirigami.Units.gridUnit * 20) : 0
readonly property int bubbleMaxWidth: Kirigami.Units.gridUnit * 20 + extraWidth * 0.5
readonly property int delegateMaxWidth: Config.compactLayout ? messageListView.width : Math.min(messageListView.width, Kirigami.Units.gridUnit * 40 + extraWidth)
readonly property int contentMaxWidth: Config.compactLayout ? width - (Config.showAvatarInTimeline ? Kirigami.Units.gridUnit * 2 : 0) - Kirigami.Units.largeSpacing * 4 : Math.min(width - Kirigami.Units.gridUnit * 2 - Kirigami.Units.largeSpacing * 6, bubbleMaxWidth)
property bool showUserMessageOnRight: Config.showLocalMessagesOnRight &&
model.author.isLocalUser && !Config.compactLayout
signal openExternally()
signal replyClicked(string eventID)
Component.onCompleted: {
if (model.isReply && model.reply === undefined) {
messageEventModel.loadReply(sortedMessageEventModel.mapToSource(sortedMessageEventModel.index(model.index, 0)))
}
}
topPadding: 0
bottomPadding: 0
topInset: showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing)
leftInset: Kirigami.Units.smallSpacing
rightInset: Kirigami.Units.smallSpacing
width: delegateMaxWidth
height: sectionDelegate.height + Math.max(model.showAuthor ? avatar.height : 0, bubble.implicitHeight) + loader.height + (showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing))
background: Rectangle {
visible: timelineContainer.hovered
color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15)
radius: Kirigami.Units.smallSpacing
}
property Item hoverComponent
// show hover actions
onHoveredChanged: {
if (hovered && !Kirigami.Settings.isMobile) {
updateHoverComponent();
}
}
// updates the global hover component to point to this delegate, and update its position
function updateHoverComponent() {
if (hoverComponent) {
hoverComponent.delegate = timelineContainer
hoverComponent.bubble = bubble
hoverComponent.updateFunction = updateHoverComponent;
hoverComponent.event = model
}
}
state: Config.compactLayout ? "alignLeft" : "alignCenter"
// Align left when in compact mode and center when using bubbles
states: [
State {
name: "alignLeft"
AnchorChanges {
target: timelineContainer
anchors.horizontalCenter: undefined
anchors.left: parent ? parent.left : undefined
}
},
State {
name: "alignCenter"
AnchorChanges {
target: timelineContainer
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
anchors.left: undefined
}
}
]
transitions: [
Transition {
AnchorAnimation{duration: Kirigami.Units.longDuration; easing.type: Easing.OutCubic}
}
]
SectionDelegate {
id: sectionDelegate
width: parent.width
anchors.left: avatar.left
anchors.leftMargin: Kirigami.Units.smallSpacing
visible: model.showSection
height: visible ? implicitHeight : 0
}
Kirigami.Avatar {
id: avatar
width: visible || Config.showAvatarInTimeline ? Kirigami.Units.gridUnit * 2 + Kirigami.Units.smallSpacing * 2 : 0
height: width
padding: Kirigami.Units.smallSpacing
topInset: Kirigami.Units.smallSpacing
bottomInset: Kirigami.Units.smallSpacing
leftInset: Kirigami.Units.smallSpacing
rightInset: Kirigami.Units.smallSpacing
sourceSize.width: width
sourceSize.height: width
anchors {
top: sectionDelegate.bottom
topMargin: model.showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing)
left: parent.left
leftMargin: Kirigami.Units.smallSpacing
}
visible: model.showAuthor &&
Config.showAvatarInTimeline &&
(Config.compactLayout || !showUserMessageOnRight)
name: model.author.name ?? model.author.displayName
source: visible && model.author.avatarMediaId ? ("image://mxc/" + model.author.avatarMediaId) : ""
color: model.author.color
MouseArea {
anchors.fill: parent
onClicked: {
userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, {
room: currentRoom,
user: author.object,
displayName: author.displayName,
avatarMediaId: author.avatarMediaId,
avatarUrl: author.avatarUrl
}).open();
}
cursorShape: Qt.PointingHandCursor
}
}
QQC2.Control {
id: bubble
topPadding: Config.compactLayout ? Kirigami.Units.smallSpacing / 2 : Kirigami.Units.largeSpacing
bottomPadding: Config.compactLayout ? Kirigami.Units.mediumSpacing / 2 : Kirigami.Units.largeSpacing
leftPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing
rightPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing
hoverEnabled: true
anchors {
top: avatar.top
leftMargin: Kirigami.Units.smallSpacing
rightMargin: showUserMessageOnRight ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
}
// HACK: anchoring didn't reset anchors.right when switching from parent.right to undefined reliably
width: Config.compactLayout ? timelineContainer.width - (Config.showAvatarInTimeline ? Kirigami.Units.gridUnit * 2 : 0) + Kirigami.Units.largeSpacing * 2 : implicitWidth
state: showUserMessageOnRight ? "userMessageOnRight" : "userMessageOnLeft"
// states for anchor animations on window resize
// as setting anchors to undefined did not work reliably
states: [
State {
name: "userMessageOnRight"
AnchorChanges {
target: bubble
anchors.left: undefined
anchors.right: parent.right
}
},
State {
name: "userMessageOnLeft"
AnchorChanges {
target: bubble
anchors.left: avatar.right
anchors.right: undefined
}
}
]
transitions: [
Transition {
AnchorAnimation{duration: Kirigami.Units.longDuration; easing.type: Easing.OutCubic}
}
]
contentItem: ColumnLayout {
id: column
spacing: Kirigami.Units.smallSpacing
RowLayout {
id: rowLayout
spacing: Kirigami.Units.smallSpacing
visible: model.showAuthor && !isEmote
QQC2.Label {
id: nameLabel
Layout.maximumWidth: contentMaxWidth - timeLabel.implicitWidth - rowLayout.spacing
text: visible ? author.displayName : ""
textFormat: Text.PlainText
font.weight: Font.Bold
color: author.color
elide: Text.ElideRight
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 {
id: timeLabel
text: visible ? time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : ""
color: Kirigami.Theme.disabledTextColor
QQC2.ToolTip.visible: hoverHandler.hovered
QQC2.ToolTip.text: time.toLocaleString(Qt.locale(), Locale.LongFormat)
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
HoverHandler {
id: hoverHandler
}
}
}
Loader {
id: replyLoader
active: model.reply !== undefined
source: 'qrc:imports/NeoChat/Component/Timeline/ReplyComponent.qml'
visible: active
Connections {
target: replyLoader.item
function onClicked() {
replyClicked(reply.eventId)
}
}
}
}
background: Item {
Kirigami.ShadowedRectangle {
id: bubbleBackground
visible: cardBackground && !Config.compactLayout
anchors.fill: parent
color: {
if (model.author.isLocalUser) {
return Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15)
} else if (timelineContainer.isHighlighted) {
return Kirigami.Theme.positiveBackgroundColor
} else {
return Kirigami.Theme.backgroundColor
}
}
radius: Kirigami.Units.smallSpacing
shadow.size: Kirigami.Units.smallSpacing
shadow.color: timelineContainer.isHighlighted ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10)
border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15)
border.width: 1
Behavior on color {
ColorAnimation {target: bubbleBackground; duration: Kirigami.Units.veryLongDuration; easing.type: Easing.InOutCubic}
}
}
}
}
Loader {
id: loader
anchors {
left: bubble.left
right: parent.right
top: bubble.bottom
topMargin: active && Config.compactLayout ? 0 : Kirigami.Units.smallSpacing
}
height: active ? item.implicitHeight : 0
//Layout.bottomMargin: readMarker ? Kirigami.Units.smallSpacing : 0
active: eventType !== "state" && eventType !== "notice" && reaction != undefined && reaction.length > 0
visible: active
sourceComponent: ReactionDelegate { }
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: timelineContainer.openContextMenu()
}
TapHandler {
acceptedButtons: Qt.LeftButton
onLongPressed: timelineContainer.openContextMenu()
}
}

View File

@@ -0,0 +1,141 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtMultimedia 5.15
import Qt.labs.platform 1.1 as Platform
import org.kde.kirigami 2.13 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
import NeoChat.Dialog 1.0
import NeoChat.Menu.Timeline 1.0
TimelineContainer {
id: videoDelegate
onReplyClicked: ListView.view.goToEvent(eventID)
hoverComponent: hoverActions
property bool playOnFinished: false
readonly property bool downloaded: progressInfo && progressInfo.completed
property bool supportStreaming: true
readonly property int maxWidth: 1000 // TODO messageListView.width
onOpenContextMenu: openFileContext(model, vid)
onDownloadedChanged: {
if (downloaded) {
vid.source = progressInfo.localPath
}
if (downloaded && playOnFinished) {
playSavedFile()
playOnFinished = false
}
}
innerObject: Video {
id: vid
Layout.maximumWidth: videoDelegate.contentMaxWidth
Layout.fillWidth: true
Layout.maximumHeight: Kirigami.Units.gridUnit * 15
Layout.minimumHeight: Kirigami.Units.gridUnit * 5
Layout.preferredWidth: (model.content.info.w === undefined || model.content.info.w > videoDelegate.maxWidth) ? videoDelegate.maxWidth : content.info.w
Layout.preferredHeight: model.content.info.w === undefined ? (videoDelegate.maxWidth * 3 / 4) : (model.content.info.w > videoDelegate.maxWidth ? (model.content.info.h / model.content.info.w * videoDelegate.maxWidth) : model.content.info.h)
loops: MediaPlayer.Infinite
fillMode: VideoOutput.PreserveAspectFit
onDurationChanged: {
if (!duration) {
vid.supportStreaming = false;
}
}
onErrorChanged: {
if (error != MediaPlayer.NoError) {
vid.supportStreaming = false;
}
}
Image {
anchors.fill: parent
visible: vid.playbackState == MediaPlayer.StoppedState || vid.error != MediaPlayer.NoError
source: model.content.thumbnailMediaId ? "image://mxc/" + model.content.thumbnailMediaId : ""
fillMode: Image.PreserveAspectFit
}
Label {
anchors.centerIn: parent
visible: vid.playbackState == MediaPlayer.StoppedState || vid.error != MediaPlayer.NoError
color: "white"
text: i18n("Video")
font.pixelSize: 16
padding: 8
background: Rectangle {
radius: Kirigami.Units.smallSpacing
color: "black"
opacity: 0.3
}
}
Rectangle {
anchors.fill: parent
visible: progressInfo.active && !videoDelegate.downloaded
color: "#BB000000"
ProgressBar {
anchors.centerIn: parent
width: parent.width * 0.8
from: 0
to: progressInfo.total
value: progressInfo.progress
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: if (vid.supportStreaming || progressInfo.completed) {
if (vid.playbackState == MediaPlayer.PlayingState) {
vid.pause()
} else {
vid.play()
}
} else {
videoDelegate.downloadAndPlay()
}
}
}
function downloadAndPlay() {
if (vid.downloaded) {
playSavedFile()
} else {
playOnFinished = true
currentRoom.downloadFile(eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId))
}
}
function playSavedFile() {
vid.stop()
vid.play()
}
}

View File

@@ -0,0 +1,15 @@
module NeoChat.Component.Timeline
RichLabel 1.0 RichLabel.qml
TimelineContainer 1.0 TimelineContainer.qml
StateDelegate 1.0 StateDelegate.qml
SectionDelegate 1.0 SectionDelegate.qml
ImageDelegate 1.0 ImageDelegate.qml
FileDelegate 1.0 FileDelegate.qml
VideoDelegate 1.0 VideoDelegate.qml
ReactionDelegate 1.0 ReactionDelegate.qml
AudioDelegate 1.0 AudioDelegate.qml
EncryptedDelegate 1.0 EncryptedDelegate.qml
EventDelegate 1.0 EventDelegate.qml
MessageDelegate 1.0 MessageDelegate.qml
ReadMarkerDelegate 1.0 ReadMarkerDelegate.qml
LinkPreviewDelegate 1.0 LinkPreviewDelegate.qml

View File

@@ -6,7 +6,7 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Controls 2.15
import org.kde.kirigami 2.14 as Kirigami
import org.kde.neochat 1.0
@@ -15,7 +15,7 @@ Loader {
property string labelText: ""
active: visible
sourceComponent: QQC2.Pane {
sourceComponent: Pane {
id: typingPane
leftPadding: Kirigami.Units.largeSpacing
@@ -28,9 +28,6 @@ Loader {
id: fontMetrics
}
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
contentItem: RowLayout {
spacing: typingPane.spacing
Row {
@@ -89,7 +86,7 @@ Loader {
}
}
}
QQC2.Label {
Label {
id: typingLabel
elide: Text.ElideRight
text: root.labelText

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,7 @@
module NeoChat.Component
FullScreenImage 1.0 FullScreenImage.qml
ChatTextInput 1.0 ChatTextInput.qml
FancyEffectsContainer 1.0 FancyEffectsContainer.qml
TypingPane 1.0 TypingPane.qml
QuickSwitcher 1.0 QuickSwitcher.qml
ShimmerGradient 1.0 ShimmerGradient.qml

View File

@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
Kirigami.OverlaySheet {
id: root
parent: applicationWindow().overlay
title: i18n("Create a Room")
contentItem: Kirigami.FormLayout {
TextField {
id: roomNameField
Kirigami.FormData.label: i18n("Room Name")
onAccepted: roomTopicField.forceActiveFocus();
}
TextField {
id: roomTopicField
Kirigami.FormData.label: i18n("Room Topic")
onAccepted: okButton.forceActiveFocus();
}
Button {
id: okButton
text: i18nc("@action:button", "Ok")
onClicked: {
Controller.createRoom(roomNameField.text, roomTopicField.text);
root.close();
root.destroy();
}
}
}
}

View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: LGPL-2.1-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component.Emoji 1.0
QQC2.Popup {
id: root
signal react(string emoji)
modal: true
focus: true
closePolicy: QQC2.Popup.CloseOnEscape | QQC2.Popup.CloseOnPressOutsideParent
margins: 0
padding: Kirigami.Units.smallSpacing
implicitWidth: Kirigami.Units.gridUnit * 15
implicitHeight: Kirigami.Units.gridUnit * 20
contentItem: EmojiPicker {
onChosen: react(emoji)
}
}

View File

@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
@@ -9,7 +9,7 @@ import org.kde.kirigami 2.19 as Kirigami
import org.kde.neochat 1.0
Column {
id: root
id: emojiItem
property string emoji
property string description
@@ -23,7 +23,7 @@ Column {
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
text: root.emoji
text: emojiItem.emoji
font.family: "emoji"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 4
}
@@ -32,7 +32,7 @@ Column {
y: parent.height * 0.75
width: parent.width
height: parent.height * 0.25
text: root.description
text: emojiItem.description
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}

View File

@@ -1,13 +1,15 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQml 2.15
import org.kde.kirigami 2.19 as Kirigami
import org.kde.neochat 1.0
Row {
id: root
id: emojiRow
property alias model: repeater.model
anchors.horizontalCenter: parent.horizontalCenter
@@ -16,7 +18,7 @@ Row {
delegate: EmojiItem {
emoji: modelData.emoji
description: modelData.description
width: root.height
width: emojiRow.height
height: width
}
}

View File

@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
@@ -9,7 +9,7 @@ import org.kde.kirigami 2.19 as Kirigami
import org.kde.neochat 1.0
Column {
id: root
id: emojiSas
required property var model
@@ -25,12 +25,12 @@ Column {
EmojiRow {
anchors.horizontalCenter: parent.horizontalCenter
height: Kirigami.Units.gridUnit * 4
model: root.model.slice(0, 4)
model: emojiSas.model.slice(0, 4)
}
EmojiRow {
anchors.horizontalCenter: parent.horizontalCenter
height: Kirigami.Units.gridUnit * 4
model: root.model.slice(4, 7)
model: emojiSas.model.slice(4, 7)
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
@@ -38,13 +38,13 @@ Column {
anchors.bottom: parent.bottom
text: i18n("They match")
icon.name: "dialog-ok"
onClicked: root.accept()
onClicked: emojiSas.accept()
}
QQC2.Button {
anchors.bottom: parent.bottom
text: i18n("They don't match")
icon.name: "dialog-cancel"
onClicked: root.reject()
onClicked: emojiSas.reject()
}
}
}

View File

@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
@@ -10,8 +10,7 @@ import org.kde.kirigami 2.19 as Kirigami
import org.kde.neochat 1.0
Kirigami.Page {
id: root
id: dialog
title: i18n("Session Verification")
required property var session
@@ -19,31 +18,31 @@ Kirigami.Page {
Item {
anchors.fill: parent
VerificationCanceled {
visible: root.session.state === KeyVerificationSession.CANCELED
visible: dialog.session.state === KeyVerificationSession.CANCELED
anchors.centerIn: parent
reason: root.session.error
reason: dialog.session.error
}
EmojiSas {
anchors.centerIn: parent
visible: root.session.state === KeyVerificationSession.WAITINGFORVERIFICATION
model: root.session.sasEmojis
onReject: root.session.cancelVerification(KeyVerificationSession.MISMATCHED_SAS)
onAccept: root.session.sendMac()
visible: dialog.session.state === KeyVerificationSession.WAITINGFORVERIFICATION
model: dialog.session.sasEmojis
onReject: dialog.session.cancelVerification(KeyVerificationSession.MISMATCHED_SAS)
onAccept: dialog.session.sendMac()
}
Message {
visible: root.session.state === KeyVerificationSession.WAITINGFORREADY
visible: dialog.session.state === KeyVerificationSession.WAITINGFORREADY
anchors.centerIn: parent
icon: "security-medium-symbolic"
text: i18n("Waiting for device to accept verification.")
}
Message {
visible: root.session.state === KeyVerificationSession.INCOMING
visible: dialog.session.state === KeyVerificationSession.INCOMING
anchors.centerIn: parent
icon: "security-medium-symbolic"
text: i18n("Incoming key verification request from device **%1**", root.session.remoteDeviceId)
text: i18n("Incoming key verification request from device **%1**", dialog.session.remoteDeviceId)
}
Message {
visible: root.session.state === KeyVerificationSession.WAITINGFORMAC
visible: dialog.session.state === KeyVerificationSession.WAITINGFORMAC
anchors.centerIn: parent
icon: "security-medium-symbolic"
text: i18n("Waiting for other party to verify.")
@@ -51,36 +50,36 @@ Kirigami.Page {
Kirigami.BasicListItem {
id: emojiVerification
text: "Emoji Verification"
visible: root.session.state === KeyVerificationSession.READY
visible: dialog.session.state === KeyVerificationSession.READY
subtitle: i18n("Compare a set of emoji on both devices")
onClicked: {
root.session.sendStartSas()
dialog.session.sendStartSas()
}
}
Message {
visible: root.session.state === KeyVerificationSession.DONE
visible: dialog.session.state === KeyVerificationSession.DONE
anchors.centerIn: parent
text: i18n("Successfully verified device **%1**", root.session.remoteDeviceId)
text: i18n("Successfully verified device **%1**", dialog.session.remoteDeviceId)
icon: "security-high"
}
}
footer: QQC2.ToolBar {
visible: root.session.state === KeyVerificationSession.INCOMING
visible: dialog.session.state === KeyVerificationSession.INCOMING
QQC2.DialogButtonBox {
anchors.fill: parent
Item { Layout.fillWidth: true }
QQC2.Button {
text: i18n("Accept")
icon.name: "dialog-ok"
onClicked: root.session.sendReady()
onClicked: dialog.session.sendReady()
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
}
QQC2.Button {
text: i18n("Decline")
icon.name: "dialog-cancel"
onClicked: root.session.cancelVerification("m.user", "Declined")
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.RejectRole
onClicked: dialog.session.cancelVerification("m.user", "Declined")
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.CancelRole
}
}
}

View File

@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
@@ -9,8 +9,7 @@ import org.kde.kirigami 2.19 as Kirigami
import org.kde.neochat 1.0
Column {
id: root
id: message
required property string icon
required property string text
@@ -19,10 +18,10 @@ Column {
width: Kirigami.Units.iconSizes.enormous
height: width
anchors.horizontalCenter: parent.horizontalCenter
source: root.icon
source: message.icon
}
QQC2.Label {
text: root.text
text: message.text
textFormat: Text.MarkdownText
}
}

View File

@@ -1,20 +1,22 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQml 2.15
import org.kde.kirigami 2.19 as Kirigami
import org.kde.neochat 1.0
Message {
id: root
id: verificationCanceled
required property int reason
anchors.centerIn: parent
icon: "security-low"
text: {
switch(root.reason) {
switch(verificationCanceled.reason) {
case KeyVerificationSession.NONE:
return i18n("The session verification was canceled for unknown reason.");
case KeyVerificationSession.TIMEOUT:

View File

@@ -0,0 +1,7 @@
module NeoChat.Dialog.KeyVerification
KeyVerificationDialog 1.0 KeyVerificationDialog.qml
Message 1.0 Message.qml
VerificationCanceled 1.0 VerificationCanceled.qml
EmojiItem 1.0 EmojiItem.qml
EmojiRow 1.0 EmojiRow.qml
EmojiSas 1.0 EmojiSas.qml

View File

@@ -0,0 +1,176 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 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
import NeoChat.Component 1.0
Kirigami.OverlaySheet {
id: root
signal closed()
property var room
property var user
property string displayName: user.displayName
property string avatarMediaId: user.avatarMediaId
property string avatarUrl: user.avatarUrl
parent: applicationWindow().overlay
leftPadding: 0
rightPadding: 0
topPadding: 0
title: i18nc("@title:menu Account detail dialog", "Account detail")
contentItem: ColumnLayout {
spacing: 0
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.largeSpacing
Kirigami.Avatar {
Layout.preferredWidth: Kirigami.Units.iconSizes.huge
Layout.preferredHeight: Kirigami.Units.iconSizes.huge
name: displayName
source: avatarMediaId ? ("image://mxc/" + avatarMediaId) : ""
color: user.color
MouseArea {
anchors.fill: parent
onClicked: {
if (avatarMediaId) {
fullScreenImage.createObject(parent, {filename: displayName, source: room.urlToMxcUrl(avatarUrl)}).showFullScreen()
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
Kirigami.Heading {
level: 1
Layout.fillWidth: true
font.bold: true
elide: Text.ElideRight
wrapMode: Text.NoWrap
text: room.htmlSafeMemberName(user.id)
}
QQC2.Label {
Layout.fillWidth: true
text: i18n("Online")
color: Kirigami.Theme.disabledTextColor
}
Kirigami.Heading {
level: 5
text: user.id
}
}
}
QQC2.MenuSeparator {}
Kirigami.BasicListItem {
visible: user !== room.localUser
action: Kirigami.Action {
text: room.connection.isIgnored(user) ? i18n("Unignore this user") : i18n("Ignore this user")
icon.name: "im-invisible-user"
onTriggered: {
root.close()
room.connection.isIgnored(user) ? room.connection.removeFromIgnoredUsers(user) : room.connection.addToIgnoredUsers(user)
}
}
}
Kirigami.BasicListItem {
visible: user !== room.localUser && room.canSendState("kick") && room.containsUser(user.id)
action: Kirigami.Action {
text: i18n("Kick this user")
icon.name: "im-kick-user"
onTriggered: {
room.kickMember(user.id)
root.close()
}
}
}
Kirigami.BasicListItem {
visible: user !== room.localUser && room.canSendState("ban") && !room.isUserBanned(user.id)
action: Kirigami.Action {
text: i18n("Ban this user")
icon.name: "im-ban-user"
icon.color: Kirigami.Theme.negativeTextColor
onTriggered: {
room.ban(user.id)
root.close()
}
}
}
Kirigami.BasicListItem {
visible: user !== room.localUser && room.canSendState("ban") && room.isUserBanned(user.id)
action: Kirigami.Action {
text: i18n("Unban this user")
icon.name: "im-irc"
icon.color: Kirigami.Theme.negativeTextColor
onTriggered: {
room.unban(user.id)
root.close()
}
}
}
Kirigami.BasicListItem {
visible: user === room.localUser || room.canSendState("redact")
action: Kirigami.Action {
text: i18n("Delete recent messages by this user")
icon.name: "delete"
icon.color: Kirigami.Theme.negativeTextColor
onTriggered: {
room.deleteMessagesByUser(user.id)
}
}
}
Kirigami.BasicListItem {
visible: user !== room.localUser
action: Kirigami.Action {
text: i18n("Open a private chat")
icon.name: "document-send"
onTriggered: {
Controller.openOrCreateDirectChat(user);
root.close()
}
}
}
Component {
id: fullScreenImage
FullScreenImage {}
}
}
onSheetOpenChanged: {
if (!sheetOpen) {
closed()
}
}
}

View File

@@ -0,0 +1,10 @@
module NeoChat.Dialog
UserDetailDialog 1.0 UserDetailDialog.qml
LoginDialog 1.0 LoginDialog.qml
CreateRoomDialog 1.0 CreateRoomDialog.qml
AcceptInvitationDialog 1.0 AcceptInvitationDialog.qml
OpenFileDialog 1.0 OpenFileDialog.qml
ImageClipboardDialog 1.0 ImageClipboardDialog.qml
StartChatDialog 1.0 StartChatDialog.qml
EmojiDialog 1.0 EmojiDialog.qml
KeyVerificationDialog 1.0 KeyVerificationDialog.qml

View File

@@ -3,30 +3,32 @@
import Qt.labs.platform 1.1 as Labs
import QtQuick 2.15
import QtQuick.Controls 2.12 as QQC2
import QtQuick.Layouts 1.10
import org.kde.kirigami 2.15 as Kirigami
Labs.Menu {
id: root
id: editMenu
required property Item field
Labs.MenuItem {
enabled: root.field !== null && root.field.canUndo
enabled: editMenu.field !== null && editMenu.field.canUndo
text: i18nc("text editing menu action", "Undo")
shortcut: StandardKey.Undo
onTriggered: {
root.field.undo()
root.close()
editMenu.field.undo()
editMenu.close()
}
}
Labs.MenuItem {
enabled: root.field !== null && root.field.canRedo
enabled: editMenu.field !== null && editMenu.field.canRedo
text: i18nc("text editing menu action", "Redo")
shortcut: StandardKey.Redo
onTriggered: {
root.field.undo()
root.close()
editMenu.field.undo()
editMenu.close()
}
}
@@ -34,42 +36,42 @@ Labs.Menu {
}
Labs.MenuItem {
enabled: root.field !== null && root.field.selectedText
enabled: editMenu.field !== null && editMenu.field.selectedText
text: i18nc("text editing menu action", "Cut")
shortcut: StandardKey.Cut
onTriggered: {
root.field.cut()
root.close()
editMenu.field.cut()
editMenu.close()
}
}
Labs.MenuItem {
enabled: root.field !== null && root.field.selectedText
enabled: editMenu.field !== null && editMenu.field.selectedText
text: i18nc("text editing menu action", "Copy")
shortcut: StandardKey.Copy
onTriggered: {
root.field.copy()
root.close()
editMenu.field.copy()
editMenu.close()
}
}
Labs.MenuItem {
enabled: root.field !== null && root.field.canPaste
enabled: editMenu.field !== null && editMenu.field.canPaste
text: i18nc("text editing menu action", "Paste")
shortcut: StandardKey.Paste
onTriggered: {
root.field.paste()
root.close()
editMenu.field.paste()
editMenu.close()
}
}
Labs.MenuItem {
enabled: root.field !== null && root.field.selectedText !== ""
enabled: editMenu.field !== null && editMenu.field.selectedText !== ""
text: i18nc("text editing menu action", "Delete")
shortcut: ""
onTriggered: {
root.field.remove(root.field.selectionStart, root.field.selectionEnd)
root.close()
editMenu.field.remove(editMenu.field.selectionStart, editMenu.field.selectionEnd)
editMenu.close()
}
}
@@ -77,12 +79,12 @@ Labs.Menu {
}
Labs.MenuItem {
enabled: root.field !== null
enabled: editMenu.field !== null
text: i18nc("text editing menu action", "Select All")
shortcut: StandardKey.SelectAll
onTriggered: {
root.field.selectAll()
root.close()
editMenu.field.selectAll()
editMenu.close()
}
}
}

View File

@@ -5,9 +5,15 @@ import Qt.labs.platform 1.1 as Labs
import QtQuick 2.15
import QtQuick.Window 2.15
import QtQuick.Controls 2.12 as QQC2
import QtQuick.Layouts 1.10
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
import NeoChat.Dialog 1.0
import NeoChat.Page 1.0
import NeoChat.Panel 1.0
Labs.MenuBar {
Labs.Menu {
@@ -22,9 +28,7 @@ Labs.MenuBar {
text: i18nc("menu", "Configure NeoChat...")
shortcut: StandardKey.Preferences
onTriggered: pageStack.pushDialogLayer("qrc:/SettingsPage.qml", {
connection: Controller.activeConnection
}, {
onTriggered: pageStack.pushDialogLayer("qrc:/imports/NeoChat/Settings/SettingsPage.qml", {}, {
title: i18n("Configure")
})
}
@@ -40,12 +44,12 @@ Labs.MenuBar {
Labs.MenuItem {
text: i18nc("menu", "New Private Chat…")
enabled: pageStack.layers.currentItem.title !== i18n("Start a Chat") && AccountRegistry.accountCount > 0
onTriggered: pushReplaceLayer("qrc:/StartChatPage.qml", {connection: Controller.activeConnection})
enabled: pageStack.layers.currentItem.title !== i18n("Start a Chat") && Controller.accountCount > 0
onTriggered: pushReplaceLayer("qrc:/imports/NeoChat/Page/StartChatPage.qml", {connection: Controller.activeConnection})
}
Labs.MenuItem {
text: i18nc("menu", "New Group…")
enabled: pageStack.layers.currentItem.title !== i18n("Start a Chat") && AccountRegistry.accountCount > 0
enabled: pageStack.layers.currentItem.title !== i18n("Start a Chat") && Controller.accountCount > 0
shortcut: StandardKey.New
onTriggered: {
const dialog = createRoomDialog.createObject(root.overlay)
@@ -54,16 +58,7 @@ Labs.MenuBar {
}
Labs.MenuItem {
text: i18nc("menu", "Browse Chats…")
onTriggered: {
let dialog = pageStack.pushDialogLayer("qrc:/JoinRoomPage.qml", {connection: Controller.activeConnection}, {title: i18nc("@title", "Explore Rooms")})
dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => {
if (isJoined) {
RoomManager.enterRoom(Controller.activeConnection.room(roomId))
} else {
Controller.joinRoom(roomId)
}
})
}
onTriggered: pushReplaceLayer("qrc:/imports/NeoChat/Page/JoinRoomPage.qml", {connection: Controller.activeConnection})
}
}
EditMenu {

View File

@@ -3,73 +3,65 @@
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.19 as Kirigami
import org.kde.kirigamiaddons.labs.components 1.0 as KirigamiComponents
import org.kde.neochat 1.0
import NeoChat.Page 1.0
/**
* Context menu when clicking on a room in the room list
*/
Loader {
id: root
property NeoChatRoom room
property var room
signal closed()
Component {
id: regularMenu
QQC2.Menu {
QQC2.MenuItem {
Menu {
MenuItem {
id: newWindow
text: i18n("Open in New Window")
icon.name: "window-new"
onTriggered: RoomManager.openWindow(room);
visible: !Kirigami.Settings.isMobile
}
QQC2.MenuSeparator {
MenuSeparator {
visible: newWindow.visible
}
QQC2.MenuItem {
MenuItem {
text: room.isFavourite ? i18n("Remove from Favourites") : i18n("Add to Favourites")
icon.name: room.isFavourite ? "bookmark-remove" : "bookmark-new"
onTriggered: room.isFavourite ? room.removeTag("m.favourite") : room.addTag("m.favourite", 1.0)
}
QQC2.MenuItem {
MenuItem {
text: room.isLowPriority ? i18n("Reprioritize") : i18n("Deprioritize")
icon.name: room.isLowPriority ? "arrow-up" : "arrow-down"
onTriggered: room.isLowPriority ? room.removeTag("m.lowpriority") : room.addTag("m.lowpriority", 1.0)
}
QQC2.MenuItem {
MenuItem {
text: i18n("Mark as Read")
icon.name: "checkmark"
onTriggered: room.markAllMessagesAsRead()
}
QQC2.MenuItem {
text: room.isDirectChat() ? i18nc("@action:inmenu", "Copy user's Matrix ID to Clipboard") : i18nc("@action:inmenu", "Copy Address to Clipboard")
icon.name: "edit-copy"
onTriggered: if (room.isDirectChat()) {
Clipboard.saveText(room.directChatRemoteUser.id)
} else if (room.canonicalAlias.length === 0) {
MenuItem {
text: i18nc("@action:inmenu", "Copy Address to Clipboard")
onTriggered: if (room.canonicalAlias.length === 0) {
Clipboard.saveText(room.id)
} else {
Clipboard.saveText(room.canonicalAlias)
}
}
QQC2.Menu {
Menu {
title: i18n("Notification State")
QQC2.MenuItem {
MenuItem {
text: i18n("Follow Global Setting")
icon.name: "globe"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.Default
@@ -78,9 +70,8 @@ Loader {
room.pushNotificationState = PushNotificationState.Default
}
}
QQC2.MenuItem {
MenuItem {
text: i18nc("As in 'notify for all messages'","All")
icon.name: "notifications"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.All
@@ -89,9 +80,8 @@ Loader {
room.pushNotificationState = PushNotificationState.All
}
}
QQC2.MenuItem {
MenuItem {
text: i18nc("As in 'notify when the user is mentioned or the message contains a set keyword'","@Mentions and Keywords")
icon.name: "im-user"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.MentionKeyword
@@ -100,9 +90,8 @@ Loader {
room.pushNotificationState = PushNotificationState.MentionKeyword
}
}
QQC2.MenuItem {
MenuItem {
text: i18nc("As in 'do not notify for any messages'","Off")
icon.name: "notifications-disabled"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.Mute
@@ -113,17 +102,15 @@ Loader {
}
}
QQC2.MenuItem {
MenuItem {
text: i18n("Room Settings")
icon.name: "configure"
onTriggered: QQC2.ApplicationWindow.window.pageStack.pushDialogLayer('qrc:/Categories.qml', {room: room}, { title: i18n("Room Settings") })
onTriggered: ApplicationWindow.window.pageStack.pushDialogLayer('qrc:/imports/NeoChat/RoomSettings/Categories.qml', {room: room})
}
QQC2.MenuSeparator {}
MenuSeparator {}
QQC2.MenuItem {
MenuItem {
text: i18n("Leave Room")
icon.name: "go-previous"
onTriggered: RoomManager.leaveRoom(room)
}
@@ -158,10 +145,9 @@ Loader {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
KirigamiComponents.Avatar {
Kirigami.Avatar {
id: avatar
source: room.avatarMediaId ? ("image://mxc/" + room.avatarMediaId) : ""
name: room.displayName
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Layout.alignment: Qt.AlignTop
@@ -170,9 +156,9 @@ Loader {
level: 5
Layout.fillWidth: true
text: room.displayName
elide: Text.ElideRight
wrapMode: Text.WordWrap
}
QQC2.ToolButton {
ToolButton {
checked: room.isFavourite
checkable: true
icon.name: 'favorite'
@@ -180,35 +166,26 @@ Loader {
onClicked: room.isFavourite ? room.removeTag("m.favourite") : room.addTag("m.favourite", 1.0)
}
QQC2.ToolButton {
ToolButton {
icon.name: 'settings-configure'
onClicked: {
QQC2.ApplicationWindow.window.pageStack.pushDialogLayer('qrc:/Categories.qml', {room: room}, { title: i18n("Room Settings") })
drawer.close()
}
onClicked: ApplicationWindow.window.pageStack.pushDialogLayer('qrc:/imports/NeoChat/RoomSettings/Categories.qml', {room: room})
}
}
Kirigami.BasicListItem {
text: room.isLowPriority ? i18n("Reprioritize") : i18n("Deprioritize")
icon: room.isLowPriority ? "arrow-up" : "arrow-down"
onClicked: room.isLowPriority ? room.removeTag("m.lowpriority") : room.addTag("m.lowpriority", 1.0)
implicitHeight: visible ? Kirigami.Units.gridUnit * 3 : 0
}
Kirigami.BasicListItem {
text: i18n("Mark as Read")
icon: "checkmark"
onClicked: room.markAllMessagesAsRead()
implicitHeight: visible ? Kirigami.Units.gridUnit * 3 : 0
}
Kirigami.BasicListItem {
text: i18n("Leave Room")
icon: "go-previous"
onClicked: {
RoomManager.leaveRoom(room)
drawer.close()
}
onClicked: RoomManager.leaveRoom(room)
implicitHeight: visible ? Kirigami.Units.gridUnit * 3 : 0
}
}

View File

@@ -3,6 +3,7 @@
import QtQuick 2.7
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.15 as Controls
import org.kde.kirigami 2.14 as Kirigami
/**
@@ -13,9 +14,8 @@ import org.kde.kirigami 2.14 as Kirigami
* TODO add Android support
*/
Kirigami.Action {
id: root
icon.name: "emblem-shared-symbolic"
id: shareAction
iconName: "emblem-shared-symbolic"
text: i18n("Share")
tooltip: i18n("Share the selected media")
@@ -41,12 +41,12 @@ Kirigami.Action {
const purposeModel = Qt.createQmlObject('import org.kde.purpose 1.0 as Purpose;
Purpose.PurposeAlternativesModel {
pluginType: "Export"
}', root._instantiator);
}', shareAction._instantiator);
purposeModel.inputData = Qt.binding(function() {
return root.inputData;
return shareAction.inputData;
});
_instantiator.model = purposeModel;
root.visible = true;
shareAction.visible = true;
}
delegate: Kirigami.Action {
@@ -55,17 +55,17 @@ Purpose.PurposeAlternativesModel {
icon.name: model.iconName
onTriggered: {
doBeforeSharing();
applicationWindow().pageStack.pushDialogLayer('qrc:/ShareDialog.qml', {
title: root.tooltip,
applicationWindow().pageStack.pushDialogLayer('qrc:/imports/NeoChat/Menu/ShareDialog.qml', {
title: shareAction.tooltip,
index: index,
model: root._instantiator.model
model: shareAction._instantiator.model
})
}
}
onObjectAdded: (index, object) => {
onObjectAdded: {
object.index = index;
root.children.push(object)
shareAction.children.push(object)
}
onObjectRemoved: (index, object) => root.children = Array.from(root.children).filter(obj => obj.pluginId !== object.pluginId)
onObjectRemoved: shareAction.children = Array.from(shareAction.children).filter(obj => obj.pluginId !== object.pluginId)
}
}

View File

@@ -7,13 +7,13 @@
import QtQuick 2.7
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Controls 2.15 as Controls
import org.kde.purpose 1.0 as Purpose
import org.kde.notification 1.0
import org.kde.kirigami 2.14 as Kirigami
Kirigami.Page {
id: root
id: window
leftPadding: 0
rightPadding: 0
@@ -23,9 +23,9 @@ Kirigami.Page {
property alias index: jobView.index
property alias model: jobView.model
QQC2.Action {
Controls.Action {
shortcut: 'Escape'
onTriggered: root.closeDialog()
onTriggered: window.closeDialog()
}
Notification {
@@ -54,15 +54,15 @@ Kirigami.Page {
sharingSuccess.sendEvent();
Clipboard.saveText(jobView.output.url);
}
root.closeDialog()
window.closeDialog()
} else if (state === Purpose.PurposeJobController.Error) {
// Show failure notification
sharingFailed.sendEvent();
root.closeDialog()
window.closeDialog()
} else if (state === Purpose.PurposeJobController.Cancelled) {
// Do nothing
root.closeDialog()
window.closeDialog()
}
}
}

View File

@@ -2,12 +2,14 @@
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Controls 2.15
import Qt.labs.platform 1.1
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Dialog 1.0
import NeoChat.Menu 1.0
MessageDelegateContextMenu {
id: root
@@ -16,6 +18,7 @@ MessageDelegateContextMenu {
required property var file
required property var progressInfo
required property string mimeType
property list<Kirigami.Action> actions: [
Kirigami.Action {
@@ -40,7 +43,7 @@ MessageDelegateContextMenu {
text: i18n("Save As")
icon.name: "document-save"
onTriggered: {
var dialog = saveAsDialog.createObject(QQC2.ApplicationWindow.overlay)
var dialog = saveAsDialog.createObject(ApplicationWindow.overlay)
dialog.open()
dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(eventId)
}
@@ -49,40 +52,25 @@ MessageDelegateContextMenu {
text: i18n("Reply")
icon.name: "mail-replied-symbolic"
onTriggered: {
currentRoom.chatBoxReplyId = eventId
currentRoom.chatBoxEditId = ""
chatBoxHelper.replyToMessage(eventId, message, author);
root.closeFullscreen()
}
},
Kirigami.Action {
text: i18n("Copy")
icon.name: "edit-copy"
onTriggered: {
if(file.downloaded) {
Clipboard.setImage(progressInfo.localPath)
} else {
file.onDownloadedChanged.connect(function() {
Clipboard.setImage(progressInfo.localPath)
});
currentRoom.downloadFile(eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId))
}
}
},
Kirigami.Action {
visible: author.id === currentRoom.localUser.id || currentRoom.canSendState("redact")
text: i18n("Remove")
icon.name: "edit-delete-remove"
icon.color: "red"
onTriggered: applicationWindow().pageStack.pushDialogLayer("qrc:/RemoveSheet.qml", {room: currentRoom, eventId: eventId}, {
title: i18nc("@title", "Remove Message"),
width: Kirigami.Units.gridUnit * 25
})
onTriggered: {
currentRoom.redactEvent(eventId);
root.closeFullscreen()
}
},
Kirigami.Action {
text: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report")
icon.name: "dialog-warning-symbolic"
visible: author.id !== currentRoom.localUser.id
onTriggered: applicationWindow().pageStack.pushDialogLayer("qrc:/ReportSheet.qml", {room: currentRoom, eventId: eventId}, {
onTriggered: applicationWindow().pageStack.pushDialogLayer("qrc:/imports/NeoChat/Menu/Timeline/ReportSheet.qml", {room: currentRoom, eventId: eventId}, {
title: i18nc("@title", "Report Message"),
width: Kirigami.Units.gridUnit * 25
})
@@ -91,7 +79,7 @@ MessageDelegateContextMenu {
text: i18n("View Source")
icon.name: "code-context"
onTriggered: {
applicationWindow().pageStack.pushDialogLayer('qrc:/MessageSourceSheet.qml', {
applicationWindow().pageStack.pushDialogLayer('qrc:/imports/NeoChat/Menu/Timeline/MessageSourceSheet.qml', {
sourceText: root.source
}, {
title: i18n("Message Source"),
@@ -107,7 +95,7 @@ MessageDelegateContextMenu {
id: shareAction
inputData: {
'urls': [],
'mimeType': [root.file.mediaInfo.mimeType]
'mimeType': [mimeType]
}
property string filename: StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId);
@@ -117,7 +105,7 @@ MessageDelegateContextMenu {
Component.onCompleted: {
shareAction.inputData = {
urls: [filename],
mimeType: [root.file.mediaInfo.mimeType]
mimeType: [mimeType]
};
}
}
@@ -126,13 +114,11 @@ MessageDelegateContextMenu {
id: saveAsDialog
FileDialog {
fileMode: FileDialog.SaveFile
folder: Config.lastSaveDirectory.length > 0 ? Config.lastSaveDirectory : StandardPaths.writableLocation(StandardPaths.DownloadLocation)
folder: StandardPaths.writableLocation(StandardPaths.DownloadLocation)
onAccepted: {
if (!currentFile) {
return;
}
Config.lastSaveDirectory = folder
Config.save()
currentRoom.downloadFile(eventId, currentFile)
}
}

View File

@@ -6,20 +6,21 @@ 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.kirigamiaddons.labs.components 1.0 as KirigamiComponents
import org.kde.neochat 1.0
import NeoChat.Dialog 1.0
Loader {
id: root
id: loadRoot
required property var author
required property string message
required property string eventId
property var eventType
property string eventType: ""
property string formattedBody: ""
required property string source
property string selectedText: ""
required property string plainText
property string htmlText: undefined
required property string plainMessage
property list<Kirigami.Action> nestedActions
@@ -27,54 +28,31 @@ Loader {
Kirigami.Action {
text: i18n("Edit")
icon.name: "document-edit"
onTriggered: {
currentRoom.chatBoxEditId = eventId;
currentRoom.chatBoxReplyId = "";
}
visible: author.id === Controller.activeConnection.localUserId && (root.eventType === DelegateType.Emote || root.eventType === DelegateType.Message)
onTriggered: chatBoxHelper.edit(message, formattedBody, eventId);
visible: eventType.length > 0 && author.id === Controller.activeConnection.localUserId && (eventType === "emote" || eventType === "message")
},
Kirigami.Action {
text: i18n("Reply")
icon.name: "mail-replied-symbolic"
onTriggered: {
currentRoom.chatBoxReplyId = eventId;
currentRoom.chatBoxEditId = "";
}
},
Kirigami.Action {
text: i18nc("@action:inmenu As in 'Forward this message'", "Forward")
icon.name: "mail-forward-symbolic"
onTriggered: {
let page = applicationWindow().pageStack.pushDialogLayer("qrc:/ChooseRoomDialog.qml", {}, {
title: i18nc("@title", "Forward Message"),
width: Kirigami.Units.gridUnit * 25
})
page.chosen.connect(function(targetRoomId) {
Controller.activeConnection.room(targetRoomId).postHtmlMessage(root.plainText, root.htmlText ? root.htmlText : root.plainText)
page.closeDialog()
})
}
onTriggered: chatBoxHelper.replyToMessage(eventId, message, author);
},
Kirigami.Action {
visible: author.id === currentRoom.localUser.id || currentRoom.canSendState("redact")
text: i18n("Remove")
icon.name: "edit-delete-remove"
icon.color: "red"
onTriggered: applicationWindow().pageStack.pushDialogLayer("qrc:/RemoveSheet.qml", {room: currentRoom, eventId: eventId}, {
title: i18nc("@title", "Remove Message"),
width: Kirigami.Units.gridUnit * 25
})
onTriggered: currentRoom.redactEvent(eventId);
},
Kirigami.Action {
text: i18n("Copy")
icon.name: "edit-copy"
onTriggered: Clipboard.saveText(root.selectedText === "" ? root.plainText : root.selectedText)
onTriggered: Clipboard.saveText(loadRoot.selectedText === "" ? loadRoot.plainMessage : loadRoot.selectedText)
},
Kirigami.Action {
text: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report")
icon.name: "dialog-warning-symbolic"
visible: author.id !== currentRoom.localUser.id
onTriggered: applicationWindow().pageStack.pushDialogLayer("qrc:/ReportSheet.qml", {room: currentRoom, eventId: eventId}, {
onTriggered: applicationWindow().pageStack.pushDialogLayer("qrc:/imports/NeoChat/Menu/Timeline/ReportSheet.qml", {room: currentRoom, eventId: eventId}, {
title: i18nc("@title", "Report Message"),
width: Kirigami.Units.gridUnit * 25
})
@@ -83,20 +61,13 @@ Loader {
text: i18n("View Source")
icon.name: "code-context"
onTriggered: {
applicationWindow().pageStack.pushDialogLayer('qrc:/MessageSourceSheet.qml', {
sourceText: root.eventSource
applicationWindow().pageStack.pushDialogLayer('qrc:/imports/NeoChat/Menu/Timeline/MessageSourceSheet.qml', {
sourceText: loadRoot.source
}, {
title: i18n("Message Source"),
width: Kirigami.Units.gridUnit * 25
});
}
},
Kirigami.Action {
text: i18n("Copy Link")
icon.name: "edit-copy"
onTriggered: {
Clipboard.saveText("https://matrix.to/#/" + currentRoom.id + "/" + root.eventId)
}
}
]
@@ -106,7 +77,7 @@ Loader {
QQC2.Menu {
id: menu
Instantiator {
model: root.nestedActions
model: loadRoot.nestedActions
delegate: QQC2.Menu {
id: menuItem
visible: modelData.visible
@@ -131,12 +102,12 @@ Loader {
}
Repeater {
model: root.actions
model: loadRoot.actions
QQC2.MenuItem {
id: menuItem
visible: modelData.visible
action: modelData
onClicked: root.item.close();
onClicked: loadRoot.item.close();
}
}
QQC2.Menu {
@@ -150,7 +121,7 @@ Loader {
Instantiator {
model: WebShortcutModel {
id: webshortcutmodel
selectedText: root.selectedText ? root.selectedText : root.plainText
selectedText: loadRoot.selectedText ? loadRoot.selectedText : loadRoot.plainMessage
onOpenUrl: RoomManager.visitNonMatrix(url)
}
delegate: QQC2.MenuItem {
@@ -158,13 +129,12 @@ Loader {
icon.name: model.decoration
onTriggered: webshortcutmodel.trigger(model.edit)
}
onObjectAdded: (index, object) => webshortcutmenu.insertItem(0, object)
onObjectAdded: webshortcutmenu.insertItem(0, object)
}
QQC2.MenuSeparator {}
QQC2.MenuItem {
text: i18n("Configure Web Shortcuts...")
icon.name: "configure"
visible: !Controller.isFlatpak
onTriggered: webshortcutmodel.configureWebShortcuts()
}
}
@@ -222,7 +192,7 @@ Loader {
text: modelData.text
onClicked: {
modelData.triggered()
root.item.close();
loadRoot.item.close();
}
implicitHeight: visible ? Kirigami.Units.gridUnit * 3 : 0
}
@@ -238,11 +208,11 @@ Loader {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
KirigamiComponents.Avatar {
Kirigami.Avatar {
id: avatar
source: author.avatarSource
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
source: author.avatarMediaId ? ("image://mxc/" + author.avatarMediaId) : ""
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Layout.alignment: Qt.AlignTop
}
ColumnLayout {
@@ -254,7 +224,7 @@ Loader {
wrapMode: Text.WordWrap
}
QQC2.Label {
text: plainText
text: message
Layout.fillWidth: true
wrapMode: Text.WordWrap
@@ -285,7 +255,7 @@ Loader {
onClicked: {
currentRoom.toggleReaction(eventId, modelData);
root.item.close();
loadRoot.item.close();
}
}
}
@@ -295,7 +265,7 @@ Loader {
}
Repeater {
id: listViewAction
model: root.actions
model: loadRoot.actions
Kirigami.BasicListItem {
icon: modelData.icon.name
@@ -305,14 +275,14 @@ Loader {
text: modelData.text
onClicked: {
modelData.triggered()
root.item.close();
loadRoot.item.close();
}
implicitHeight: visible ? Kirigami.Units.gridUnit * 3 : 0
}
}
Repeater {
model: root.nestedActions
model: loadRoot.nestedActions
Kirigami.BasicListItem {
action: modelData

View File

@@ -1,9 +1,9 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Controls 2.15
import org.kde.syntaxhighlighting 1.0
import org.kde.kirigami 2.15 as Kirigami
@@ -17,16 +17,16 @@ Kirigami.Page {
rightPadding: 0
bottomPadding: 0
title: i18n("Event Source")
title: i18n("Message Source")
QQC2.ScrollView {
ScrollView {
anchors.fill: parent
contentWidth: availableWidth
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
QQC2.TextArea {
TextArea {
id: sourceTextArea
text: sourceText
readOnly: true

View File

@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
@@ -7,12 +7,10 @@ import QtQuick.Layouts 1.15
import org.kde.kirigami 2.20 as Kirigami
import org.kde.neochat 1.0
Kirigami.Page {
id: root
id: reportSheet
property NeoChatRoom room
property var room
property string eventId
title: i18n("Report Message")
@@ -35,14 +33,14 @@ Kirigami.Page {
icon.name: "dialog-warning-symbolic"
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
onClicked: {
root.room.reportEvent(eventId, reason.text)
root.closeDialog()
reportSheet.room.reportEvent(eventId, reason.text)
reportSheet.closeDialog()
}
}
QQC2.Button {
text: i18nc("@action", "Cancel")
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.RejectRole
onClicked: root.closeDialog()
onClicked: reportSheet.closeDialog()
}
}
}

View File

@@ -0,0 +1,5 @@
module NeoChat.Menu.Timeline
MessageDelegateContextMenu 1.0 MessageDelegateContextMenu.qml
FileDelegateContextMenu 1.0 FileDelegateContextMenu.qml
MessageSourceSheet 1.0 MessageSourceSheet.qml
ReportSheet 1.0 ReportSheet.qml

View File

@@ -0,0 +1,6 @@
module NeoChat.Menu
RoomListContextMenu 1.0 RoomListContextMenu.qml
GlobalMenu 1.0 GlobalMenu.qml
EditMenu 1.0 EditMenu.qml
ShareAction 1.0 ShareAction.qml
ShareDialog 1.0 ShareDialog.qml

View File

@@ -7,7 +7,6 @@ import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1 as Platform
import org.kde.kirigami 2.15 as Kirigami
import org.kde.kirigamiaddons.labs.components 1.0 as KirigamiComponents
import org.kde.kquickimageeditor 1.0 as KQuickImageEditor
Kirigami.Page {
@@ -35,14 +34,14 @@ Kirigami.Page {
left: Kirigami.Action {
id: undoAction
text: i18nc("@action:button Undo modification", "Undo")
icon.name: "edit-undo"
iconName: "edit-undo"
onTriggered: imageDoc.undo();
visible: imageDoc.edited
}
main: Kirigami.Action {
id: okAction
text: i18nc("@action:button Accept image modification", "Accept")
icon.name: "dialog-ok"
iconName: "dialog-ok"
onTriggered: {
let newPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + (new Date()).getTime() + "." + imagePath.split('.').pop();
if (imageDoc.saveAs(newPath)) {;
@@ -122,7 +121,7 @@ Kirigami.Page {
display: QQC2.Button.TextBesideIcon
actions: [
Kirigami.Action {
icon.name: rootEditorView.resizing ? "dialog-cancel" : "transform-crop"
iconName: rootEditorView.resizing ? "dialog-cancel" : "transform-crop"
text: rootEditorView.resizing ? i18n("Cancel") : i18nc("@action:button Crop an image", "Crop");
onTriggered: {
resizeRectangle.width = editImage.paintedWidth
@@ -138,31 +137,31 @@ Kirigami.Page {
}
},
Kirigami.Action {
icon.name: "dialog-ok"
iconName: "dialog-ok"
visible: rootEditorView.resizing
text: i18nc("@action:button Crop an image", "Crop");
onTriggered: rootEditorView.crop();
},
Kirigami.Action {
icon.name: "object-rotate-left"
iconName: "object-rotate-left"
text: i18nc("@action:button Rotate an image to the left", "Rotate left");
onTriggered: imageDoc.rotate(-90);
visible: !rootEditorView.resizing
},
Kirigami.Action {
icon.name: "object-rotate-right"
iconName: "object-rotate-right"
text: i18nc("@action:button Rotate an image to the right", "Rotate right");
onTriggered: imageDoc.rotate(90);
visible: !rootEditorView.resizing
},
Kirigami.Action {
icon.name: "object-flip-vertical"
iconName: "object-flip-vertical"
text: i18nc("@action:button Mirror an image vertically", "Flip");
onTriggered: imageDoc.mirror(false, true);
visible: !rootEditorView.resizing
},
Kirigami.Action {
icon.name: "object-flip-horizontal"
iconName: "object-flip-horizontal"
text: i18nc("@action:button Mirror an image horizontally", "Mirror");
onTriggered: imageDoc.mirror(true, false);
visible: !rootEditorView.resizing
@@ -171,7 +170,7 @@ Kirigami.Page {
}
}
footer: KirigamiComponents.Banner {
footer: Kirigami.InlineMessage {
id: msg
type: Kirigami.MessageType.Error
showCloseButton: true

View File

@@ -2,19 +2,17 @@
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.kirigamiaddons.delegates 1.0 as Delegates
import org.kde.kirigamiaddons.labs.components 1.0 as KirigamiComponents
import org.kde.neochat 1.0
Kirigami.ScrollablePage {
id: root
property NeoChatRoom room
property var room
title: i18n("Invite a User")
@@ -38,7 +36,7 @@ Kirigami.ScrollablePage {
onAccepted: userDictListModel.search()
}
QQC2.Button {
Button {
visible: identifierField.isUserID
text: i18n("Add")
@@ -51,6 +49,9 @@ Kirigami.ScrollablePage {
}
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
id: userDictListView
clip: true
@@ -70,50 +71,59 @@ Kirigami.ScrollablePage {
text: i18n("No users available")
}
delegate: Delegates.RoundedItemDelegate {
delegate: Kirigami.AbstractListItem {
id: delegate
required property string userID
required property string name
required property string avatar
property bool inRoom: room && room.containsUser(userID)
text: name
topPadding: Kirigami.Units.largeSpacing
bottomPadding: Kirigami.Units.largeSpacing
contentItem: RowLayout {
KirigamiComponents.Avatar {
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
source: delegate.avatar ? ("image://mxc/" + delegate.avatar) : ""
name: delegate.name
Kirigami.Avatar {
Layout.preferredWidth: height
Layout.fillHeight: true
source: avatar ? ("image://mxc/" + avatar) : ""
name: name
}
Delegates.SubtitleContentItem {
itemDelegate: delegate
subtitle: delegate.userID
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
QQC2.ToolButton {
id: inviteButton
icon.name: "document-send"
text: i18n("Send invitation")
checkable: true
checked: inRoom
opacity: inRoom ? 0.5 : 1
spacing: 0
onToggled: {
if (inRoom) {
checked = true
} else {
room.inviteToRoom(delegate.userID);
applicationWindow().pageStack.layers.pop();
}
Kirigami.Heading {
Layout.fillWidth: true
Layout.fillHeight: true
level: 3
text: name
textFormat: Text.PlainText
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
QQC2.ToolTip.text: !inRoom ? text : i18n("User is either already a member or has been invited")
QQC2.ToolTip.visible: inviteButton.hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
Label {
Layout.fillWidth: true
Layout.fillHeight: true
text: userID
textFormat: Text.PlainText
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
}
ToolButton {
visible: !inRoom
icon.name: "document-send"
text: i18n("Send invitation")
onClicked: {
room.inviteToRoom(userID);
applicationWindow().pageStack.layers.pop();
}
}
}
}

View File

@@ -0,0 +1,162 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0
import NeoChat.Component 1.0
Kirigami.ScrollablePage {
id: root
property var connection
property alias keyword: identifierField.text
property string server
title: i18n("Explore Rooms")
Component.onCompleted: identifierField.forceActiveFocus()
header: Control {
padding: Kirigami.Units.largeSpacing
contentItem: RowLayout {
Kirigami.SearchField {
id: identifierField
property bool isRoomAlias: text.match(/#(.+):(.+)/g)
property var room: isRoomAlias ? connection.roomByAlias(text) : null
property bool isJoined: room != null
Layout.fillWidth: true
placeholderText: i18n("Find a room...")
}
Button {
id: joinButton
visible: identifierField.isRoomAlias
text: identifierField.isJoined ? i18n("View") : i18n("Join")
highlighted: true
onClicked: {
if (!identifierField.isJoined) {
Controller.joinRoom(identifierField.text);
// When joining the room, the room will be opened
}
applicationWindow().pageStack.layers.pop();
}
}
ComboBox {
Layout.maximumWidth: 120
id: serverField
editable: currentIndex == 1
model: [i18n("Local"), i18n("Global"), "matrix.org"]
onCurrentIndexChanged: {
if (currentIndex == 0) {
server = ""
} else if (currentIndex == 2) {
server = "matrix.org"
}
}
Keys.onReturnPressed: {
if (currentIndex == 1) {
server = editText
}
}
}
}
}
ListView {
id: publicRoomsListView
clip: true
model: PublicRoomListModel {
id: publicRoomListModel
connection: root.connection
server: root.server
keyword: root.keyword
}
onContentYChanged: {
if(publicRoomListModel.hasMore && contentHeight - contentY < publicRoomsListView.height + 200)
publicRoomListModel.next();
}
delegate: Kirigami.AbstractListItem {
property bool justJoined: false
width: publicRoomsListView.width
onClicked: {
if (!isJoined) {
Controller.joinRoom(roomID)
justJoined = true;
} else {
RoomManager.enterRoom(connection.room(roomID))
}
applicationWindow().pageStack.layers.pop();
}
contentItem: RowLayout {
Kirigami.Avatar {
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
source: model.avatar ? ("image://mxc/" + model.avatar) : ""
name: name
}
ColumnLayout {
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
Kirigami.Heading {
Layout.fillWidth: true
level: 4
text: name
font.bold: true
textFormat: Text.PlainText
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
Label {
visible: isJoined || justJoined
text: i18n("Joined")
color: Kirigami.Theme.linkColor
}
}
Label {
Layout.fillWidth: true
visible: text
text: topic ? topic.replace(/(\r\n\t|\n|\r\t)/gm," ") : ""
textFormat: Text.PlainText
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
RowLayout {
Layout.fillWidth: true
Kirigami.Icon {
source: "user"
color: Kirigami.Theme.disabledTextColor
implicitHeight: Kirigami.Units.iconSizes.small
implicitWidth: Kirigami.Units.iconSizes.small
}
Label {
text: memberCount + " " + (alias ?? roomID)
color: Kirigami.Theme.disabledTextColor
elide: Text.ElideRight
Layout.fillWidth: true
}
}
}
}
}
}
}

View File

@@ -2,10 +2,10 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.12 as QQC2
import org.kde.kirigami 2.19 as Kirigami
Kirigami.Page {
title: i18n("Loading…")
Kirigami.LoadingPlaceholder {
id: loadingIndicator
anchors.centerIn: parent

View File

@@ -0,0 +1,395 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import QtQml.Models 2.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.kitemmodels 1.0
import org.kde.neochat 1.0
import NeoChat.Component 1.0
import NeoChat.Menu 1.0
Kirigami.ScrollablePage {
header: ColumnLayout {
visible: !page.collapsedMode
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
ListView {
id: spaceList
property string activeSpaceId: ''
orientation: Qt.Horizontal
spacing: Kirigami.Units.largeSpacing
clip:true
visible: spaceList.count > 0
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Layout.fillWidth: true
model: SortFilterSpaceListModel {
id: sortFilterSpaceListModel
sourceModel: RoomListModel {
id: spaceListModel
connection: Controller.activeConnection
}
}
header: QQC2.Control {
contentItem: QQC2.RoundButton {
id: homeButton
flat: true
padding: Kirigami.Units.gridUnit / 2
icon.name: "home"
text: i18nc("@action:button", "Show All Rooms")
display: QQC2.AbstractButton.IconOnly
onClicked: {
sortFilterRoomListModel.activeSpaceId = "";
spaceList.activeSpaceId = '';
listView.positionViewAtIndex(0, ListView.Beginning);
}
QQC2.ToolTip {
text: homeButton.text
}
}
}
delegate: QQC2.Control {
required property string avatar
required property var currentRoom
required property int index
required property string id
implicitWidth: ListView.view.headerItem.implicitWidth
implicitHeight: ListView.view.headerItem.implicitHeight
contentItem: Kirigami.Avatar {
actions.main: Kirigami.Action {
onTriggered: {
spaceList.activeSpaceId = id;
sortFilterRoomListModel.activeSpaceId = id;
}
}
QQC2.ToolTip {
text: currentRoom.displayName
}
source: avatar !== "" ? "image://mxc/" + avatar : ""
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
}
id: page
title: i18n("Rooms")
property var enteredRoom
property bool collapsedMode: Config.roomListPageWidth === applicationWindow().collapsedPageWidth && applicationWindow().shouldUseSidebars
verticalScrollBarPolicy: collapsedMode ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded
onCollapsedModeChanged: if (collapsedMode) {
sortFilterRoomListModel.filterText = "";
}
Connections {
target: RoomManager
function onCurrentRoomChanged() {
itemSelection.setCurrentIndex(roomListModel.index(roomListModel.indexForRoom(RoomManager.currentRoom), 0), ItemSelectionModel.SelectCurrent)
}
}
function goToNextRoom() {
do {
listView.incrementCurrentIndex();
} while (!listView.currentItem.visible && listView.currentIndex === listView.count)
listView.currentItem.action.trigger();
}
function goToPreviousRoom() {
do {
listView.decrementCurrentIndex();
} while (!listView.currentItem.visible && listView.currentIndex !== 0)
listView.currentItem.action.trigger();
}
titleDelegate: collapsedMode ? empty : searchField
Component {
id: empty
Item {}
}
Component {
id: searchField
Kirigami.SearchField {
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
Layout.fillHeight: true
Layout.fillWidth: true
onTextChanged: sortFilterRoomListModel.filterText = text
KeyNavigation.tab: listView
}
}
ListView {
id: listView
activeFocusOnTab: true
clip: accountList.count > 1
header: QQC2.ItemDelegate {
visible: page.collapsedMode
action: Kirigami.Action {
id: enterRoomAction
onTriggered: quickView.item.open();
}
topPadding: Kirigami.Units.largeSpacing
leftPadding: Kirigami.Units.largeSpacing
rightPadding: Kirigami.Units.largeSpacing
bottomPadding: Kirigami.Units.largeSpacing
width: visible ? page.width : 0
height: visible ? Kirigami.Units.gridUnit * 2 : 0
Kirigami.Icon {
anchors.centerIn: parent
width: 22
height: 22
source: "search"
}
Kirigami.Separator {
width: parent.width
anchors.bottom: parent.bottom
}
}
Layout.fillWidth: true
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - (Kirigami.Units.largeSpacing * 4)
visible: listView.count == 0
text: sortFilterRoomListModel.filterText.length > 0 ? i18n("No rooms found") : i18n("Join some rooms to get started")
helpfulAction: Kirigami.Action {
icon.name: sortFilterRoomListModel.filterText.length > 0 ? "search" : "list-add"
text: sortFilterRoomListModel.filterText.length > 0 ? i18n("Search in room directory") : i18n("Explore rooms")
onTriggered: pageStack.layers.push("qrc:/imports/NeoChat/Page/JoinRoomPage.qml", {
connection: Controller.activeConnection,
keyword: sortFilterRoomListModel.filterText
})
}
}
ItemSelectionModel {
id: itemSelection
model: roomListModel
onCurrentChanged: {
listView.currentIndex = sortFilterRoomListModel.mapFromSource(current).row
}
}
model: SortFilterRoomListModel {
id: sortFilterRoomListModel
sourceModel: RoomListModel {
id: roomListModel
connection: Controller.activeConnection
}
roomSortOrder: Config.mergeRoomList ? SortFilterRoomListModel.LastActivity : SortFilterRoomListModel.Categories
onLayoutChanged: {
listView.currentIndex = sortFilterRoomListModel.mapFromSource(itemSelection.currentIndex).row
}
}
section.property: sortFilterRoomListModel.filterText.length === 0 && !Config.mergeRoomList ? "category" : null
section.delegate: Kirigami.ListSectionHeader {
id: sectionHeader
height: implicitHeight
action: Kirigami.Action {
onTriggered: roomListModel.setCategoryVisible(section, !roomListModel.categoryVisible(section))
}
contentItem: RowLayout {
implicitHeight: categoryName.implicitHeight
Kirigami.Heading {
id: categoryName
level: 3
text: roomListModel.categoryName(section)
Layout.fillWidth: true
elide: Text.ElideRight
visible: !page.collapsedMode
}
Kirigami.Icon {
source: page.collapsedMode ? roomListModel.categoryIconName(section) : (roomListModel.categoryVisible(section) ? "go-up" : "go-down")
implicitHeight: Kirigami.Units.iconSizes.small
implicitWidth: Kirigami.Units.iconSizes.small
Layout.alignment: Qt.AlignHCenter
}
}
}
reuseItems: true
currentIndex: -1 // we don't want any room highlighted by default
delegate: page.collapsedMode ? collapsedModeListComponent : normalModeListComponent
Component {
id: collapsedModeListComponent
QQC2.ItemDelegate {
action: Kirigami.Action {
id: enterRoomAction
onTriggered: {
RoomManager.enterRoom(currentRoom);
}
}
Keys.onEnterPressed: enterRoomAction.trigger()
Keys.onReturnPressed: enterRoomAction.trigger()
topPadding: Kirigami.Units.largeSpacing
leftPadding: Kirigami.Units.largeSpacing
rightPadding: Kirigami.Units.largeSpacing
bottomPadding: Kirigami.Units.largeSpacing
width: ListView.view.width
height: ListView.view.width
contentItem: Kirigami.Avatar {
source: avatar ? "image://mxc/" + avatar : ""
name: model.name || i18n("No Name")
sourceSize.width: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
sourceSize.height: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
}
QQC2.ToolTip {
enabled: text.length !== 0
text: name ?? ""
}
}
}
Component {
id: roomListContextMenu
RoomListContextMenu {}
}
Component {
id: normalModeListComponent
Kirigami.BasicListItem {
id: roomListItem
visible: model.categoryVisible || sortFilterRoomListModel.filterText.length > 0 || Config.mergeRoomList
topPadding: Kirigami.Units.largeSpacing
bottomPadding: Kirigami.Units.largeSpacing
highlighted: listView.currentIndex === index
focus: true
icon: undefined
action: Kirigami.Action {
id: enterRoomAction
onTriggered: {
RoomManager.enterRoom(currentRoom);
}
}
Keys.onEnterPressed: enterRoomAction.trigger()
Keys.onReturnPressed: enterRoomAction.trigger()
bold: unreadCount > 0
label: name ?? ""
labelItem.textFormat: Text.PlainText
subtitle: subtitleText
subtitleItem.textFormat: Text.PlainText
onPressAndHold: {
createRoomListContextMenu()
}
TapHandler {
acceptedButtons: Qt.RightButton
acceptedDevices: PointerDevice.Mouse
onTapped: createRoomListContextMenu()
}
leading: Kirigami.Avatar {
source: avatar ? "image://mxc/" + avatar : ""
name: model.name || i18n("No Name")
implicitWidth: visible ? height : 0
visible: Config.showAvatarInTimeline
sourceSize.width: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
sourceSize.height: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
}
trailing: RowLayout {
QQC2.Label {
text: notificationCount > 0 ? notificationCount : "●"
visible: unreadCount > 0
padding: Kirigami.Units.smallSpacing
color: Kirigami.Theme.textColor
Layout.minimumWidth: height
horizontalAlignment: Text.AlignHCenter
background: Rectangle {
visible: notificationCount > 0
Kirigami.Theme.colorSet: Kirigami.Theme.Button
color: highlightCount > 0 ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.disabledTextColor
opacity: highlightCount > 0 ? 1 : 0.3
radius: height / 2
}
}
QQC2.Button {
id: configButton
visible: roomListItem.hovered
Accessible.name: i18n("Configure room")
action: Kirigami.Action {
id: optionAction
icon.name: "configure"
onTriggered: {
createRoomListContextMenu()
}
}
}
}
function createRoomListContextMenu() {
const menu = roomListContextMenu.createObject(page, {room: currentRoom})
configButton.visible = true
configButton.down = true
menu.closed.connect(function() {
configButton.down = undefined
configButton.visible = Qt.binding(function() { return roomListItem.hovered || Kirigami.Settings.isMobile })
})
menu.open()
}
}
}
}
footer: QQC2.ToolBar {
visible: accountList.count > 1 && !collapsedMode
height: visible ? implicitHeight : 0
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
contentItem: RowLayout {
spacing: 0
Repeater {
id: accountList
model: AccountRegistry
delegate: Kirigami.BasicListItem {
checkable: true
checked: Controller.activeConnection && Controller.activeConnection.localUserId === model.connection.localUserId
onClicked: Controller.activeConnection = model.connection
Layout.fillWidth: true
Layout.fillHeight: true
text: model.connection.localUserId
subtitle: model.connection.localUser.accountLabel
}
}
}
}
}

View File

@@ -0,0 +1,678 @@
// SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1 as Platform
import Qt.labs.qmlmodels 1.0
import org.kde.kirigami 2.19 as Kirigami
import org.kde.kitemmodels 1.0
import org.kde.neochat 1.0
import NeoChat.Component 1.0
import NeoChat.Component.ChatBox 1.0
import NeoChat.Component.Timeline 1.0
import NeoChat.Dialog 1.0
import NeoChat.Menu.Timeline 1.0
Kirigami.ScrollablePage {
id: page
/// It's not readonly because of the seperate window view.
property var currentRoom: RoomManager.currentRoom
property bool loading: page.currentRoom === null || (messageListView.count === 0 && !page.currentRoom.allHistoryLoaded && !page.currentRoom.isInvite)
/// Used to determine if scrolling to the bottom should mark the message as unread
property bool hasScrolledUpBefore: false;
/// Disable cancel shortcut. Used by the seperate window since it provide its own
/// cancel implementation.
property bool disableCancelShortcut: false
title: currentRoom.htmlSafeDisplayName
KeyNavigation.left: pageStack.get(0)
Connections {
target: RoomManager
function onCurrentRoomChanged() {
if(!RoomManager.currentRoom) {
if(pageStack.lastItem == page) {
pageStack.pop()
}
} else if (page.currentRoom.isInvite) {
page.currentRoom.clearInvitationNotification();
}
}
}
signal switchRoomUp()
signal switchRoomDown()
onCurrentRoomChanged: {
hasScrolledUpBefore = false;
chatBoxHelper.clearEditReply()
}
Connections {
target: messageEventModel
function onRowsInserted() {
markReadIfVisibleTimer.restart()
}
}
Timer {
id: markReadIfVisibleTimer
interval: 1000
onTriggered: {
if (loading || !currentRoom.readMarkerLoaded || !applicationWindow().active) {
restart()
} else {
markReadIfVisible()
}
}
}
ActionsHandler {
id: actionsHandler
room: page.currentRoom
connection: Controller.activeConnection
}
ChatBoxHelper {
id: chatBoxHelper
}
Shortcut {
sequence: StandardKey.Cancel
onActivated: applicationWindow().pageStack.get(0).forceActiveFocus()
enabled: !page.disableCancelShortcut
}
Connections {
target: Controller.activeConnection
function onJoinedRoom(room, invited) {
if(page.currentRoom.id === invited.id) {
RoomManager.enterRoom(room);
}
}
}
Connections {
target: actionsHandler
function onShowMessage(messageType, message) {
page.header.contentItem.text = message;
page.header.contentItem.type = messageType === ActionsHandler.Error ? Kirigami.MessageType.Error : Kirigami.MessageType.Information;
page.header.visible = true;
}
}
header: QQC2.Control {
height: visible ? implicitHeight : 0
visible: false
padding: Kirigami.Units.smallSpacing
contentItem: Kirigami.InlineMessage {
showCloseButton: true
visible: true
}
}
Kirigami.PlaceholderMessage {
id: invitation
visible: currentRoom && currentRoom.isInvite
anchors.centerIn: parent
text: i18n("Accept this invitation?")
RowLayout {
QQC2.Button {
Layout.alignment : Qt.AlignHCenter
text: i18n("Reject")
onClicked: RoomManager.leaveRoom(page.currentRoom);
}
QQC2.Button {
Layout.alignment : Qt.AlignHCenter
text: i18n("Accept")
onClicked: {
currentRoom.acceptInvitation();
}
}
}
}
Kirigami.LoadingPlaceholder {
id: loadingIndicator
anchors.centerIn: parent
visible: loading
}
focus: true
Keys.onTabPressed: {
if (event.modifiers & Qt.ControlModifier) {
switchRoomDown();
}
}
Keys.onBacktabPressed: {
if (event.modifiers & Qt.ControlModifier) {
switchRoomUp();
}
}
Keys.onPressed: {
if (event.key === Qt.Key_PageDown && (event.modifiers & Qt.ControlModifier)) {
switchRoomDown();
} else if (event.key === Qt.Key_PageUp && (event.modifiers & Qt.ControlModifier)) {
switchRoomUp();
} else if (!(event.modifiers & Qt.ControlModifier) && event.key < Qt.Key_Escape) {
event.accepted = true;
chatBox.addText(event.text);
chatBox.focusInputField();
return;
}
}
Connections {
target: currentRoom
function onPositiveMessage(message) {
page.header.contentItem.text = message;
page.header.contentItem.type = Kirigami.MessageType.Positive;
page.header.visible = true;
}
}
// hover actions on a delegate, activated in TimelineContainer.qml
Connections {
target: page.flickable
enabled: hoverActions.visible
function onContentYChanged() {
hoverActions.updateFunction();
}
}
CollapseStateProxyModel {
id: collapseStateProxyModel
sourceModel: sortedMessageEventModel
}
ListView {
id: messageListView
visible: !invitation.visible
readonly property int largestVisibleIndex: count > 0 ? indexAt(contentX + (width / 2), contentY + height - 1) : -1
readonly property bool isLoaded: page.width * page.height > 10
spacing: 0
verticalLayoutDirection: ListView.BottomToTop
highlightMoveDuration: 500
model: !isLoaded ? undefined : collapseStateProxyModel
MessageEventModel {
id: messageEventModel
room: currentRoom
}
Timer {
interval: 1000
running: messageListView.atYBeginning
triggeredOnStart: true
onTriggered: {
if (messageListView.atYBeginning && messageEventModel.canFetchMore(messageEventModel.index(0, 0))) {
messageEventModel.fetchMore(messageEventModel.index(0, 0));
}
}
repeat: true
}
// HACK: The view should do this automatically but doesn't.
onAtYBeginningChanged: if (atYBeginning && messageEventModel.canFetchMore(messageEventModel.index(0, 0))) {
messageEventModel.fetchMore(messageEventModel.index(0, 0));
}
onAtYEndChanged: if (atYEnd && hasScrolledUpBefore) {
if (QQC2.ApplicationWindow.window && (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden)) {
currentRoom.markAllMessagesAsRead();
}
hasScrolledUpBefore = false;
} else if (!atYEnd) {
hasScrolledUpBefore = true;
}
QQC2.Popup {
anchors.centerIn: parent
id: attachDialog
padding: 16
contentItem: RowLayout {
QQC2.ToolButton {
Layout.preferredWidth: 160
Layout.fillHeight: true
icon.name: 'mail-attachment'
text: i18n("Choose local file")
onClicked: {
attachDialog.close()
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay)
fileDialog.chosen.connect(function(path) {
if (!path) return
chatBoxHelper.attachmentPath = path;
})
fileDialog.open()
}
}
Kirigami.Separator {}
QQC2.ToolButton {
Layout.preferredWidth: 160
Layout.fillHeight: true
padding: 16
icon.name: 'insert-image'
text: i18n("Clipboard image")
onClicked: {
const localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png"
if (!Clipboard.saveImage(localPath)) {
return;
}
chatBoxHelper.attachmentPath = localPath;
attachDialog.close();
}
}
}
}
Component {
id: openFileDialog
OpenFileDialog {}
}
MessageFilterModel {
id: sortedMessageEventModel
sourceModel: messageEventModel
}
delegate: EventDelegate {}
QQC2.RoundButton {
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: Kirigami.Units.largeSpacing
anchors.rightMargin: Kirigami.Units.largeSpacing
implicitWidth: Kirigami.Units.gridUnit * 2
implicitHeight: Kirigami.Units.gridUnit * 2
id: goReadMarkerFab
visible: currentRoom && currentRoom.hasUnreadMessages && currentRoom.readMarkerLoaded
action: Kirigami.Action {
onTriggered: {
messageListView.goToEvent(currentRoom.readMarkerEventId)
}
icon.name: "go-up"
}
QQC2.ToolTip {
text: i18n("Jump to first unread message")
}
}
QQC2.RoundButton {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: Kirigami.Units.largeSpacing + messageListView.headerItem.height
anchors.rightMargin: Kirigami.Units.largeSpacing
implicitWidth: Kirigami.Units.gridUnit * 2
implicitHeight: Kirigami.Units.gridUnit * 2
id: goMarkAsReadFab
visible: !messageListView.atYEnd
action: Kirigami.Action {
onTriggered: {
goToLastMessage();
currentRoom.markAllMessagesAsRead();
}
icon.name: "go-down"
}
QQC2.ToolTip {
text: i18n("Jump to latest message")
}
}
Component.onCompleted: {
positionViewAtBeginning();
}
DropArea {
id: dropAreaFile
anchors.fill: parent
onDropped: chatBoxHelper.attachmentPath = drop.urls[0]
}
QQC2.Pane {
visible: dropAreaFile.containsDrag
anchors {
fill: parent
margins: Kirigami.Units.gridUnit
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - (Kirigami.Units.largeSpacing * 4)
text: i18n("Drag items here to share them")
}
}
Component {
id: messageDelegateContextMenu
MessageDelegateContextMenu {}
}
Component {
id: fileDelegateContextMenu
FileDelegateContextMenu {}
}
Component {
id: fullScreenImage
FullScreenImage {}
}
Component {
id: userDetailDialog
UserDetailDialog {}
}
header: TypingPane {
id: typingPane
visible: !loadingIndicator.visible && currentRoom && currentRoom.usersTyping.length > 0
labelText: visible ? i18ncp(
"Message displayed when some users are typing", "%2 is typing", "%2 are typing",
currentRoom.usersTyping.length,
currentRoom.usersTyping.map(user => user.displayName).join(", ")
) : ""
anchors.left: parent.left
height: visible ? implicitHeight : 0
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
z: 2
}
headerPositioning: ListView.OverlayHeader
function goToEvent(eventID) {
const index = eventToIndex(eventID)
messageListView.positionViewAtIndex(index, ListView.Center)
itemAtIndex(index).isTemporaryHighlighted = true
}
Item {
id: hoverActions
property var event: null
property bool userMsg: event && event.author.id === Controller.activeConnection.localUserId
property bool showEdit: event && (userMsg && (event.eventType === "emote" || event.eventType === "message"))
property var delegate: null
property var bubble: null
property var hovered: bubble && bubble.hovered
property var visibleDelayed: (hovered || hoverHandler.hovered) && !Kirigami.Settings.isMobile
onVisibleDelayedChanged: if (visibleDelayed) {
visible = true;
} else {
// HACK: delay disapearing by 200ms, otherwise this can create some glitches
// See https://invent.kde.org/network/neochat/-/issues/333
hoverActionsTimer.restart();
}
Timer {
id: hoverActionsTimer
interval: 200
onTriggered: hoverActions.visible = hoverActions.visibleDelayed;
}
property int childOffset: userMsg && Config.showLocalMessagesOnRight && !Config.compactLayout ? (bubble ? bubble.width : 0) - childWidth : Math.max((bubble ? bubble.width : 0) - childWidth, 0)
x: delegate && bubble ? (delegate.x + bubble.x + Kirigami.Units.largeSpacing + childOffset - (Config.compactLayout ? Kirigami.Units.gridUnit * 3 + (delegate.width >= Kirigami.Units.gridUnit * 20 ? Kirigami.Units.gridUnit * 2 : 0 ): 0) - (userMsg && !Config.compactLayout ? Kirigami.Units.gridUnit : 0)) : 0
y: bubble ? bubble.mapToItem(parent, 0, 0).y - hoverActions.childHeight + Kirigami.Units.smallSpacing: 0;
visible: false
property var updateFunction
property alias childWidth: hoverActionsRow.width
property alias childHeight: hoverActionsRow.height
RowLayout {
id: hoverActionsRow
z: 4
spacing: 0
HoverHandler {
id: hoverHandler
margin: Kirigami.Units.smallSpacing
}
Kirigami.Icon {
source: "security-high"
width: height
height: parent.height
visible: hoverActions.event.verified
HoverHandler {
id: hover
}
QQC2.ToolTip.text: i18n("This message was sent from a verified device")
QQC2.ToolTip.visible: hover.hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
QQC2.ToolTip.text: i18n("React")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
icon.name: "preferences-desktop-emoticons"
onClicked: emojiDialog.open();
EmojiDialog {
id: emojiDialog
onReact: {
page.currentRoom.toggleReaction(hoverActions.event.eventId, emoji);
chatBox.focusInputField();
}
}
}
QQC2.Button {
QQC2.ToolTip.text: i18n("Edit")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
visible: hoverActions.showEdit
icon.name: "document-edit"
onClicked: {
if (hoverActions.showEdit) {
chatBoxHelper.edit(hoverActions.event.message, hoverActions.event.formattedBody, hoverActions.event.eventId)
}
chatBox.focusInputField();
}
}
QQC2.Button {
QQC2.ToolTip.text: i18n("Reply")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
icon.name: "mail-replied-symbolic"
onClicked: {
chatBoxHelper.replyToMessage(hoverActions.event.eventId, hoverActions.event.message, hoverActions.event.author);
chatBox.focusInputField();
}
}
}
}
}
footer: ChatBox {
id: chatBox
visible: !invitation.visible && !(messageListView.count === 0 && !currentRoom.allHistoryLoaded)
onMessageSent: {
if (!messageListView.atYEnd) {
goToLastMessage();
}
}
onEditLastUserMessage: {
const targetMessage = messageEventModel.getLastLocalUserMessageEventId();
if (targetMessage) {
chatBoxHelper.edit(targetMessage["message"], targetMessage["formattedBody"], targetMessage["event_id"]);
chatBox.focusInputField();
}
}
onReplyPreviousUserMessage: {
const replyResponse = messageEventModel.getLatestMessageFromIndex(0);
if (replyResponse && replyResponse["event_id"]) {
chatBoxHelper.replyToMessage(replyResponse["event_id"], replyResponse["message"], replyResponse["sender_id"]);
}
}
}
background: FancyEffectsContainer {
id: fancyEffectsContainer
z: 100
enabled: Config.showFancyEffects
function processFancyEffectsReason(fancyEffect) {
if (fancyEffect === "snowflake") {
fancyEffectsContainer.showSnowEffect()
}
if (fancyEffect === "fireworks") {
fancyEffectsContainer.showFireworksEffect()
}
if (fancyEffect === "confetti") {
fancyEffectsContainer.showConfettiEffect()
}
}
Connections {
enabled: Config.showFancyEffects
target: messageEventModel
function onFancyEffectsReasonFound(fancyEffect) {
fancyEffectsContainer.processFancyEffectsReason(fancyEffect)
}
}
Connections {
enabled: Config.showFancyEffects
target: chatBox
function onFancyEffectsReasonFound(fancyEffect) {
fancyEffectsContainer.processFancyEffectsReason(fancyEffect)
}
}
}
function warning(title, message) {
page.header.contentItem.text = `${title}<br />${message}`;
page.header.contentItem.type = Kirigami.MessageType.Warning;
page.header.visible = true;
}
function showUserDetail(user) {
userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, {
room: currentRoom,
user: user,
}).open();
}
function goToLastMessage() {
currentRoom.markAllMessagesAsRead()
// scroll to the very end, i.e to messageListView.YEnd
messageListView.positionViewAtIndex(0, ListView.End)
}
function eventToIndex(eventID) {
const index = messageEventModel.eventIDToIndex(eventID)
if (index === -1)
return -1
return sortedMessageEventModel.mapFromSource(messageEventModel.index(index, 0)).row
}
function firstVisibleIndex() {
let center = messageListView.x + messageListView.width / 2;
let index = -1
let i = 0
while(index === -1 && i < 100) {
index = messageListView.indexAt(center, messageListView.y + messageListView.contentY + i);
i++;
}
return index
}
function lastVisibleIndex() {
let center = messageListView.x + messageListView.width / 2;
let index = -1
let i = 0
while(index === -1 && i < 100) {
index = messageListView.indexAt(center, messageListView.y + messageListView.contentY + messageListView.height - i);
i++
}
return index;
}
// Mark all messages as read if all unread messages are visible to the user
function markReadIfVisible() {
let readMarkerRow = eventToIndex(currentRoom.readMarkerEventId)
if (readMarkerRow > 0 && readMarkerRow < firstVisibleIndex()) {
currentRoom.markAllMessagesAsRead()
}
}
/// Open message context dialog for file and videos
function openFileContext(event, file) {
const contextMenu = fileDelegateContextMenu.createObject(page, {
author: event.author,
message: event.message,
eventId: event.eventId,
source: event.source,
file: file,
mimeType: event.mimeType,
progressInfo: event.progressInfo,
plainMessage: event.message,
});
contextMenu.open();
}
/// Open context menu for normal message
function openMessageContext(event, selectedText, plainMessage) {
const contextMenu = messageDelegateContextMenu.createObject(page, {
selectedText: selectedText,
author: event.author,
message: event.display,
eventId: event.eventId,
formattedBody: event.formattedBody,
source: event.source,
eventType: event.eventType,
plainMessage: plainMessage,
});
contextMenu.open();
}
}

View File

@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
Kirigami.ApplicationWindow {
id: window
required property var currentRoom
minimumWidth: Kirigami.Units.gridUnit * 10
minimumHeight: Kirigami.Units.gridUnit * 15
Shortcut {
sequence: StandardKey.Cancel
onActivated: window.close()
}
pageStack.initialPage: RoomPage {
visible: true
currentRoom: window.currentRoom
disableCancelShortcut: true
}
}

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