diff --git a/.ci/format.sh b/.ci/format.sh index d3b629c3..e1e6c1e4 100755 --- a/.ci/format.sh +++ b/.ci/format.sh @@ -11,5 +11,7 @@ FILES=$(find src -type f -type f \( -iname "*.cpp" -o -iname "*.h" \)) for f in $FILES do - clang-format -i "$f" && git diff --exit-code + clang-format -i "$f" done; + +git diff --exit-code diff --git a/.ci/install.sh b/.ci/install.sh index c1f42357..2c7c71e3 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -31,8 +31,8 @@ if [ "$TRAVIS_OS_NAME" = "linux" ]; then QT_PKG="59" fi - wget https://cmake.org/files/v3.12/cmake-3.12.2-Linux-x86_64.sh - sudo sh cmake-3.12.2-Linux-x86_64.sh --skip-license --prefix=/usr/local + wget https://cmake.org/files/v3.15/cmake-3.15.5-Linux-x86_64.sh + sudo sh cmake-3.15.5-Linux-x86_64.sh --skip-license --prefix=/usr/local mkdir -p build-libsodium ( cd build-libsodium @@ -54,5 +54,7 @@ if [ "$TRAVIS_OS_NAME" = "linux" ]; then qt${QT_PKG}tools \ qt${QT_PKG}svg \ qt${QT_PKG}multimedia \ + qt${QT_PKG}quickcontrols2 \ + qt${QT_PKG}graphicaleffects \ liblmdb-dev fi diff --git a/.ci/linux/deploy.sh b/.ci/linux/deploy.sh index 2caf5e0f..524d72d5 100755 --- a/.ci/linux/deploy.sh +++ b/.ci/linux/deploy.sh @@ -44,8 +44,7 @@ do linuxdeployqt=$res done -./"$linuxdeployqt" ${DIR}/usr/share/applications/*.desktop -unsupported-allow-new-glibc -bundle-non-qt-libs -./"$linuxdeployqt" ${DIR}/usr/share/applications/*.desktop -unsupported-allow-new-glibc -appimage +./"$linuxdeployqt" ${DIR}/usr/share/applications/*.desktop -unsupported-allow-new-glibc -bundle-non-qt-libs -qmldir=./resources/qml -appimage chmod +x nheko-*x86_64.AppImage diff --git a/.ci/macos/deploy.sh b/.ci/macos/deploy.sh index ee4acaed..1dc9472d 100755 --- a/.ci/macos/deploy.sh +++ b/.ci/macos/deploy.sh @@ -16,7 +16,7 @@ PATH=/usr/local/opt/qt/bin/:${PATH} mkdir -p nheko.app/Contents/Frameworks find "${ICU_LIB}" -type l -name "*.dylib" -exec cp -a -n {} nheko.app/Contents/Frameworks/ \; || true - sudo macdeployqt nheko.app -dmg -always-overwrite + sudo macdeployqt nheko.app -dmg -always-overwrite -qmldir=../resources/qml/ user=$(id -nu) sudo chown "${user}" nheko.dmg diff --git a/.ci/script.sh b/.ci/script.sh index ac6bfed6..06536278 100755 --- a/.ci/script.sh +++ b/.ci/script.sh @@ -13,6 +13,9 @@ if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo update-alternatives --set gcc "/usr/bin/${C_COMPILER}" sudo update-alternatives --set g++ "/usr/bin/${CXX_COMPILER}" + + export PATH="/usr/local/bin/:${PATH}" + cmake --version fi if [ "$TRAVIS_OS_NAME" = "linux" ]; then @@ -35,7 +38,8 @@ cmake --build .deps # Build nheko cmake -GNinja -H. -Bbuild \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ - -DCMAKE_INSTALL_PREFIX=.deps/usr + -DCMAKE_INSTALL_PREFIX=.deps/usr \ + -DBUILD_SHARED_LIBS=ON # weird workaround, as the boost 1.70 cmake files seem to be broken? cmake --build build if [ "$TRAVIS_OS_NAME" = "osx" ]; then diff --git a/.gitignore b/.gitignore index 43c9b7b4..2d772e58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ -build +/build* tags cscope* .clang_complete *wintoastlib* +/.ccls-cache +/.exrc +.gdb_history # GTAGS GTAGS @@ -49,6 +52,10 @@ ui_*.h *.qmlproject.user *.qmlproject.user.* +# Vim +*.swp +*.swo + #####=== CMake ===##### CMakeCache.txt diff --git a/.travis.yml b/.travis.yml index 6a699043..4ab6408a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,8 @@ matrix: include: - os: osx compiler: clang - osx_image: xcode9 + # Use the default osx image, because that one is actually tested to work with homebrew and probably the oldest supported version + # osx_image: xcode9 env: - DEPLOYMENT=1 - USE_BUNDLED_BOOST=0 @@ -42,8 +43,8 @@ matrix: env: - CXX_COMPILER=g++-8 - C_COMPILER=gcc-8 - - QT_VERSION=571 - - QT_PKG=57 + - QT_VERSION=592 + - QT_PKG=59 - USE_BUNDLED_BOOST=1 - USE_BUNDLED_CMARK=1 - USE_BUNDLED_JSON=1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d5aff7a..67a1dfb0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,7 +69,8 @@ include(LMDB) # # Discover Qt dependencies. # -find_package(Qt5 COMPONENTS Core Widgets LinguistTools Concurrent Svg Multimedia REQUIRED) +find_package(Qt5 COMPONENTS Core Widgets LinguistTools Concurrent Svg Multimedia Qml QuickControls2 QuickWidgets REQUIRED) +find_package(Qt5QuickCompiler) find_package(Qt5DBus) if (APPLE) @@ -192,12 +193,8 @@ set(SRC_FILES # Timeline src/timeline/TimelineViewManager.cpp - src/timeline/TimelineItem.cpp - src/timeline/TimelineView.cpp - src/timeline/widgets/AudioItem.cpp - src/timeline/widgets/FileItem.cpp - src/timeline/widgets/ImageItem.cpp - src/timeline/widgets/VideoItem.cpp + src/timeline/TimelineModel.cpp + src/timeline/DelegateChooser.cpp # UI components src/ui/Avatar.cpp @@ -229,6 +226,8 @@ set(SRC_FILES src/Logging.cpp src/MainWindow.cpp src/MatrixClient.cpp + src/MxcImageProvider.cpp + src/ColorImageProvider.cpp src/QuickSwitcher.cpp src/Olm.cpp src/RegisterPage.cpp @@ -260,7 +259,7 @@ include(FeatureSummary) set(Boost_USE_STATIC_LIBS OFF) set(Boost_USE_STATIC_RUNTIME OFF) set(Boost_USE_MULTITHREADED ON) -find_package(Boost 1.66 REQUIRED +find_package(Boost 1.70 REQUIRED COMPONENTS atomic chrono date_time @@ -333,13 +332,9 @@ qt5_wrap_cpp(MOC_HEADERS src/emoji/PickButton.h # Timeline - src/timeline/TimelineItem.h - src/timeline/TimelineView.h src/timeline/TimelineViewManager.h - src/timeline/widgets/AudioItem.h - src/timeline/widgets/FileItem.h - src/timeline/widgets/ImageItem.h - src/timeline/widgets/VideoItem.h + src/timeline/TimelineModel.h + src/timeline/DelegateChooser.h # UI components src/ui/Avatar.h @@ -370,7 +365,7 @@ qt5_wrap_cpp(MOC_HEADERS src/CommunitiesList.h src/LoginPage.h src/MainWindow.h - src/MatrixClient.h + src/MxcImageProvider.h src/InviteeItem.h src/QuickSwitcher.h src/RegisterPage.h @@ -405,6 +400,9 @@ set(COMMON_LIBS Qt5::Svg Qt5::Concurrent Qt5::Multimedia + Qt5::Qml + Qt5::QuickControls2 + Qt5::QuickWidgets nlohmann_json::nlohmann_json) if(APPVEYOR_BUILD) @@ -448,6 +446,7 @@ if(APPLE) target_link_libraries (nheko ${NHEKO_LIBS} Qt5::MacExtras) elseif(WIN32) add_executable (nheko ${OS_BUNDLE} ${ICON_FILE} ${NHEKO_DEPS}) + target_compile_definitions(nheko PRIVATE WIN32_LEAN_AND_MEAN) target_link_libraries (nheko ${NTDLIB} ${NHEKO_LIBS} Qt5::WinMain) else() add_executable (nheko ${OS_BUNDLE} ${NHEKO_DEPS}) diff --git a/Dockerfile b/Dockerfile index 2e01b40b..dddd1c6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN \ add-apt-repository -y ppa:ubuntu-toolchain-r/test && \ apt-get update -qq && \ apt-get install -y \ - qt510base qt510tools qt510svg qt510multimedia \ + qt510base qt510tools qt510svg qt510multimedia qt510quickcontrols2 qt510graphicaleffects \ gcc-5 g++-5 RUN \ @@ -44,4 +44,4 @@ ENV PATH=/opt/qt510/bin:$PATH RUN mkdir /build -WORKDIR /build \ No newline at end of file +WORKDIR /build diff --git a/Makefile b/Makefile index 2f688d3b..7f603dcb 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,7 @@ update-translations: -locations relative \ -Iinclude/dialogs \ -Iinclude \ - src/ -ts resources/langs/nheko_*.ts -no-obsolete + src/ resources/qml/ -ts resources/langs/nheko_*.ts -no-obsolete clean: rm -rf build diff --git a/README.md b/README.md index 1a9609ab..1179463d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ nheko The motivation behind the project is to provide a native desktop app for [Matrix] that feels more like a mainstream chat app ([Riot], Telegram etc) and less like an IRC client. +### Translations ### +[![Translation status](http://weblate.nheko.im/widgets/nheko/-/nheko-master/svg-badge.svg)](http://weblate.nheko.im/engage/nheko/?utm_source=widget) + +Help us with translations so as many people as possible will be able to use nheko! + ### Note regarding End-to-End encryption Currently the implementation is at best a **proof of concept** and it should only be used for @@ -84,13 +89,14 @@ sudo port install nheko ### Build Requirements -- Qt5 (5.7 or greater). Qt 5.7 adds support for color font rendering with - Freetype, which is essential to properly support emoji. -- CMake 3.1 or greater. +- Qt5 (5.9 or greater). Qt 5.7 adds support for color font rendering with + Freetype, which is essential to properly support emoji, 5.8 adds some features + to make interopability with Qml easier. +- CMake 3.15 or greater. (Lower version may work, but may break boost linking) - [mtxclient](https://github.com/Nheko-Reborn/mtxclient) - [LMDB](https://symas.com/lightning-memory-mapped-database/) - [cmark](https://github.com/commonmark/cmark) -- Boost 1.66 or greater. +- Boost 1.70 or greater. - [libolm](https://git.matrix.org/git/olm) - [libsodium](https://github.com/jedisct1/libsodium) - [spdlog](https://github.com/gabime/spdlog) @@ -126,7 +132,7 @@ sudo pacman -S qt5-base \ ##### Gentoo Linux ```bash -sudo emerge -a ">=dev-qt/qtgui-5.7.1" media-libs/fontconfig +sudo emerge -a ">=dev-qt/qtgui-5.9.0" media-libs/fontconfig ``` ##### Ubuntu (e.g 14.04) diff --git a/appveyor.yml b/appveyor.yml index 08251174..8572418f 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -34,6 +34,7 @@ install: lmdb:%PLATFORM%-windows openssl:%PLATFORM%-windows zlib:%PLATFORM%-windows + - vcpkg upgrade --no-dry-run build_script: # VERSION format: branch-master/branch-1.2 diff --git a/cmake/Translations.cmake b/cmake/Translations.cmake index 8ca91883..16120219 100644 --- a/cmake/Translations.cmake +++ b/cmake/Translations.cmake @@ -21,4 +21,8 @@ if(NOT EXISTS ${_qrc}) endif() qt5_add_resources(LANG_QRC ${_qrc}) -qt5_add_resources(QRC resources/res.qrc) +if(Qt5QuickCompiler_FOUND) + qtquick_compiler_add_resources(QRC resources/res.qrc) +else() + qt5_add_resources(QRC resources/res.qrc) +endif() diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index d0a715e0..0da4a671 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -33,23 +33,23 @@ option(USE_BUNDLED_JSON "Use the bundled version of nlohmann json." ${USE_BUNDLE option(MTX_STATIC "Compile / link bundled mtx client statically" OFF) if(USE_BUNDLED_BOOST) - # bundled boost is 1.68, which requires CMake 3.12 or greater. - cmake_minimum_required(VERSION 3.12) + # bundled boost is 1.70, which requires CMake 3.15 or greater. + cmake_minimum_required(VERSION 3.15) endif() include(ExternalProject) set(BOOST_URL - https://dl.bintray.com/boostorg/release/1.69.0/source/boost_1_69_0.tar.bz2) + https://dl.bintray.com/boostorg/release/1.70.0/source/boost_1_70_0.tar.bz2) set(BOOST_SHA256 - 8f32d4617390d1c2d16f26a27ab60d97807b35440d45891fa340fc2648b04406) + 430ae8354789de4fd19ee52f3b1f739e1fba576f0aded0897c3c2bc00fb38778) set( MTXCLIENT_URL - https://github.com/Nheko-Reborn/mtxclient/archive/6eee767cc25a9db9f125843e584656cde1ebb6c5.tar.gz + https://github.com/Nheko-Reborn/mtxclient/archive/64182a84e35378113f7d3a80f3073894416480e7.zip ) set(MTXCLIENT_HASH - 72fe77da4fed98b3cf069299f66092c820c900359a27ec26070175f9ad208a03) + c9973501920046f04c72983472451736343d00e7a40f4d4a12181191093a5fab) set( TWEENY_URL https://github.com/mobius3/tweeny/archive/b94ce07cfb02a0eb8ac8aaf66137dabdaea857cf.tar.gz diff --git a/deps/cmake/Boost.cmake b/deps/cmake/Boost.cmake index 47eb723b..5d11fd93 100644 --- a/deps/cmake/Boost.cmake +++ b/deps/cmake/Boost.cmake @@ -3,6 +3,10 @@ if(WIN32) return() endif() +include(BoostToolsetId) +set(BOOST_TOOLSET "gcc") +Boost_Get_ToolsetId(BOOST_TOOLSET) + ExternalProject_Add( Boost @@ -16,6 +20,7 @@ ExternalProject_Add( CONFIGURE_COMMAND ${DEPS_BUILD_DIR}/boost/bootstrap.sh --with-libraries=random,thread,system,iostreams,atomic,chrono,date_time,regex --prefix=${DEPS_INSTALL_DIR} + --with-toolset=${BOOST_TOOLSET} BUILD_COMMAND ${DEPS_BUILD_DIR}/boost/b2 -d0 cxxstd=14 variant=release link=shared runtime-link=shared threading=multi --layout=system INSTALL_COMMAND ${DEPS_BUILD_DIR}/boost/b2 -d0 install ) diff --git a/deps/cmake/BoostToolsetId.cmake b/deps/cmake/BoostToolsetId.cmake new file mode 100644 index 00000000..f6c231a5 --- /dev/null +++ b/deps/cmake/BoostToolsetId.cmake @@ -0,0 +1,35 @@ +# - Translate CMake compilers to the Boost.Build toolset equivalents +# To build Boost reliably when a non-system compiler may be used, we +# need to both specify the toolset when running bootstrap.sh *and* in +# the user-config.jam file. +# +# This module provides the following functions to help translate between +# the systems: +# +# function Boost_Get_ToolsetId() +# Set var equal to Boost's name for the CXX toolchain picked +# up by CMake. Only supports GNU and Clang families at present. +# Intel support is provisional +# +# downloaded from https://github.com/drbenmorgan/BoostBuilder/blob/master/BoostToolsetId.cmake + +function(Boost_Get_ToolsetId _var) + set(BOOST_TOOLSET) + + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") + if(APPLE) + set(BOOST_TOOLSET "darwin") + else() + set(BOOST_TOOLSET "gcc") + endif() + elseif(CMAKE_CXX_COMPILER_ID MATCHES ".*Clang") + set(BOOST_TOOLSET "clang") + elseif(CMAKE_CXX_COMPILER_ID MATCHES "Intel") + set(BOOST_TOOLSET "intel") + elseif(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") + set(BOOST_TOOLSET "msvc") + endif() + + set(${_var} ${BOOST_TOOLSET} PARENT_SCOPE) +endfunction() + diff --git a/resources/langs/nheko_de.ts b/resources/langs/nheko_de.ts index e92bf966..59c6dffd 100644 --- a/resources/langs/nheko_de.ts +++ b/resources/langs/nheko_de.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - In Datei speichern - - ChatPage - - Failed to upload image. Please try again. - Hochladen des Bildes fehlgeschlagen. Bitte versuche es erneut. + + Failed to upload media. Please try again. + Medienupload fehlgeschlagen. Bitte versuche es erneut. - - Failed to upload file. Please try again. - Hochladen der Datei fehlgeschlagen. Bitte versuche es erneut. - - - - Failed to upload audio. Please try again. - Hochladen der Audiodatei fehlgeschlagen. Bitte versuche es erneut. - - - - Failed to upload video. Please try again. - Hochladen der Videodatei fehlgeschlagen. Bitte versuche es erneut. - - - + Failed to restore OLM account. Please login again. Wiederherstellung des OLM Accounts fehlgeschlagen. Bitte logge dich erneut ein. @@ -42,18 +19,18 @@ Gespeicherte Nachrichten konnten nicht wiederhergestellt werden. Bitte melde Dich erneut an. - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. Fehler beim Setup der Verschlüsselungsschlüssel. Servermeldung: %1 %2. Bitte versuche es später erneut. - + Please try to login again: %1 Bitte melde dich erneut an: %1 - + Room creation failed: %1 Raum konnte nicht erstellt werden: %1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - Datei speichern - - - - ImageItem - - - Save image - Bild speichern + + Encrypted + Verschlüsselt @@ -200,7 +169,7 @@ MemberList - + Room members Teilnehmerliste @@ -210,6 +179,27 @@ OK + + MessageDelegate + + + redacted + gelöscht + + + + Encryption enabled + Verschlüsselung aktiviert + + + + Placeholder + + + unimplemented event: + unimplementiertes event: + + QuickSwitcher @@ -277,7 +267,7 @@ RoomInfo - + no version stored keine Version gespeichert @@ -285,12 +275,12 @@ RoomInfoListItem - + Leave room Raum verlassen - + Accept Akzeptieren @@ -331,36 +321,36 @@ StatusIndicator - - Encrypted - Verschlüsselt + + Failed + Fehlgeschlagen - - Delivered - Erhalten - - - - Seen - Gelesen - - - + Sent Gesendet + + + Received + Empfangen + + + + Read + Gelesen + TextInputWidget - + Send a file Versende Datei - + Write a message... Schreibe eine Nachricht… @@ -375,7 +365,7 @@ Emoji - + Select a file Datei auswählen @@ -391,32 +381,9 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - Nachricht zurückziehen fehlgeschlagen: %1 - - - - Reply - Antworten - - - - Options - Optionen - - - - TimelineView - - - Encryption is enabled - Verschlüsselung aktiv - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted -- verschlüsselter Event (keine Schlüssel zur Entschlüsselung gefunden) -- @@ -440,16 +407,87 @@ -- Entschlüsselungsfehler (%1) -- - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet -- verschlüsselter Event (Unbekannter Eventtyp) -- + + + Message redaction failed: %1 + Nachricht zurückziehen fehlgeschlagen: %1 + + + + Save image + Bild speichern + + + + Save video + Video speichern + + + + Save audio + Audiodatei speichern + + + + Save file + Datei speichern + + + + TimelineRow + + + Reply + Antworten + + + + Options + Optionen + + + + Read receipts + Lesebestätigungen + + + + Mark as read + Als gelesen markieren + + + + View raw message + Zeige rohen Nachrichteninhalt + + + + Redact message + Nachricht löschen + + + + Save as + Speichern als... + + + + TimelineView + + + No room open + Kein Raum geöffnet + TopRoomBar - + Room options Raumoptionen @@ -515,7 +553,7 @@ UserSettingsPage - + Minimize to tray Ins Benachrichtigungsfeld minimieren @@ -529,6 +567,11 @@ Group's sidebar Gruppen-Seitenleiste + + + Circular Avatars + Runde Profilbilder + Typing notifications @@ -605,7 +648,7 @@ ALLGEMEINES - + Open Sessions File Öffne Sessions Datei @@ -825,7 +868,7 @@ Medien-Größe: %2 dialogs::ReadReceipts - + Read receipts Lesebestätigungen @@ -951,7 +994,7 @@ Medien-Größe: %2 Aktivierung der Verschlüsselung fehlgeschlagen: %1 - + Select an avatar Wähle einen Avatar @@ -977,19 +1020,6 @@ Medien-Größe: %2 Hochladen der Bilddatei fehlgeschlagen: %s - - dialogs::UserMentions - - - This Room - Dieser Raum - - - - All Rooms - Alle Räume - - dialogs::UserProfile @@ -1013,7 +1043,7 @@ Medien-Größe: %2 Gespräch beginnen - + Devices Geräte @@ -1064,69 +1094,103 @@ Medien-Größe: %2 message-description sent: - - %1 an audio clip - %1 einen Audioclip + + You sent an audio clip + Du hast eine Audiodatei gesendet. - %1 an image - %1 ein Bild + %1 sent an audio clip + %1 hat eine Audiodatei gesendet. + + + + You sent an image + Du hast ein Bild gesendet. - %1 a file - %1 eine Datei + %1 sent an image + %1 hat ein Bild gesendet. + + + + You sent a file + Du hast eine Datei gesendet. - %1 a video clip - %1 einen Videoclip + %1 sent a file + %1 hat eine Datei gesendet. + + + + You sent a video + Du hast ein Video gesendet. - %1 a sticker - %1 einen Sticker + %1 sent a video + %1 hat ein Video gesendet. + + + + You sent a sticker + Du hast einen Sticker gesendet. - %1 a notification - 1% eine Benachrichtigung + %1 sent a sticker + %1 hat einen Sticker gesendet. + + + + You sent a notification + Du hast eine Benachrichtigung gesendet. + + + + %1 sent a notification + %1 hat eine Benachrichtigung gesendet. + + + + You: %1 + Du: %1 + + + + %1: %2 + %1: %2 - %1 an encrypted message - 1% eine verschüsselte Nachricht + You sent an encrypted message + Du hast eine verschlüsselte Nachricht gesendet. + + + + %1 sent an encrypted message + %1 hat eine verschlüsselte Nachricht gesendet. - message-description: + popups::UserMentions - - sent - For when someone else is the sender - + + This Room + Dieser Raum - - - message-description: - - sent - For when you are the sender - + + All Rooms + Alle Räume utils - - - You - Du - - - + sent a file. @@ -1146,7 +1210,7 @@ Medien-Größe: %2 - + Unknown Message Type Unbekannter Nachrichtentyp diff --git a/resources/langs/nheko_el.ts b/resources/langs/nheko_el.ts index 700c3d57..fe65785b 100644 --- a/resources/langs/nheko_el.ts +++ b/resources/langs/nheko_el.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - Αποθήκευση - - ChatPage - - Failed to upload image. Please try again. + + Failed to upload media. Please try again. - - Failed to upload file. Please try again. - - - - - Failed to upload audio. Please try again. - - - - - Failed to upload video. Please try again. - - - - + Failed to restore OLM account. Please login again. @@ -42,18 +19,18 @@ - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. - + Please try to login again: %1 - + Room creation failed: %1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - Αποθήκευση - - - - ImageItem - - - Save image - Αποθήκευση Εικόνας + + Encrypted + @@ -200,7 +169,7 @@ MemberList - + Room members Μέλη @@ -210,6 +179,27 @@ + + MessageDelegate + + + redacted + + + + + Encryption enabled + + + + + Placeholder + + + unimplemented event: + + + QuickSwitcher @@ -277,7 +267,7 @@ RoomInfo - + no version stored @@ -285,12 +275,12 @@ RoomInfoListItem - + Leave room Βγές - + Accept Αποδοχή @@ -331,36 +321,36 @@ StatusIndicator - - Encrypted + + Failed - - Delivered - - - - - Seen - - - - + Sent + + + Received + + + + + Read + + TextInputWidget - + Send a file - + Write a message... Γράψε ένα μήνυμα... @@ -375,7 +365,7 @@ - + Select a file Διάλεξε ένα αρχείο @@ -391,32 +381,9 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - - - - - Reply - - - - - Options - - - - - TimelineView - - - Encryption is enabled - - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted @@ -440,16 +407,87 @@ - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet + + + Message redaction failed: %1 + + + + + Save image + Αποθήκευση Εικόνας + + + + Save video + + + + + Save audio + + + + + Save file + + + + + TimelineRow + + + Reply + + + + + Options + + + + + Read receipts + + + + + Mark as read + + + + + View raw message + + + + + Redact message + + + + + Save as + + + + + TimelineView + + + No room open + + TopRoomBar - + Room options @@ -515,7 +553,7 @@ UserSettingsPage - + Minimize to tray Ελαχιστοποίηση @@ -529,6 +567,11 @@ Group's sidebar + + + Circular Avatars + + Typing notifications @@ -605,7 +648,7 @@ ΓΕΝΙΚΑ - + Open Sessions File @@ -823,7 +866,7 @@ Media size: %2 dialogs::ReadReceipts - + Read receipts @@ -949,7 +992,7 @@ Media size: %2 - + Select an avatar @@ -975,19 +1018,6 @@ Media size: %2 - - dialogs::UserMentions - - - This Room - - - - - All Rooms - - - dialogs::UserProfile @@ -1011,7 +1041,7 @@ Media size: %2 - + Devices @@ -1062,69 +1092,103 @@ Media size: %2 message-description sent: - - %1 an audio clip + + You sent an audio clip - %1 an image + %1 sent an audio clip + + + + + You sent an image - %1 a file + %1 sent an image + + + + + You sent a file - %1 a video clip + %1 sent a file + + + + + You sent a video - %1 a sticker + %1 sent a video + + + + + You sent a sticker - %1 a notification + %1 sent a sticker + + + + + You sent a notification + + + + + %1 sent a notification + + + + + You: %1 + + + + + %1: %2 - %1 an encrypted message + You sent an encrypted message + + + + + %1 sent an encrypted message - message-description: + popups::UserMentions - - sent - For when someone else is the sender + + This Room - - - message-description: - - sent - For when you are the sender + + All Rooms utils - - - You - - - - + sent a file. @@ -1144,7 +1208,7 @@ Media size: %2 - + Unknown Message Type diff --git a/resources/langs/nheko_en.ts b/resources/langs/nheko_en.ts index edb95313..49ea7439 100644 --- a/resources/langs/nheko_en.ts +++ b/resources/langs/nheko_en.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - Save File - - ChatPage - - Failed to upload image. Please try again. - Failed to upload image. Please try again. + + Failed to upload media. Please try again. + - - Failed to upload file. Please try again. - Failed to upload file. Please try again. - - - - Failed to upload audio. Please try again. - Failed to upload audio. Please try again. - - - - Failed to upload video. Please try again. - Failed to upload video. Please try again. - - - + Failed to restore OLM account. Please login again. Failed to restore OLM account. Please login again. @@ -42,18 +19,18 @@ Failed to restore save data. Please login again. - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. Failed to setup encryption keys. Server response: %1 %2. Please try again later. - + Please try to login again: %1 Please try to login again: %1 - + Room creation failed: %1 Room creation failed: %1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - Save File - - - - ImageItem - - - Save image - Save image + + Encrypted + @@ -200,7 +169,7 @@ MemberList - + Room members Room members @@ -210,6 +179,27 @@ OK + + MessageDelegate + + + redacted + + + + + Encryption enabled + + + + + Placeholder + + + unimplemented event: + + + QuickSwitcher @@ -277,7 +267,7 @@ RoomInfo - + no version stored no version stored @@ -285,12 +275,12 @@ RoomInfoListItem - + Leave room Leave room - + Accept Accept @@ -331,36 +321,36 @@ StatusIndicator - - Encrypted - Encrypted + + Failed + - - Delivered - Delivered - - - - Seen - Seen - - - + Sent - Sent + + + + + Received + + + + + Read + TextInputWidget - + Send a file Send a file - + Write a message... Write a message… @@ -375,7 +365,7 @@ Emoji - + Select a file Select a file @@ -391,65 +381,113 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - Message redaction failed: %1 - - - - Reply - Reply - - - - Options - Options - - - - TimelineView - - - Encryption is enabled - Encryption is enabled - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted - -- Encrypted Event (No keys found for decryption) -- + -- Encrypted Event (No keys found for decryption) -- -- Decryption Error (failed to communicate with DB) -- Placeholder, when the message can't be decrypted, because the DB access failed when trying to lookup the session. - -- Decryption Error (failed to communicate with DB) -- + -- Decryption Error (failed to communicate with DB) -- -- Decryption Error (failed to retrieve megolm keys from db) -- Placeholder, when the message can't be decrypted, because the DB access failed. - -- Decryption Error (failed to retrieve megolm keys from db) -- + -- Decryption Error (failed to retrieve megolm keys from db) -- -- Decryption Error (%1) -- Placeholder, when the message can't be decrypted. In this case, the Olm decrytion returned an error, which is passed ad %1 - -- Decryption Error (%1) -- + -- Decryption Error (%1) -- - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet - -- Encrypted Event (Unknown event type) -- + -- Encrypted Event (Unknown event type) -- + + + + Message redaction failed: %1 + Message redaction failed: %1 + + + + Save image + Save image + + + + Save video + + + + + Save audio + + + + + Save file + + + + + TimelineRow + + + Reply + + + + + Options + + + + + Read receipts + Read receipts + + + + Mark as read + + + + + View raw message + + + + + Redact message + + + + + Save as + + + + + TimelineView + + + No room open + TopRoomBar - + Room options Room options @@ -515,7 +553,7 @@ UserSettingsPage - + Minimize to tray Minimize to tray @@ -529,6 +567,11 @@ Group's sidebar Group's sidebar + + + Circular Avatars + + Typing notifications @@ -605,7 +648,7 @@ GENERAL - + Open Sessions File Open Sessions File @@ -825,7 +868,7 @@ Media size: %2 dialogs::ReadReceipts - + Read receipts Read receipts @@ -953,7 +996,7 @@ Media size: %2 Failed to enable encryption: %1 - + Select an avatar Select an avatar @@ -979,19 +1022,6 @@ Media size: %2 Failed to upload image: %s - - dialogs::UserMentions - - - This Room - This Room - - - - All Rooms - All Rooms - - dialogs::UserProfile @@ -1015,7 +1045,7 @@ Media size: %2 Start a conversation - + Devices Devices @@ -1066,69 +1096,103 @@ Media size: %2 message-description sent: - - %1 an audio clip - %1 an audio clip + + You sent an audio clip + - %1 an image - %1 an image + %1 sent an audio clip + + + + + You sent an image + - %1 a file - %1 a file + %1 sent an image + + + + + You sent a file + - %1 a video clip - %1 a video clip + %1 sent a file + + + + + You sent a video + - %1 a sticker - %1 a sticker + %1 sent a video + + + + + You sent a sticker + - %1 a notification - %1 a notification + %1 sent a sticker + + + + + You sent a notification + + + + + %1 sent a notification + + + + + You: %1 + + + + + %1: %2 + - %1 an encrypted message - %1 an encrypted message + You sent an encrypted message + + + + + %1 sent an encrypted message + - message-description: + popups::UserMentions - - sent - For when someone else is the sender - sent + + This Room + This Room - - - message-description: - - sent - For when you are the sender - sent + + All Rooms + All Rooms utils - - - You - You - - - + sent a file. sent a file. @@ -1148,7 +1212,7 @@ Media size: %2 sent a video. - + Unknown Message Type Unknown Message Type diff --git a/resources/langs/nheko_fi.ts b/resources/langs/nheko_fi.ts index 89eb33b7..4bb20e30 100644 --- a/resources/langs/nheko_fi.ts +++ b/resources/langs/nheko_fi.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - Tallenna tiedosto - - ChatPage - - Failed to upload image. Please try again. - Kuvan lähettäminen epäonnistui. Ole hyvä ja yritä uudelleen. + + Failed to upload media. Please try again. + - - Failed to upload file. Please try again. - Tiedoston lähettäminen epäonnistui. Ole hyvä ja yritä uudelleen. - - - - Failed to upload audio. Please try again. - Äänitiedoston lähettäminen epäonnistui. Ole hyvä ja yritä uudelleen. - - - - Failed to upload video. Please try again. - Videon lähettäminen epäonnistui. Ole hyvä ja yritä uudelleen. - - - + Failed to restore OLM account. Please login again. OLM-tilin palauttaminen epäonnistui. Ole hyvä ja kirjaudu sisään uudelleen. @@ -42,18 +19,18 @@ Tallennettujen tietojen palauttaminen epäonnistui. Ole hyvä ja kirjaudu sisään uudelleen. - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. Salausavainten lähetys epäonnistui. Palvelimen vastaus: %1 %2. Ole hyvä ja yritä uudelleen myöhemmin. - + Please try to login again: %1 Ole hyvä ja yritä kirjautua sisään uudelleen: %1 - + Room creation failed: %1 Huoneen luominen epäonnistui: %1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - Tallenna tiedosto - - - - ImageItem - - - Save image - Tallenna kuva + + Encrypted + @@ -200,7 +169,7 @@ MemberList - + Room members Huoneen jäsenet @@ -210,6 +179,27 @@ OK + + MessageDelegate + + + redacted + + + + + Encryption enabled + + + + + Placeholder + + + unimplemented event: + + + QuickSwitcher @@ -277,7 +267,7 @@ RoomInfo - + no version stored ei tallennettua versiota @@ -285,12 +275,12 @@ RoomInfoListItem - + Leave room Poistu huoneesta - + Accept Hyväksy @@ -331,36 +321,36 @@ StatusIndicator - - Encrypted - Salattu + + Failed + - - Delivered - Toimitettu - - - - Seen - Luettu - - - + Sent - Lähetetty + + + + + Received + + + + + Read + TextInputWidget - + Send a file Lähetä tiedosto - + Write a message... Kirjoita viesti… @@ -375,7 +365,7 @@ Emoji - + Select a file Valitse tiedosto @@ -391,65 +381,113 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - Viestin poisto epäonnistui: %1 - - - - Reply - Vastaa - - - - Options - Asetukset - - - - TimelineView - - - Encryption is enabled - Salaus on käytössä - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted - -- Salattu viesti (salauksen purkuavaimia ei löydetty) -- + -- Salattu viesti (salauksen purkuavaimia ei löydetty) -- -- Decryption Error (failed to communicate with DB) -- Placeholder, when the message can't be decrypted, because the DB access failed when trying to lookup the session. - -- Virhe purkaessa salausta (tietokannan kanssa kommunikointi epäonnistui) -- + -- Virhe purkaessa salausta (tietokannan kanssa kommunikointi epäonnistui) -- -- Decryption Error (failed to retrieve megolm keys from db) -- Placeholder, when the message can't be decrypted, because the DB access failed. - -- Virhe purkaessa salausta (megolm-avaimien hakeminen tietokannasta epäonnistui) -- + -- Virhe purkaessa salausta (megolm-avaimien hakeminen tietokannasta epäonnistui) -- -- Decryption Error (%1) -- Placeholder, when the message can't be decrypted. In this case, the Olm decrytion returned an error, which is passed ad %1 - -- Virhe purkaessa salausta (%1) -- + -- Virhe purkaessa salausta (%1) -- - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet - -- Salattu viesti (tuntematon viestityyppi) -- + -- Salattu viesti (tuntematon viestityyppi) -- + + + + Message redaction failed: %1 + Viestin poisto epäonnistui: %1 + + + + Save image + Tallenna kuva + + + + Save video + + + + + Save audio + + + + + Save file + + + + + TimelineRow + + + Reply + + + + + Options + + + + + Read receipts + Lukukuittaukset + + + + Mark as read + + + + + View raw message + + + + + Redact message + + + + + Save as + + + + + TimelineView + + + No room open + TopRoomBar - + Room options Huonevaihtoehdot @@ -515,7 +553,7 @@ UserSettingsPage - + Minimize to tray Pienennä ilmoitusalueelle @@ -529,6 +567,11 @@ Group's sidebar Ryhmäsivupalkki + + + Circular Avatars + + Typing notifications @@ -605,7 +648,7 @@ YLEISET ASETUKSET - + Open Sessions File Avaa Istuntoavaintiedosto @@ -825,7 +868,7 @@ Median koko: %2 dialogs::ReadReceipts - + Read receipts Lukukuittaukset @@ -953,7 +996,7 @@ Median koko: %2 Salauksen aktivointi epäonnistui: %1 - + Select an avatar Valitse profiilikuva @@ -979,19 +1022,6 @@ Median koko: %2 Kuvan lähetys epäonnistui: %s - - dialogs::UserMentions - - - This Room - - - - - All Rooms - - - dialogs::UserProfile @@ -1015,7 +1045,7 @@ Median koko: %2 Aloita keskustelu - + Devices Laitteet @@ -1066,69 +1096,103 @@ Median koko: %2 message-description sent: - - %1 an audio clip + + You sent an audio clip - %1 an image + %1 sent an audio clip + + + + + You sent an image - %1 a file + %1 sent an image + + + + + You sent a file - %1 a video clip + %1 sent a file + + + + + You sent a video - %1 a sticker + %1 sent a video + + + + + You sent a sticker - %1 a notification + %1 sent a sticker + + + + + You sent a notification + + + + + %1 sent a notification + + + + + You: %1 + + + + + %1: %2 - %1 an encrypted message + You sent an encrypted message + + + + + %1 sent an encrypted message - message-description: + popups::UserMentions - - sent - For when someone else is the sender + + This Room - - - message-description: - - sent - For when you are the sender + + All Rooms utils - - - You - Sinä - - - + sent a file. @@ -1148,7 +1212,7 @@ Median koko: %2 - + Unknown Message Type diff --git a/resources/langs/nheko_fr.ts b/resources/langs/nheko_fr.ts index 42f82b0f..8ef22268 100644 --- a/resources/langs/nheko_fr.ts +++ b/resources/langs/nheko_fr.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - Enregistrer le fichier - - ChatPage - - Failed to upload image. Please try again. + + Failed to upload media. Please try again. - - Failed to upload file. Please try again. - - - - - Failed to upload audio. Please try again. - - - - - Failed to upload video. Please try again. - - - - + Failed to restore OLM account. Please login again. @@ -42,18 +19,18 @@ - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. - + Please try to login again: %1 - + Room creation failed: %1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - Enregistrer le fichier - - - - ImageItem - - - Save image - Enregistrer l'image + + Encrypted + @@ -200,7 +169,7 @@ MemberList - + Room members Membres du salon @@ -210,6 +179,27 @@ + + MessageDelegate + + + redacted + + + + + Encryption enabled + + + + + Placeholder + + + unimplemented event: + + + QuickSwitcher @@ -278,7 +268,7 @@ RoomInfo - + no version stored @@ -286,12 +276,12 @@ RoomInfoListItem - + Leave room Quitter le salon - + Accept Accepter @@ -332,36 +322,36 @@ StatusIndicator - - Encrypted + + Failed - - Delivered - - - - - Seen - - - - + Sent + + + Received + + + + + Read + + TextInputWidget - + Send a file - + Write a message... Écrivez un message... @@ -376,7 +366,7 @@ - + Select a file Sélectionnez un fichier @@ -392,32 +382,9 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - - - - - Reply - - - - - Options - - - - - TimelineView - - - Encryption is enabled - - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted @@ -441,16 +408,87 @@ - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet + + + Message redaction failed: %1 + + + + + Save image + Enregistrer l'image + + + + Save video + + + + + Save audio + + + + + Save file + + + + + TimelineRow + + + Reply + + + + + Options + + + + + Read receipts + Accusés de lecture + + + + Mark as read + + + + + View raw message + + + + + Redact message + + + + + Save as + + + + + TimelineView + + + No room open + + TopRoomBar - + Room options @@ -516,7 +554,7 @@ UserSettingsPage - + Minimize to tray Réduire à la barre des tâches @@ -530,6 +568,11 @@ Group's sidebar Barre latérale des groupes + + + Circular Avatars + + Typing notifications @@ -606,7 +649,7 @@ GÉNÉRAL - + Open Sessions File @@ -826,7 +869,7 @@ Taille du média : %2 dialogs::ReadReceipts - + Read receipts Accusés de lecture @@ -952,7 +995,7 @@ Taille du média : %2 - + Select an avatar @@ -978,19 +1021,6 @@ Taille du média : %2 - - dialogs::UserMentions - - - This Room - - - - - All Rooms - - - dialogs::UserProfile @@ -1014,7 +1044,7 @@ Taille du média : %2 - + Devices @@ -1065,69 +1095,103 @@ Taille du média : %2 message-description sent: - - %1 an audio clip + + You sent an audio clip - %1 an image + %1 sent an audio clip + + + + + You sent an image - %1 a file + %1 sent an image + + + + + You sent a file - %1 a video clip + %1 sent a file + + + + + You sent a video - %1 a sticker + %1 sent a video + + + + + You sent a sticker - %1 a notification + %1 sent a sticker + + + + + You sent a notification + + + + + %1 sent a notification + + + + + You: %1 + + + + + %1: %2 - %1 an encrypted message + You sent an encrypted message + + + + + %1 sent an encrypted message - message-description: + popups::UserMentions - - sent - For when someone else is the sender + + This Room - - - message-description: - - sent - For when you are the sender + + All Rooms utils - - - You - - - - + sent a file. @@ -1147,7 +1211,7 @@ Taille du média : %2 - + Unknown Message Type diff --git a/resources/langs/nheko_nl.ts b/resources/langs/nheko_nl.ts index 53840f82..aaeae41c 100644 --- a/resources/langs/nheko_nl.ts +++ b/resources/langs/nheko_nl.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - Bestand opslaan - - ChatPage - - Failed to upload image. Please try again. + + Failed to upload media. Please try again. - - Failed to upload file. Please try again. - - - - - Failed to upload audio. Please try again. - - - - - Failed to upload video. Please try again. - - - - + Failed to restore OLM account. Please login again. @@ -42,18 +19,18 @@ - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. - + Please try to login again: %1 - + Room creation failed: %1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - Bestand opslaan - - - - ImageItem - - - Save image - Afbeelding opslaan + + Encrypted + @@ -200,7 +169,7 @@ MemberList - + Room members Kamerleden @@ -210,6 +179,27 @@ + + MessageDelegate + + + redacted + + + + + Encryption enabled + + + + + Placeholder + + + unimplemented event: + + + QuickSwitcher @@ -277,7 +267,7 @@ RoomInfo - + no version stored @@ -285,12 +275,12 @@ RoomInfoListItem - + Leave room Kamer verlaten - + Accept Accepteren @@ -331,36 +321,36 @@ StatusIndicator - - Encrypted + + Failed - - Delivered - - - - - Seen - - - - + Sent + + + Received + + + + + Read + + TextInputWidget - + Send a file - + Write a message... Typ een bericht... @@ -375,7 +365,7 @@ - + Select a file Kies een bestand @@ -391,32 +381,9 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - - - - - Reply - - - - - Options - - - - - TimelineView - - - Encryption is enabled - - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted @@ -440,16 +407,87 @@ - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet + + + Message redaction failed: %1 + + + + + Save image + Afbeelding opslaan + + + + Save video + + + + + Save audio + + + + + Save file + + + + + TimelineRow + + + Reply + + + + + Options + + + + + Read receipts + Leesbevestigingen + + + + Mark as read + + + + + View raw message + + + + + Redact message + + + + + Save as + + + + + TimelineView + + + No room open + + TopRoomBar - + Room options @@ -515,7 +553,7 @@ UserSettingsPage - + Minimize to tray Minimaliseren naar systeemvak @@ -529,6 +567,11 @@ Group's sidebar Zijbalk van groep + + + Circular Avatars + + Typing notifications @@ -605,7 +648,7 @@ ALGEMEEN - + Open Sessions File @@ -825,7 +868,7 @@ Mediagrootte: %2 dialogs::ReadReceipts - + Read receipts Leesbevestigingen @@ -951,7 +994,7 @@ Mediagrootte: %2 - + Select an avatar @@ -977,19 +1020,6 @@ Mediagrootte: %2 - - dialogs::UserMentions - - - This Room - - - - - All Rooms - - - dialogs::UserProfile @@ -1013,7 +1043,7 @@ Mediagrootte: %2 - + Devices @@ -1064,69 +1094,103 @@ Mediagrootte: %2 message-description sent: - - %1 an audio clip + + You sent an audio clip - %1 an image + %1 sent an audio clip + + + + + You sent an image - %1 a file + %1 sent an image + + + + + You sent a file - %1 a video clip + %1 sent a file + + + + + You sent a video - %1 a sticker + %1 sent a video + + + + + You sent a sticker - %1 a notification + %1 sent a sticker + + + + + You sent a notification + + + + + %1 sent a notification + + + + + You: %1 + + + + + %1: %2 - %1 an encrypted message + You sent an encrypted message + + + + + %1 sent an encrypted message - message-description: + popups::UserMentions - - sent - For when someone else is the sender + + This Room - - - message-description: - - sent - For when you are the sender + + All Rooms utils - - - You - - - - + sent a file. @@ -1146,7 +1210,7 @@ Mediagrootte: %2 - + Unknown Message Type diff --git a/resources/langs/nheko_pl.ts b/resources/langs/nheko_pl.ts index f4f98dbb..b7c3878d 100644 --- a/resources/langs/nheko_pl.ts +++ b/resources/langs/nheko_pl.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - Zapisz plik - - ChatPage - - Failed to upload image. Please try again. - Nie udało się wysłać obrazu. Spróbuj ponownie. + + Failed to upload media. Please try again. + - - Failed to upload file. Please try again. - Nie udało się wysłać pliku. Spróbuj ponownie. - - - - Failed to upload audio. Please try again. - Nie udało się wysłać pliku dźwiękowego. Spróbuj ponownie. - - - - Failed to upload video. Please try again. - Nie udało się wysłać filmu. Spróbuj ponownie. - - - + Failed to restore OLM account. Please login again. Nie udało się przywrócić konta OLM. Spróbuj zalogować się ponownie. @@ -42,18 +19,18 @@ Nie udało się przywrócić zapisanych danych. Spróbuj zalogować się ponownie. - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. - + Please try to login again: %1 Spróbuj zalogować się ponownie: %1 - + Room creation failed: %1 Tworzenie pokoju nie powiodło się: %1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - Zapisz plik - - - - ImageItem - - - Save image - Zapisz obraz + + Encrypted + @@ -200,7 +169,7 @@ MemberList - + Room members Członkowie pokoju @@ -210,6 +179,27 @@ + + MessageDelegate + + + redacted + + + + + Encryption enabled + + + + + Placeholder + + + unimplemented event: + + + QuickSwitcher @@ -277,7 +267,7 @@ RoomInfo - + no version stored @@ -285,12 +275,12 @@ RoomInfoListItem - + Leave room Opuść pokój - + Accept Akceptuj @@ -331,36 +321,36 @@ StatusIndicator - - Encrypted - Szyfrowana + + Failed + - - Delivered - Dostarczono - - - - Seen - Wyświetlona - - - + Sent - Wysłana + + + + + Received + + + + + Read + TextInputWidget - + Send a file Wyślij plik - + Write a message... Napisz wiadomość… @@ -375,7 +365,7 @@ Emoji - + Select a file Wybierz plik @@ -391,32 +381,9 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - Redagowanie wiadomości nie powiodło się: %1 - - - - Reply - - - - - Options - - - - - TimelineView - - - Encryption is enabled - Szyfrowanie jest włączone - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted @@ -440,16 +407,87 @@ - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet + + + Message redaction failed: %1 + Redagowanie wiadomości nie powiodło się: %1 + + + + Save image + Zapisz obraz + + + + Save video + + + + + Save audio + + + + + Save file + + + + + TimelineRow + + + Reply + + + + + Options + + + + + Read receipts + Potwierdzenia przeczytania + + + + Mark as read + + + + + View raw message + + + + + Redact message + + + + + Save as + + + + + TimelineView + + + No room open + + TopRoomBar - + Room options Ustawienia pokoju @@ -516,7 +554,7 @@ UserSettingsPage - + Minimize to tray Zminimalizuj do paska zadań @@ -530,6 +568,11 @@ Group's sidebar Pasek boczny grupy + + + Circular Avatars + + Typing notifications @@ -606,7 +649,7 @@ OGÓLNE - + Open Sessions File @@ -826,7 +869,7 @@ Rozmiar multimediów: %2 dialogs::ReadReceipts - + Read receipts Potwierdzenia przeczytania @@ -955,7 +998,7 @@ Rozmiar multimediów: %2 Nie udało się włączyć szyfrowania: %1 - + Select an avatar Wybierz awatar @@ -981,19 +1024,6 @@ Rozmiar multimediów: %2 Nie udało się wysłać obrazu: %s - - dialogs::UserMentions - - - This Room - - - - - All Rooms - - - dialogs::UserProfile @@ -1017,7 +1047,7 @@ Rozmiar multimediów: %2 Rozpocznij rozmowę - + Devices Urządzenia @@ -1068,69 +1098,103 @@ Rozmiar multimediów: %2 message-description sent: - - %1 an audio clip + + You sent an audio clip - %1 an image + %1 sent an audio clip + + + + + You sent an image - %1 a file + %1 sent an image + + + + + You sent a file - %1 a video clip + %1 sent a file + + + + + You sent a video - %1 a sticker + %1 sent a video + + + + + You sent a sticker - %1 a notification + %1 sent a sticker + + + + + You sent a notification + + + + + %1 sent a notification + + + + + You: %1 + + + + + %1: %2 - %1 an encrypted message + You sent an encrypted message + + + + + %1 sent an encrypted message - message-description: + popups::UserMentions - - sent - For when someone else is the sender + + This Room - - - message-description: - - sent - For when you are the sender + + All Rooms utils - - - You - - - - + sent a file. @@ -1150,7 +1214,7 @@ Rozmiar multimediów: %2 - + Unknown Message Type diff --git a/resources/langs/nheko_ru.ts b/resources/langs/nheko_ru.ts index 04285c72..3069cdad 100644 --- a/resources/langs/nheko_ru.ts +++ b/resources/langs/nheko_ru.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - Сохранить файл - - ChatPage - - Failed to upload image. Please try again. - Не удалось загрузить изображение. Пожалуйста, попробуйте еще раз. + + Failed to upload media. Please try again. + - - Failed to upload file. Please try again. - Не удалось загрузить файл. Пожалуйста, попробуйте еще раз. - - - - Failed to upload audio. Please try again. - Не удалось загрузить аудио. Пожалуйста, попробуйте еще раз. - - - - Failed to upload video. Please try again. - Не удалось загрузить видео. Пожалуйста, попробуйте еще раз. - - - + Failed to restore OLM account. Please login again. Не удалось восстановить учетную запись OLM. Пожалуйста, войдите снова. @@ -42,18 +19,18 @@ Не удалось восстановить сохраненные данные. Пожалуйста, войдите снова. - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. Не удалось настроить ключи шифрования. Ответ сервера:%1 %2. Пожалуйста, попробуйте позже. - + Please try to login again: %1 Повторите попытку входа: %1 - + Room creation failed: %1 Не удалось создать комнату: %1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - Сохранить файл - - - - ImageItem - - - Save image - Сохранить изображение + + Encrypted + @@ -200,7 +169,7 @@ MemberList - + Room members Участники комнаты @@ -210,6 +179,27 @@ + + MessageDelegate + + + redacted + + + + + Encryption enabled + + + + + Placeholder + + + unimplemented event: + + + QuickSwitcher @@ -277,7 +267,7 @@ RoomInfo - + no version stored @@ -285,12 +275,12 @@ RoomInfoListItem - + Leave room Покинуть комнату - + Accept Принять @@ -331,36 +321,36 @@ StatusIndicator - - Encrypted - Зашифровано + + Failed + - - Delivered - Доставлено - - - - Seen - Прочитано - - - + Sent - Отправлено + + + + + Received + + + + + Read + TextInputWidget - + Send a file Отправить файл - + Write a message... Написать сообщение... @@ -375,7 +365,7 @@ - + Select a file Выберите файл @@ -391,32 +381,9 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - Ошибка редактирования сообщения: %1 - - - - Reply - - - - - Options - - - - - TimelineView - - - Encryption is enabled - Шифрование включено - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted @@ -440,16 +407,87 @@ - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet + + + Message redaction failed: %1 + Ошибка редактирования сообщения: %1 + + + + Save image + Сохранить изображение + + + + Save video + + + + + Save audio + + + + + Save file + + + + + TimelineRow + + + Reply + + + + + Options + + + + + Read receipts + Подтверждать прочтение + + + + Mark as read + + + + + View raw message + + + + + Redact message + + + + + Save as + + + + + TimelineView + + + No room open + + TopRoomBar - + Room options Настройки комнаты @@ -516,7 +554,7 @@ UserSettingsPage - + Minimize to tray Сворачивать в системную панель @@ -530,6 +568,11 @@ Group's sidebar Боковая панель групп + + + Circular Avatars + + Typing notifications @@ -606,7 +649,7 @@ ГЛАВНОЕ - + Open Sessions File Открыть файл сеансов @@ -827,7 +870,7 @@ Media size: %2 dialogs::ReadReceipts - + Read receipts Подтверждать прочтение @@ -954,7 +997,7 @@ Media size: %2 Не удалось включить шифрование: %1 - + Select an avatar Выберите аватар @@ -980,19 +1023,6 @@ Media size: %2 Не удалось загрузить изображение: %s - - dialogs::UserMentions - - - This Room - - - - - All Rooms - - - dialogs::UserProfile @@ -1016,7 +1046,7 @@ Media size: %2 Начать разговор - + Devices Устройства @@ -1067,69 +1097,103 @@ Media size: %2 message-description sent: - - %1 an audio clip + + You sent an audio clip - %1 an image + %1 sent an audio clip + + + + + You sent an image - %1 a file + %1 sent an image + + + + + You sent a file - %1 a video clip + %1 sent a file + + + + + You sent a video - %1 a sticker + %1 sent a video + + + + + You sent a sticker - %1 a notification + %1 sent a sticker + + + + + You sent a notification + + + + + %1 sent a notification + + + + + You: %1 + + + + + %1: %2 - %1 an encrypted message + You sent an encrypted message + + + + + %1 sent an encrypted message - message-description: + popups::UserMentions - - sent - For when someone else is the sender + + This Room - - - message-description: - - sent - For when you are the sender + + All Rooms utils - - - You - - - - + sent a file. @@ -1149,7 +1213,7 @@ Media size: %2 - + Unknown Message Type diff --git a/resources/langs/nheko_zh_CN.ts b/resources/langs/nheko_zh_CN.ts index 1e539e64..31ca068c 100644 --- a/resources/langs/nheko_zh_CN.ts +++ b/resources/langs/nheko_zh_CN.ts @@ -1,38 +1,15 @@ - - AudioItem - - - Save File - 保存文件 - - ChatPage - - Failed to upload image. Please try again. - 上传图像失败。请重试。 + + Failed to upload media. Please try again. + - - Failed to upload file. Please try again. - 上传文件失败,请重试。 - - - - Failed to upload audio. Please try again. - 上传音频失败。请重试。 - - - - Failed to upload video. Please try again. - 上传视频失败。请重试。 - - - + Failed to restore OLM account. Please login again. 恢复 OLM 账户失败。请重新登录。 @@ -42,18 +19,18 @@ 恢复保存的数据失败。请重新登录。 - + Failed to setup encryption keys. Server response: %1 %2. Please try again later. - + Please try to login again: %1 请尝试再次登录:%1 - + Room creation failed: %1 创建聊天室失败:%1 @@ -116,19 +93,11 @@ - FileItem + EncryptionIndicator - - Save File - 保存文件 - - - - ImageItem - - - Save image - 保存图像 + + Encrypted + @@ -200,7 +169,7 @@ MemberList - + Room members 聊天室成员 @@ -210,6 +179,27 @@ + + MessageDelegate + + + redacted + + + + + Encryption enabled + + + + + Placeholder + + + unimplemented event: + + + QuickSwitcher @@ -277,7 +267,7 @@ RoomInfo - + no version stored @@ -285,12 +275,12 @@ RoomInfoListItem - + Leave room 离开聊天室 - + Accept 接受 @@ -331,36 +321,36 @@ StatusIndicator - - Encrypted - 加密的 + + Failed + - - Delivered - 已送达 - - - - Seen - 已阅读 - - - + Sent - 已发送 + + + + + Received + + + + + Read + TextInputWidget - + Send a file 发送一个文件 - + Write a message... 写一条消息... @@ -375,7 +365,7 @@ - + Select a file 选择一个文件 @@ -391,32 +381,9 @@ - TimelineItem + TimelineModel - - Message redaction failed: %1 - 删除消息失败:%1 - - - - Reply - - - - - Options - - - - - TimelineView - - - Encryption is enabled - 加密已启用 - - - + -- Encrypted Event (No keys found for decryption) -- Placeholder, when the message was not decrypted yet or can't be decrypted @@ -440,16 +407,87 @@ - + -- Encrypted Event (Unknown event type) -- Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet + + + Message redaction failed: %1 + 删除消息失败:%1 + + + + Save image + 保存图像 + + + + Save video + + + + + Save audio + + + + + Save file + + + + + TimelineRow + + + Reply + + + + + Options + + + + + Read receipts + 阅读回执 + + + + Mark as read + + + + + View raw message + + + + + Redact message + + + + + Save as + + + + + TimelineView + + + No room open + + TopRoomBar - + Room options 聊天室选项 @@ -514,7 +552,7 @@ UserSettingsPage - + Minimize to tray 最小化至托盘 @@ -528,6 +566,11 @@ Group's sidebar 群组侧边栏 + + + Circular Avatars + + Typing notifications @@ -604,7 +647,7 @@ 通用 - + Open Sessions File 打开会话文件 @@ -824,7 +867,7 @@ Media size: %2 dialogs::ReadReceipts - + Read receipts 阅读回执 @@ -951,7 +994,7 @@ Media size: %2 启用加密失败:%1 - + Select an avatar 选择一个头像 @@ -977,19 +1020,6 @@ Media size: %2 上传图像失败:%s - - dialogs::UserMentions - - - This Room - - - - - All Rooms - - - dialogs::UserProfile @@ -1013,7 +1043,7 @@ Media size: %2 开始一个聊天 - + Devices 设备 @@ -1072,69 +1102,103 @@ Media size: %2 message-description sent: - - %1 an audio clip + + You sent an audio clip - %1 an image + %1 sent an audio clip + + + + + You sent an image - %1 a file + %1 sent an image + + + + + You sent a file - %1 a video clip + %1 sent a file + + + + + You sent a video - %1 a sticker + %1 sent a video + + + + + You sent a sticker - %1 a notification + %1 sent a sticker + + + + + You sent a notification + + + + + %1 sent a notification + + + + + You: %1 + + + + + %1: %2 - %1 an encrypted message + You sent an encrypted message + + + + + %1 sent an encrypted message - message-description: + popups::UserMentions - - sent - For when someone else is the sender + + This Room - - - message-description: - - sent - For when you are the sender + + All Rooms utils - - - You - - - - + sent a file. @@ -1154,7 +1218,7 @@ Media size: %2 - + Unknown Message Type diff --git a/resources/qml/Avatar.qml b/resources/qml/Avatar.qml new file mode 100644 index 00000000..a53f057b --- /dev/null +++ b/resources/qml/Avatar.qml @@ -0,0 +1,51 @@ +import QtQuick 2.6 +import QtGraphicalEffects 1.0 +import Qt.labs.settings 1.0 + +Rectangle { + id: avatar + width: 48 + height: 48 + radius: settings.avatar_circles ? height/2 : 3 + + Settings { + id: settings + category: "user" + property bool avatar_circles: true + } + + property alias url: img.source + property string displayName + + Text { + anchors.fill: parent + text: String.fromCodePoint(displayName.codePointAt(0)) + color: colors.text + font.pixelSize: avatar.height/2 + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + } + + Image { + id: img + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + mipmap: true + smooth: false + + sourceSize.width: avatar.width + sourceSize.height: avatar.height + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + anchors.fill: parent + width: avatar.width + height: avatar.height + radius: settings.avatar_circles ? height/2 : 3 + } + } + } + color: colors.dark +} diff --git a/resources/qml/EncryptionIndicator.qml b/resources/qml/EncryptionIndicator.qml new file mode 100644 index 00000000..905cf934 --- /dev/null +++ b/resources/qml/EncryptionIndicator.qml @@ -0,0 +1,24 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.1 +import im.nheko 1.0 + +Rectangle { + id: indicator + color: "transparent" + width: 16 + height: 16 + ToolTip.visible: ma.containsMouse && indicator.visible + ToolTip.text: qsTr("Encrypted") + MouseArea{ + id: ma + anchors.fill: parent + hoverEnabled: true + } + + Image { + id: stateImg + anchors.fill: parent + source: "image://colorimage/:/icons/icons/ui/lock.png?"+colors.buttonText + } +} + diff --git a/resources/qml/ImageButton.qml b/resources/qml/ImageButton.qml new file mode 100644 index 00000000..dc576e18 --- /dev/null +++ b/resources/qml/ImageButton.qml @@ -0,0 +1,29 @@ +import QtQuick 2.3 +import QtQuick.Controls 2.3 + +Button { + property string image: undefined + + id: button + + flat: true + + // disable background, because we don't want a border on hover + background: Item { + } + + Image { + id: buttonImg + // Workaround, can't get icon.source working for now... + anchors.fill: parent + source: "image://colorimage/" + image + "?" + (button.hovered ? colors.highlight : colors.buttonText) + } + + MouseArea + { + id: mouseArea + anchors.fill: parent + onPressed: mouse.accepted = false + cursorShape: Qt.PointingHandCursor + } +} diff --git a/resources/qml/MatrixText.qml b/resources/qml/MatrixText.qml new file mode 100644 index 00000000..46e74711 --- /dev/null +++ b/resources/qml/MatrixText.qml @@ -0,0 +1,33 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.3 + +TextEdit { + textFormat: TextEdit.RichText + readOnly: true + wrapMode: Text.Wrap + selectByMouse: true + color: colors.text + + onLinkActivated: { + if (/^https:\/\/matrix.to\/#\/(@.*)$/.test(link)) chat.model.openUserProfile(/^https:\/\/matrix.to\/#\/(@.*)$/.exec(link)[1]) + else if (/^https:\/\/matrix.to\/#\/(![^\/]*)$/.test(link)) timelineManager.setHistoryView(/^https:\/\/matrix.to\/#\/(!.*)$/.exec(link)[1]) + else if (/^https:\/\/matrix.to\/#\/(![^\/]*)\/(\$.*)$/.test(link)) { + var match = /^https:\/\/matrix.to\/#\/(![^\/]*)\/(\$.*)$/.exec(link) + timelineManager.setHistoryView(match[1]) + chat.positionViewAtIndex(chat.model.idToIndex(match[2]), ListView.Contain) + } + else Qt.openUrlExternally(link) + } + MouseArea + { + anchors.fill: parent + onPressed: mouse.accepted = false + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } + + ToolTip { + visible: parent.hoveredLink + text: parent.hoveredLink + palette: colors + } +} diff --git a/resources/qml/StatusIndicator.qml b/resources/qml/StatusIndicator.qml new file mode 100644 index 00000000..91e8f769 --- /dev/null +++ b/resources/qml/StatusIndicator.qml @@ -0,0 +1,38 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.1 +import im.nheko 1.0 + +Rectangle { + id: indicator + property int state: 0 + color: "transparent" + width: 16 + height: 16 + ToolTip.visible: ma.containsMouse && state != MtxEvent.Empty + ToolTip.text: switch (state) { + case MtxEvent.Failed: return qsTr("Failed") + case MtxEvent.Sent: return qsTr("Sent") + case MtxEvent.Received: return qsTr("Received") + case MtxEvent.Read: return qsTr("Read") + default: return "" + } + MouseArea{ + id: ma + anchors.fill: parent + hoverEnabled: true + } + + Image { + id: stateImg + // Workaround, can't get icon.source working for now... + anchors.fill: parent + source: switch (indicator.state) { + case MtxEvent.Failed: return "image://colorimage/:/icons/icons/ui/remove-symbol.png?" + colors.buttonText + case MtxEvent.Sent: return "image://colorimage/:/icons/icons/ui/clock.png?" + colors.buttonText + case MtxEvent.Received: return "image://colorimage/:/icons/icons/ui/checkmark.png?" + colors.buttonText + case MtxEvent.Read: return "image://colorimage/:/icons/icons/ui/double-tick-indicator.png?" + colors.buttonText + default: return "" + } + } +} + diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml new file mode 100644 index 00000000..2c2ed02a --- /dev/null +++ b/resources/qml/TimelineRow.qml @@ -0,0 +1,122 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import QtQuick.Window 2.2 + +import im.nheko 1.0 + +import "./delegates" + +RowLayout { + property var view: chat + + anchors.leftMargin: avatarSize + 4 + anchors.left: parent.left + anchors.right: parent.right + + height: Math.max(contentItem.height, 16) + + Column { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + + //property var replyTo: model.replyTo + + //Text { + // property int idx: timelineManager.timeline.idToIndex(replyTo) + // text: "" + (idx != -1 ? timelineManager.timeline.data(timelineManager.timeline.index(idx, 0), 2) : "nothing") + //} + MessageDelegate { + id: contentItem + + width: parent.width + height: childrenRect.height + } + } + + StatusIndicator { + state: model.state + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredHeight: 16 + } + + EncryptionIndicator { + visible: model.isEncrypted + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredHeight: 16 + } + + ImageButton { + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredHeight: 16 + id: replyButton + + image: ":/icons/icons/ui/mail-reply.png" + ToolTip { + visible: replyButton.hovered + text: qsTr("Reply") + palette: colors + } + + onClicked: view.model.replyAction(model.id) + } + ImageButton { + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredHeight: 16 + id: optionsButton + + image: ":/icons/icons/ui/vertical-ellipsis.png" + ToolTip { + visible: optionsButton.hovered + text: qsTr("Options") + palette: colors + } + + onClicked: contextMenu.open() + + Menu { + y: optionsButton.height + id: contextMenu + palette: colors + + MenuItem { + text: qsTr("Read receipts") + onTriggered: view.model.readReceiptsAction(model.id) + } + MenuItem { + text: qsTr("Mark as read") + } + MenuItem { + text: qsTr("View raw message") + onTriggered: view.model.viewRawMessage(model.id) + } + MenuItem { + text: qsTr("Redact message") + onTriggered: view.model.redactEvent(model.id) + } + MenuItem { + visible: model.type == MtxEvent.ImageMessage || model.type == MtxEvent.VideoMessage || model.type == MtxEvent.AudioMessage || model.type == MtxEvent.FileMessage || model.type == MtxEvent.Sticker + text: qsTr("Save as") + onTriggered: timelineManager.timeline.saveMedia(model.id) + } + } + } + + Text { + Layout.alignment: Qt.AlignRight | Qt.AlignTop + text: model.timestamp.toLocaleTimeString("HH:mm") + color: inactiveColors.text + + MouseArea{ + id: ma + anchors.fill: parent + hoverEnabled: true + } + + ToolTip { + visible: ma.containsMouse + text: Qt.formatDateTime(model.timestamp, Qt.DefaultLocaleLongDate) + palette: colors + } + } +} diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml new file mode 100644 index 00000000..1a1900ad --- /dev/null +++ b/resources/qml/TimelineView.qml @@ -0,0 +1,185 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.1 +import QtQuick.Layouts 1.2 +import QtGraphicalEffects 1.0 +import QtQuick.Window 2.2 + +import im.nheko 1.0 + +import "./delegates" + +Item { + property var colors: currentActivePalette + property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled } + property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive + property int avatarSize: 40 + + Rectangle { + anchors.fill: parent + color: colors.window + + Text { + visible: !timelineManager.timeline && !timelineManager.isInitialSync + anchors.centerIn: parent + text: qsTr("No room open") + font.pointSize: 24 + color: colors.windowText + } + + BusyIndicator { + anchors.centerIn: parent + running: timelineManager.isInitialSync + height: 200 + width: 200 + } + + ListView { + id: chat + + cacheBuffer: 2000 + + visible: timelineManager.timeline != null + anchors.fill: parent + + anchors.leftMargin: 4 + anchors.rightMargin: scrollbar.width + + model: timelineManager.timeline + + boundsBehavior: Flickable.StopAtBounds + + onVerticalOvershootChanged: contentY = contentY - verticalOvershoot + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + z: -1 + onWheel: { + if (wheel.angleDelta != 0) { + chat.contentY = chat.contentY - wheel.angleDelta.y + wheel.accepted = true + chat.forceLayout() + chat.updatePosition() + } + } + } + + onModelChanged: { + if (model) { + currentIndex = model.currentIndex + if (model.currentIndex == count - 1) { + positionViewAtEnd() + } else { + positionViewAtIndex(model.currentIndex, ListView.End) + } + } + } + + ScrollBar.vertical: ScrollBar { + id: scrollbar + parent: chat.parent + anchors.top: chat.top + anchors.left: chat.right + anchors.bottom: chat.bottom + onPressedChanged: if (!pressed) chat.updatePosition() + } + + property bool atBottom: false + onCountChanged: { + if (atBottom) { + var newIndex = count - 1 // last index + positionViewAtEnd() + currentIndex = newIndex + model.currentIndex = newIndex + } + + if (contentHeight < height && model) { + model.fetchHistory(); + } + } + + onAtYBeginningChanged: if (atYBeginning) { chat.model.currentIndex = 0; chat.currentIndex = 0; model.fetchHistory(); } + + function updatePosition() { + for (var y = chat.contentY + chat.height; y > chat.height; y -= 9) { + var i = chat.itemAt(100, y); + if (!i) continue; + if (!i.isFullyVisible()) continue; + chat.model.currentIndex = i.getIndex(); + chat.currentIndex = i.getIndex() + atBottom = i.getIndex() == count - 1; + break; + } + } + onMovementEnded: updatePosition() + + spacing: 4 + delegate: TimelineRow { + function isFullyVisible() { + return height > 1 && (y - chat.contentY - 1) + height < chat.height + } + function getIndex() { + return index; + } + } + + section { + property: "section" + delegate: Column { + topPadding: 4 + bottomPadding: 4 + spacing: 8 + + width: parent.width + height: (section.includes(" ") ? dateBubble.height + 8 + userName.height : userName.height) + 8 + + Label { + id: dateBubble + anchors.horizontalCenter: parent.horizontalCenter + visible: section.includes(" ") + text: chat.model.formatDateSeparator(new Date(Number(section.split(" ")[1]))) + color: colors.windowText + + height: contentHeight * 1.2 + width: contentWidth * 1.2 + horizontalAlignment: Text.AlignHCenter + background: Rectangle { + radius: parent.height / 2 + color: colors.dark + } + } + Row { + height: userName.height + spacing: 4 + Avatar { + width: avatarSize + height: avatarSize + url: chat.model.avatarUrl(section.split(" ")[0]).replace("mxc://", "image://MxcImage/") + displayName: chat.model.displayName(section.split(" ")[0]) + + MouseArea { + anchors.fill: parent + onClicked: chat.model.openUserProfile(section.split(" ")[0]) + cursorShape: Qt.PointingHandCursor + } + } + + Text { + id: userName + text: chat.model.escapeEmoji(chat.model.displayName(section.split(" ")[0])) + color: chat.model.userColor(section.split(" ")[0], colors.window) + textFormat: Text.RichText + + MouseArea { + anchors.fill: parent + onClicked: chat.model.openUserProfile(section.split(" ")[0]) + cursorShape: Qt.PointingHandCursor + } + } + } + } + } + } + } +} diff --git a/resources/qml/delegates/FileMessage.qml b/resources/qml/delegates/FileMessage.qml new file mode 100644 index 00000000..2c911c5e --- /dev/null +++ b/resources/qml/delegates/FileMessage.qml @@ -0,0 +1,57 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.2 + +Rectangle { + radius: 10 + color: colors.dark + height: row.height + 24 + width: parent ? parent.width : undefined + + RowLayout { + id: row + + anchors.centerIn: parent + width: parent.width - 24 + + spacing: 15 + + Rectangle { + id: button + color: colors.light + radius: 22 + height: 44 + width: 44 + Image { + id: img + anchors.centerIn: parent + + source: "qrc:/icons/icons/ui/arrow-pointing-down.png" + fillMode: Image.Pad + + } + MouseArea { + anchors.fill: parent + onClicked: timelineManager.timeline.saveMedia(model.id) + cursorShape: Qt.PointingHandCursor + } + } + ColumnLayout { + id: col + + Text { + Layout.fillWidth: true + text: model.body + textFormat: Text.PlainText + elide: Text.ElideRight + color: colors.text + } + Text { + Layout.fillWidth: true + text: model.filesize + textFormat: Text.PlainText + elide: Text.ElideRight + color: colors.text + } + } + } +} diff --git a/resources/qml/delegates/ImageMessage.qml b/resources/qml/delegates/ImageMessage.qml new file mode 100644 index 00000000..1b6e5729 --- /dev/null +++ b/resources/qml/delegates/ImageMessage.qml @@ -0,0 +1,23 @@ +import QtQuick 2.6 + +import im.nheko 1.0 + +Item { + width: Math.min(parent ? parent.width : undefined, model.width) + height: width * model.proportionalHeight + + Image { + id: img + anchors.fill: parent + + source: model.url.replace("mxc://", "image://MxcImage/") + asynchronous: true + fillMode: Image.PreserveAspectFit + + MouseArea { + enabled: model.type == MtxEvent.ImageMessage + anchors.fill: parent + onClicked: timelineManager.openImageOverlay(model.url, model.id) + } + } +} diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml new file mode 100644 index 00000000..178dfd86 --- /dev/null +++ b/resources/qml/delegates/MessageDelegate.qml @@ -0,0 +1,55 @@ +import QtQuick 2.6 +import im.nheko 1.0 + +DelegateChooser { + //role: "type" //< not supported in our custom implementation, have to use roleValue + roleValue: model.type + + DelegateChoice { + roleValue: MtxEvent.TextMessage + TextMessage {} + } + DelegateChoice { + roleValue: MtxEvent.NoticeMessage + NoticeMessage {} + } + DelegateChoice { + roleValue: MtxEvent.EmoteMessage + TextMessage {} + } + DelegateChoice { + roleValue: MtxEvent.ImageMessage + ImageMessage {} + } + DelegateChoice { + roleValue: MtxEvent.Sticker + ImageMessage {} + } + DelegateChoice { + roleValue: MtxEvent.FileMessage + FileMessage {} + } + DelegateChoice { + roleValue: MtxEvent.VideoMessage + PlayableMediaMessage {} + } + DelegateChoice { + roleValue: MtxEvent.AudioMessage + PlayableMediaMessage {} + } + DelegateChoice { + roleValue: MtxEvent.Redacted + Pill { + text: qsTr("redacted") + } + } + DelegateChoice { + roleValue: MtxEvent.Encryption + Pill { + text: qsTr("Encryption enabled") + } + } + DelegateChoice { + Placeholder {} + } +} diff --git a/resources/qml/delegates/NoticeMessage.qml b/resources/qml/delegates/NoticeMessage.qml new file mode 100644 index 00000000..a392eb5b --- /dev/null +++ b/resources/qml/delegates/NoticeMessage.qml @@ -0,0 +1,8 @@ +import ".." + +MatrixText { + text: model.formattedBody + width: parent ? parent.width : undefined + font.italic: true + color: inactiveColors.text +} diff --git a/resources/qml/delegates/Pill.qml b/resources/qml/delegates/Pill.qml new file mode 100644 index 00000000..53a9684e --- /dev/null +++ b/resources/qml/delegates/Pill.qml @@ -0,0 +1,14 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.1 + +Label { + color: inactiveColors.text + horizontalAlignment: Text.AlignHCenter + + height: contentHeight * 1.2 + width: contentWidth * 1.2 + background: Rectangle { + radius: parent.height / 2 + color: colors.dark + } +} diff --git a/resources/qml/delegates/Placeholder.qml b/resources/qml/delegates/Placeholder.qml new file mode 100644 index 00000000..4c0e68c3 --- /dev/null +++ b/resources/qml/delegates/Placeholder.qml @@ -0,0 +1,7 @@ +import ".." + +MatrixText { + text: qsTr("unimplemented event: ") + model.type + width: parent ? parent.width : undefined + color: inactiveColors.text +} diff --git a/resources/qml/delegates/PlayableMediaMessage.qml b/resources/qml/delegates/PlayableMediaMessage.qml new file mode 100644 index 00000000..d0d4d7cb --- /dev/null +++ b/resources/qml/delegates/PlayableMediaMessage.qml @@ -0,0 +1,164 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.2 +import QtQuick.Controls 2.1 +import QtMultimedia 5.6 + +import im.nheko 1.0 + +Rectangle { + id: bg + radius: 10 + color: colors.dark + height: content.height + 24 + width: parent ? parent.width : undefined + + Column { + id: content + width: parent.width - 24 + anchors.centerIn: parent + + Rectangle { + id: videoContainer + visible: model.type == MtxEvent.VideoMessage + width: Math.min(parent.width, model.width ? model.width : 400) // some media has 0 as size... + height: width*model.proportionalHeight + Image { + anchors.fill: parent + source: model.thumbnailUrl.replace("mxc://", "image://MxcImage/") + asynchronous: true + fillMode: Image.PreserveAspectFit + + VideoOutput { + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectFit + source: media + } + } + } + + RowLayout { + width: parent.width + Text { + id: positionText + text: "--:--:--" + color: colors.text + } + Slider { + Layout.fillWidth: true + id: progress + value: media.position + from: 0 + to: media.duration + + onMoved: media.seek(value) + //indeterminate: true + function updatePositionTexts() { + function formatTime(date) { + var hh = date.getUTCHours(); + var mm = date.getUTCMinutes(); + var ss = date.getSeconds(); + if (hh < 10) {hh = "0"+hh;} + if (mm < 10) {mm = "0"+mm;} + if (ss < 10) {ss = "0"+ss;} + return hh+":"+mm+":"+ss; + } + positionText.text = formatTime(new Date(media.position)) + durationText.text = formatTime(new Date(media.duration)) + } + onValueChanged: updatePositionTexts() + } + Text { + id: durationText + text: "--:--:--" + color: colors.text + } + } + + RowLayout { + width: parent.width + + spacing: 15 + + Rectangle { + id: button + color: colors.light + radius: 22 + height: 44 + width: 44 + Image { + id: img + anchors.centerIn: parent + + source: "qrc:/icons/icons/ui/arrow-pointing-down.png" + fillMode: Image.Pad + + } + MouseArea { + anchors.fill: parent + onClicked: { + switch (button.state) { + case "": timelineManager.timeline.cacheMedia(model.id); break; + case "stopped": + media.play(); console.log("play"); + button.state = "playing" + break + case "playing": + media.pause(); console.log("pause"); + button.state = "stopped" + break + } + } + cursorShape: Qt.PointingHandCursor + } + MediaPlayer { + id: media + onError: console.log(errorString) + onStatusChanged: if(status == MediaPlayer.Loaded) progress.updatePositionTexts() + onStopped: button.state = "stopped" + } + + Connections { + target: timelineManager.timeline + onMediaCached: { + if (mxcUrl == model.url) { + media.source = "file://" + cacheUrl + button.state = "stopped" + console.log("media loaded: " + mxcUrl + " at " + cacheUrl) + } + console.log("media cached: " + mxcUrl + " at " + cacheUrl) + } + } + + states: [ + State { + name: "stopped" + PropertyChanges { target: img; source: "qrc:/icons/icons/ui/play-sign.png" } + }, + State { + name: "playing" + PropertyChanges { target: img; source: "qrc:/icons/icons/ui/pause-symbol.png" } + } + ] + } + ColumnLayout { + id: col + + Text { + Layout.fillWidth: true + text: model.body + textFormat: Text.PlainText + elide: Text.ElideRight + color: colors.text + } + Text { + Layout.fillWidth: true + text: model.filesize + textFormat: Text.PlainText + elide: Text.ElideRight + color: colors.text + } + } + } + } +} + diff --git a/resources/qml/delegates/TextMessage.qml b/resources/qml/delegates/TextMessage.qml new file mode 100644 index 00000000..f984b32f --- /dev/null +++ b/resources/qml/delegates/TextMessage.qml @@ -0,0 +1,6 @@ +import ".." + +MatrixText { + text: model.formattedBody.replace("
", "
")
+	width: parent ? parent.width : undefined
+}
diff --git a/resources/res.qrc b/resources/res.qrc
index ad27af5a..53406c48 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -114,4 +114,21 @@
         styles/nheko.qss
         styles/nheko-dark.qss
     
+    
+        qml/TimelineView.qml
+        qml/Avatar.qml
+        qml/ImageButton.qml
+        qml/MatrixText.qml
+        qml/StatusIndicator.qml
+        qml/EncryptionIndicator.qml
+        qml/TimelineRow.qml
+        qml/delegates/MessageDelegate.qml
+        qml/delegates/TextMessage.qml
+        qml/delegates/NoticeMessage.qml
+        qml/delegates/ImageMessage.qml
+        qml/delegates/PlayableMediaMessage.qml
+        qml/delegates/FileMessage.qml
+        qml/delegates/Pill.qml
+        qml/delegates/Placeholder.qml
+    
 
diff --git a/src/AvatarProvider.cpp b/src/AvatarProvider.cpp
index ec745c04..68b6901e 100644
--- a/src/AvatarProvider.cpp
+++ b/src/AvatarProvider.cpp
@@ -43,7 +43,6 @@ resolve(const QString &avatarUrl, int size, QObject *receiver, AvatarCallback ca
 
         QPixmap pixmap;
         if (avatar_cache.find(cacheKey, &pixmap)) {
-                nhlog::net()->info("cached pixmap {}", avatarUrl.toStdString());
                 callback(pixmap);
                 return;
         }
@@ -52,7 +51,6 @@ resolve(const QString &avatarUrl, int size, QObject *receiver, AvatarCallback ca
         if (!data.isNull()) {
                 pixmap.loadFromData(data);
                 avatar_cache.insert(cacheKey, pixmap);
-                nhlog::net()->info("loaded pixmap from disk cache {}", avatarUrl.toStdString());
                 callback(pixmap);
                 return;
         }
@@ -69,8 +67,8 @@ resolve(const QString &avatarUrl, int size, QObject *receiver, AvatarCallback ca
                          });
 
         mtx::http::ThumbOpts opts;
-        opts.width   = 256;
-        opts.height  = 256;
+        opts.width   = size;
+        opts.height  = size;
         opts.mxc_url = avatarUrl.toStdString();
 
         http::client()->get_thumbnail(
@@ -86,8 +84,6 @@ resolve(const QString &avatarUrl, int size, QObject *receiver, AvatarCallback ca
 
                   cache::client()->saveImage(opts.mxc_url, res);
 
-                  nhlog::net()->info("downloaded pixmap {}", opts.mxc_url);
-
                   emit proxy->avatarDownloaded(QByteArray(res.data(), res.size()));
           });
 }
diff --git a/src/Cache.h b/src/Cache.h
index 0da49793..f5e1cfa0 100644
--- a/src/Cache.h
+++ b/src/Cache.h
@@ -91,7 +91,6 @@ from_json(const json &j, ReadReceiptKey &key)
 struct DescInfo
 {
         QString event_id;
-        QString username;
         QString userid;
         QString body;
         QString timestamp;
diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp
index 21ded4b3..d6f6940b 100644
--- a/src/ChatPage.cpp
+++ b/src/ChatPage.cpp
@@ -54,6 +54,8 @@ constexpr int CHECK_CONNECTIVITY_INTERVAL = 15'000;
 constexpr int RETRY_TIMEOUT               = 5'000;
 constexpr size_t MAX_ONETIME_KEYS         = 50;
 
+Q_DECLARE_METATYPE(boost::optional)
+
 ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
   : QWidget(parent)
   , isConnected_(true)
@@ -62,6 +64,9 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
 {
         setObjectName("chatPage");
 
+        qRegisterMetaType>(
+          "boost::optional");
+
         topLayout_ = new QHBoxLayout(this);
         topLayout_->setSpacing(0);
         topLayout_->setMargin(0);
@@ -113,12 +118,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
         view_manager_ = new TimelineViewManager(this);
 
         contentLayout_->addWidget(top_bar_);
-        contentLayout_->addWidget(view_manager_);
-
-        connect(this,
-                &ChatPage::removeTimelineEvent,
-                view_manager_,
-                &TimelineViewManager::removeTimelineEvent);
+        contentLayout_->addWidget(view_manager_->getWidget());
 
         // Splitter
         splitter->addWidget(sideBar_);
@@ -304,9 +304,9 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
 
         connect(
           text_input_,
-          &TextInputWidget::uploadImage,
+          &TextInputWidget::uploadMedia,
           this,
-          [this](QSharedPointer dev, const QString &fn) {
+          [this](QSharedPointer dev, QString mimeClass, const QString &fn) {
                   QMimeDatabase db;
                   QMimeType mime = db.mimeTypeForData(dev.data());
 
@@ -316,9 +316,18 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
                           return;
                   }
 
-                  auto bin        = dev->peek(dev->size());
-                  auto payload    = std::string(bin.data(), bin.size());
-                  auto dimensions = QImageReader(dev.data()).size();
+                  auto bin     = dev->peek(dev->size());
+                  auto payload = std::string(bin.data(), bin.size());
+                  boost::optional encryptedFile;
+                  if (cache::client()->isRoomEncrypted(current_room_.toStdString())) {
+                          mtx::crypto::BinaryBuf buf;
+                          std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload);
+                          payload                      = mtx::crypto::to_string(buf);
+                  }
+
+                  QSize dimensions;
+                  if (mimeClass == "image")
+                          dimensions = QImageReader(dev.data()).size();
 
                   http::client()->upload(
                     payload,
@@ -327,193 +336,61 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
                     [this,
                      room_id  = current_room_,
                      filename = fn,
-                     mime     = mime.name(),
-                     size     = payload.size(),
+                     encryptedFile,
+                     mimeClass,
+                     mime = mime.name(),
+                     size = payload.size(),
                      dimensions](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) {
                             if (err) {
                                     emit uploadFailed(
-                                      tr("Failed to upload image. Please try again."));
-                                    nhlog::net()->warn("failed to upload image: {} {} ({})",
+                                      tr("Failed to upload media. Please try again."));
+                                    nhlog::net()->warn("failed to upload media: {} {} ({})",
                                                        err->matrix_error.error,
                                                        to_string(err->matrix_error.errcode),
                                                        static_cast(err->status_code));
                                     return;
                             }
 
-                            emit imageUploaded(room_id,
+                            emit mediaUploaded(room_id,
                                                filename,
+                                               encryptedFile,
                                                QString::fromStdString(res.content_uri),
+                                               mimeClass,
                                                mime,
                                                size,
                                                dimensions);
                     });
           });
 
-        connect(text_input_,
-                &TextInputWidget::uploadFile,
-                this,
-                [this](QSharedPointer dev, const QString &fn) {
-                        QMimeDatabase db;
-                        QMimeType mime = db.mimeTypeForData(dev.data());
-
-                        if (!dev->open(QIODevice::ReadOnly)) {
-                                emit uploadFailed(
-                                  QString("Error while reading media: %1").arg(dev->errorString()));
-                                return;
-                        }
-
-                        auto bin     = dev->readAll();
-                        auto payload = std::string(bin.data(), bin.size());
-
-                        http::client()->upload(
-                          payload,
-                          mime.name().toStdString(),
-                          QFileInfo(fn).fileName().toStdString(),
-                          [this,
-                           room_id  = current_room_,
-                           filename = fn,
-                           mime     = mime.name(),
-                           size     = payload.size()](const mtx::responses::ContentURI &res,
-                                                  mtx::http::RequestErr err) {
-                                  if (err) {
-                                          emit uploadFailed(
-                                            tr("Failed to upload file. Please try again."));
-                                          nhlog::net()->warn("failed to upload file: {} ({})",
-                                                             err->matrix_error.error,
-                                                             static_cast(err->status_code));
-                                          return;
-                                  }
-
-                                  emit fileUploaded(room_id,
-                                                    filename,
-                                                    QString::fromStdString(res.content_uri),
-                                                    mime,
-                                                    size);
-                          });
-                });
-
-        connect(text_input_,
-                &TextInputWidget::uploadAudio,
-                this,
-                [this](QSharedPointer dev, const QString &fn) {
-                        QMimeDatabase db;
-                        QMimeType mime = db.mimeTypeForData(dev.data());
-
-                        if (!dev->open(QIODevice::ReadOnly)) {
-                                emit uploadFailed(
-                                  QString("Error while reading media: %1").arg(dev->errorString()));
-                                return;
-                        }
-
-                        auto bin     = dev->readAll();
-                        auto payload = std::string(bin.data(), bin.size());
-
-                        http::client()->upload(
-                          payload,
-                          mime.name().toStdString(),
-                          QFileInfo(fn).fileName().toStdString(),
-                          [this,
-                           room_id  = current_room_,
-                           filename = fn,
-                           mime     = mime.name(),
-                           size     = payload.size()](const mtx::responses::ContentURI &res,
-                                                  mtx::http::RequestErr err) {
-                                  if (err) {
-                                          emit uploadFailed(
-                                            tr("Failed to upload audio. Please try again."));
-                                          nhlog::net()->warn("failed to upload audio: {} ({})",
-                                                             err->matrix_error.error,
-                                                             static_cast(err->status_code));
-                                          return;
-                                  }
-
-                                  emit audioUploaded(room_id,
-                                                     filename,
-                                                     QString::fromStdString(res.content_uri),
-                                                     mime,
-                                                     size);
-                          });
-                });
-        connect(text_input_,
-                &TextInputWidget::uploadVideo,
-                this,
-                [this](QSharedPointer dev, const QString &fn) {
-                        QMimeDatabase db;
-                        QMimeType mime = db.mimeTypeForData(dev.data());
-
-                        if (!dev->open(QIODevice::ReadOnly)) {
-                                emit uploadFailed(
-                                  QString("Error while reading media: %1").arg(dev->errorString()));
-                                return;
-                        }
-
-                        auto bin     = dev->readAll();
-                        auto payload = std::string(bin.data(), bin.size());
-
-                        http::client()->upload(
-                          payload,
-                          mime.name().toStdString(),
-                          QFileInfo(fn).fileName().toStdString(),
-                          [this,
-                           room_id  = current_room_,
-                           filename = fn,
-                           mime     = mime.name(),
-                           size     = payload.size()](const mtx::responses::ContentURI &res,
-                                                  mtx::http::RequestErr err) {
-                                  if (err) {
-                                          emit uploadFailed(
-                                            tr("Failed to upload video. Please try again."));
-                                          nhlog::net()->warn("failed to upload video: {} ({})",
-                                                             err->matrix_error.error,
-                                                             static_cast(err->status_code));
-                                          return;
-                                  }
-
-                                  emit videoUploaded(room_id,
-                                                     filename,
-                                                     QString::fromStdString(res.content_uri),
-                                                     mime,
-                                                     size);
-                          });
-                });
-
         connect(this, &ChatPage::uploadFailed, this, [this](const QString &msg) {
                 text_input_->hideUploadSpinner();
                 emit showNotification(msg);
         });
         connect(this,
-                &ChatPage::imageUploaded,
+                &ChatPage::mediaUploaded,
                 this,
                 [this](QString roomid,
                        QString filename,
+                       boost::optional encryptedFile,
                        QString url,
+                       QString mimeClass,
                        QString mime,
                        qint64 dsize,
                        QSize dimensions) {
                         text_input_->hideUploadSpinner();
-                        view_manager_->queueImageMessage(
-                          roomid, filename, url, mime, dsize, dimensions);
-                });
-        connect(this,
-                &ChatPage::fileUploaded,
-                this,
-                [this](QString roomid, QString filename, QString url, QString mime, qint64 dsize) {
-                        text_input_->hideUploadSpinner();
-                        view_manager_->queueFileMessage(roomid, filename, url, mime, dsize);
-                });
-        connect(this,
-                &ChatPage::audioUploaded,
-                this,
-                [this](QString roomid, QString filename, QString url, QString mime, qint64 dsize) {
-                        text_input_->hideUploadSpinner();
-                        view_manager_->queueAudioMessage(roomid, filename, url, mime, dsize);
-                });
-        connect(this,
-                &ChatPage::videoUploaded,
-                this,
-                [this](QString roomid, QString filename, QString url, QString mime, qint64 dsize) {
-                        text_input_->hideUploadSpinner();
-                        view_manager_->queueVideoMessage(roomid, filename, url, mime, dsize);
+
+                        if (mimeClass == "image")
+                                view_manager_->queueImageMessage(
+                                  roomid, filename, encryptedFile, url, mime, dsize, dimensions);
+                        else if (mimeClass == "audio")
+                                view_manager_->queueAudioMessage(
+                                  roomid, filename, encryptedFile, url, mime, dsize);
+                        else if (mimeClass == "video")
+                                view_manager_->queueVideoMessage(
+                                  roomid, filename, encryptedFile, url, mime, dsize);
+                        else
+                                view_manager_->queueFileMessage(
+                                  roomid, filename, encryptedFile, url, mime, dsize);
                 });
 
         connect(room_list_, &RoomList::roomAvatarChanged, this, &ChatPage::updateTopBarAvatar);
@@ -566,7 +443,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
         connect(this,
                 &ChatPage::initializeViews,
                 view_manager_,
-                [this](const mtx::responses::Rooms &rooms) { view_manager_->initialize(rooms); });
+                [this](const mtx::responses::Rooms &rooms) { view_manager_->sync(rooms); });
         connect(this,
                 &ChatPage::initializeEmptyViews,
                 view_manager_,
@@ -582,7 +459,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
                         nhlog::db()->error("failed to retrieve invites: {}", e.what());
                 }
 
-                view_manager_->initialize(rooms);
+                view_manager_->sync(rooms);
                 removeLeftRooms(rooms.leave);
 
                 bool hasNotifications = false;
diff --git a/src/ChatPage.h b/src/ChatPage.h
index e41ae1ae..20e156af 100644
--- a/src/ChatPage.h
+++ b/src/ChatPage.h
@@ -18,7 +18,9 @@
 #pragma once
 
 #include 
+#include 
 #include 
+#include 
 #include 
 
 #include 
@@ -94,27 +96,14 @@ signals:
                                         const QPoint widgetPos);
 
         void uploadFailed(const QString &msg);
-        void imageUploaded(const QString &roomid,
+        void mediaUploaded(const QString &roomid,
                            const QString &filename,
+                           const boost::optional &file,
                            const QString &url,
+                           const QString &mimeClass,
                            const QString &mime,
                            qint64 dsize,
                            const QSize &dimensions);
-        void fileUploaded(const QString &roomid,
-                          const QString &filename,
-                          const QString &url,
-                          const QString &mime,
-                          qint64 dsize);
-        void audioUploaded(const QString &roomid,
-                           const QString &filename,
-                           const QString &url,
-                           const QString &mime,
-                           qint64 dsize);
-        void videoUploaded(const QString &roomid,
-                           const QString &filename,
-                           const QString &url,
-                           const QString &mime,
-                           qint64 dsize);
 
         void contentLoaded();
         void closing();
@@ -125,8 +114,6 @@ signals:
         void showUserSettingsPage();
         void showOverlayProgressBar();
 
-        void removeTimelineEvent(const QString &room_id, const QString &event_id);
-
         void ownProfileOk();
         void setUserDisplayName(const QString &name);
         void setUserAvatar(const QString &avatar);
diff --git a/src/ColorImageProvider.cpp b/src/ColorImageProvider.cpp
new file mode 100644
index 00000000..92e4732b
--- /dev/null
+++ b/src/ColorImageProvider.cpp
@@ -0,0 +1,30 @@
+#include "ColorImageProvider.h"
+
+#include "Logging.h"
+#include 
+
+QPixmap
+ColorImageProvider::requestPixmap(const QString &id, QSize *size, const QSize &)
+{
+        auto args = id.split('?');
+
+        nhlog::ui()->info("Loading {}, source is {}", id.toStdString(), args[0].toStdString());
+
+        QPixmap source(args[0]);
+
+        if (size)
+                *size = QSize(source.width(), source.height());
+
+        if (args.size() < 2)
+                return source;
+
+        QColor color(args[1]);
+
+        QPixmap colorized = source;
+        QPainter painter(&colorized);
+        painter.setCompositionMode(QPainter::CompositionMode_SourceIn);
+        painter.fillRect(colorized.rect(), color);
+        painter.end();
+
+        return colorized;
+}
diff --git a/src/ColorImageProvider.h b/src/ColorImageProvider.h
new file mode 100644
index 00000000..21f36c12
--- /dev/null
+++ b/src/ColorImageProvider.h
@@ -0,0 +1,11 @@
+#include 
+
+class ColorImageProvider : public QQuickImageProvider
+{
+public:
+        ColorImageProvider()
+          : QQuickImageProvider(QQuickImageProvider::Pixmap)
+        {}
+
+        QPixmap requestPixmap(const QString &id, QSize *size, const QSize &requestedSize) override;
+};
diff --git a/src/Logging.cpp b/src/Logging.cpp
index 32287582..126b3781 100644
--- a/src/Logging.cpp
+++ b/src/Logging.cpp
@@ -5,14 +5,43 @@
 #include "spdlog/sinks/stdout_color_sinks.h"
 #include 
 
+#include 
+#include 
+
 namespace {
 std::shared_ptr db_logger     = nullptr;
 std::shared_ptr net_logger    = nullptr;
 std::shared_ptr crypto_logger = nullptr;
 std::shared_ptr ui_logger     = nullptr;
+std::shared_ptr qml_logger    = nullptr;
 
 constexpr auto MAX_FILE_SIZE = 1024 * 1024 * 6;
 constexpr auto MAX_LOG_FILES = 3;
+
+void
+qmlMessageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
+{
+        std::string localMsg = msg.toStdString();
+        const char *file     = context.file ? context.file : "";
+        const char *function = context.function ? context.function : "";
+        switch (type) {
+        case QtDebugMsg:
+                nhlog::qml()->debug("{} ({}:{}, {})", localMsg, file, context.line, function);
+                break;
+        case QtInfoMsg:
+                nhlog::qml()->info("{} ({}:{}, {})", localMsg, file, context.line, function);
+                break;
+        case QtWarningMsg:
+                nhlog::qml()->warn("{} ({}:{}, {})", localMsg, file, context.line, function);
+                break;
+        case QtCriticalMsg:
+                nhlog::qml()->critical("{} ({}:{}, {})", localMsg, file, context.line, function);
+                break;
+        case QtFatalMsg:
+                nhlog::qml()->critical("{} ({}:{}, {})", localMsg, file, context.line, function);
+                break;
+        }
+}
 }
 
 namespace nhlog {
@@ -35,12 +64,15 @@ init(const std::string &file_path)
         db_logger  = std::make_shared("db", std::begin(sinks), std::end(sinks));
         crypto_logger =
           std::make_shared("crypto", std::begin(sinks), std::end(sinks));
+        qml_logger = std::make_shared("qml", std::begin(sinks), std::end(sinks));
 
         if (nheko::enable_debug_log) {
                 db_logger->set_level(spdlog::level::trace);
                 ui_logger->set_level(spdlog::level::trace);
                 crypto_logger->set_level(spdlog::level::trace);
         }
+
+        qInstallMessageHandler(qmlMessageHandler);
 }
 
 std::shared_ptr
@@ -66,4 +98,10 @@ crypto()
 {
         return crypto_logger;
 }
+
+std::shared_ptr
+qml()
+{
+        return qml_logger;
+}
 }
diff --git a/src/Logging.h b/src/Logging.h
index e54f3c3f..f572afae 100644
--- a/src/Logging.h
+++ b/src/Logging.h
@@ -19,5 +19,8 @@ db();
 std::shared_ptr
 crypto();
 
+std::shared_ptr
+qml();
+
 extern bool enable_debug_log_from_commandline;
 }
diff --git a/src/MatrixClient.h b/src/MatrixClient.h
index 2af57267..c77b1183 100644
--- a/src/MatrixClient.h
+++ b/src/MatrixClient.h
@@ -20,16 +20,6 @@ Q_DECLARE_METATYPE(nlohmann::json)
 Q_DECLARE_METATYPE(std::vector)
 Q_DECLARE_METATYPE(std::vector)
 
-class MediaProxy : public QObject
-{
-        Q_OBJECT
-
-signals:
-        void imageDownloaded(const QPixmap &);
-        void imageSaved(const QString &, const QByteArray &);
-        void fileDownloaded(const QByteArray &);
-};
-
 namespace http {
 mtx::http::Client *
 client();
diff --git a/src/MxcImageProvider.cpp b/src/MxcImageProvider.cpp
new file mode 100644
index 00000000..edf6ceb5
--- /dev/null
+++ b/src/MxcImageProvider.cpp
@@ -0,0 +1,83 @@
+#include "MxcImageProvider.h"
+
+#include "Cache.h"
+
+void
+MxcImageResponse::run()
+{
+        if (m_requestedSize.isValid() && !m_encryptionInfo) {
+                QString fileName = QString("%1_%2x%3_crop")
+                                     .arg(m_id)
+                                     .arg(m_requestedSize.width())
+                                     .arg(m_requestedSize.height());
+
+                auto data = cache::client()->image(fileName);
+                if (!data.isNull() && m_image.loadFromData(data)) {
+                        m_image = m_image.scaled(m_requestedSize, Qt::KeepAspectRatio);
+                        m_image.setText("mxc url", "mxc://" + m_id);
+                        emit finished();
+                        return;
+                }
+
+                mtx::http::ThumbOpts opts;
+                opts.mxc_url = "mxc://" + m_id.toStdString();
+                opts.width   = m_requestedSize.width() > 0 ? m_requestedSize.width() : -1;
+                opts.height  = m_requestedSize.height() > 0 ? m_requestedSize.height() : -1;
+                opts.method  = "crop";
+                http::client()->get_thumbnail(
+                  opts, [this, fileName](const std::string &res, mtx::http::RequestErr err) {
+                          if (err) {
+                                  nhlog::net()->error("Failed to download image {}",
+                                                      m_id.toStdString());
+                                  m_error = "Failed download";
+                                  emit finished();
+
+                                  return;
+                          }
+
+                          auto data = QByteArray(res.data(), res.size());
+                          cache::client()->saveImage(fileName, data);
+                          m_image.loadFromData(data);
+                          m_image.setText("mxc url", "mxc://" + m_id);
+
+                          emit finished();
+                  });
+        } else {
+                auto data = cache::client()->image(m_id);
+                if (!data.isNull() && m_image.loadFromData(data)) {
+                        m_image.setText("mxc url", "mxc://" + m_id);
+                        emit finished();
+                        return;
+                }
+
+                http::client()->download(
+                  "mxc://" + m_id.toStdString(),
+                  [this](const std::string &res,
+                         const std::string &,
+                         const std::string &originalFilename,
+                         mtx::http::RequestErr err) {
+                          if (err) {
+                                  nhlog::net()->error("Failed to download image {}",
+                                                      m_id.toStdString());
+                                  m_error = "Failed download";
+                                  emit finished();
+
+                                  return;
+                          }
+
+                          auto temp = res;
+                          if (m_encryptionInfo)
+                                  temp = mtx::crypto::to_string(
+                                    mtx::crypto::decrypt_file(temp, m_encryptionInfo.value()));
+
+                          auto data = QByteArray(temp.data(), temp.size());
+                          m_image.loadFromData(data);
+                          m_image.setText("original filename",
+                                          QString::fromStdString(originalFilename));
+                          m_image.setText("mxc url", "mxc://" + m_id);
+                          cache::client()->saveImage(m_id, data);
+
+                          emit finished();
+                  });
+        }
+}
diff --git a/src/MxcImageProvider.h b/src/MxcImageProvider.h
new file mode 100644
index 00000000..2c197a13
--- /dev/null
+++ b/src/MxcImageProvider.h
@@ -0,0 +1,69 @@
+#pragma once
+
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+
+#include 
+
+class MxcImageResponse
+  : public QQuickImageResponse
+  , public QRunnable
+{
+public:
+        MxcImageResponse(const QString &id,
+                         const QSize &requestedSize,
+                         boost::optional encryptionInfo)
+          : m_id(id)
+          , m_requestedSize(requestedSize)
+          , m_encryptionInfo(encryptionInfo)
+        {
+                setAutoDelete(false);
+        }
+
+        QQuickTextureFactory *textureFactory() const override
+        {
+                return QQuickTextureFactory::textureFactoryForImage(m_image);
+        }
+        QString errorString() const override { return m_error; }
+
+        void run() override;
+
+        QString m_id, m_error;
+        QSize m_requestedSize;
+        QImage m_image;
+        boost::optional m_encryptionInfo;
+};
+
+class MxcImageProvider
+  : public QObject
+  , public QQuickAsyncImageProvider
+{
+        Q_OBJECT
+public slots:
+        QQuickImageResponse *requestImageResponse(const QString &id,
+                                                  const QSize &requestedSize) override
+        {
+                boost::optional info;
+                auto temp = infos.find("mxc://" + id);
+                if (temp != infos.end())
+                        info = *temp;
+
+                MxcImageResponse *response = new MxcImageResponse(id, requestedSize, info);
+                pool.start(response);
+                return response;
+        }
+
+        void addEncryptionInfo(mtx::crypto::EncryptedFile info)
+        {
+                infos.insert(QString::fromStdString(info.url), info);
+        }
+
+private:
+        QThreadPool pool;
+        QHash infos;
+};
diff --git a/src/RoomInfoListItem.cpp b/src/RoomInfoListItem.cpp
index 8aadbea2..8bebb0f5 100644
--- a/src/RoomInfoListItem.cpp
+++ b/src/RoomInfoListItem.cpp
@@ -118,7 +118,7 @@ RoomInfoListItem::RoomInfoListItem(QString room_id, RoomInfo info, QWidget *pare
         // so we can't use them for sorting.
         if (roomType_ == RoomType::Invited)
                 lastMsgInfo_ = {
-                  emptyEventId, "-", "-", "-", "-", QDateTime::currentDateTime().addYears(10)};
+                  emptyEventId, "-", "-", "-", QDateTime::currentDateTime().addYears(10)};
 }
 
 void
@@ -142,7 +142,7 @@ RoomInfoListItem::resizeEvent(QResizeEvent *)
 void
 RoomInfoListItem::paintEvent(QPaintEvent *event)
 {
-        bool rounded = QSettings().value("user/avatar/circles", true).toBool();
+        bool rounded = QSettings().value("user/avatar_circles", true).toBool();
 
         Q_UNUSED(event);
 
@@ -210,33 +210,11 @@ RoomInfoListItem::paintEvent(QPaintEvent *event)
                         p.setFont(QFont{});
                         p.setPen(subtitlePen);
 
-                        // The limit is the space between the end of the avatar and the start of the
-                        // timestamp.
-                        int usernameLimit =
-                          std::max(0, width() - 3 * wm.padding - msgStampWidth - wm.iconSize - 20);
-                        auto userName =
-                          metrics.elidedText(lastMsgInfo_.username, Qt::ElideRight, usernameLimit);
-
-                        p.setFont(QFont{});
-                        p.drawText(QPoint(2 * wm.padding + wm.iconSize, bottom_y), userName);
-
-#if QT_VERSION < QT_VERSION_CHECK(5, 11, 0)
-                        int nameWidth = QFontMetrics(QFont{}).width(userName);
-#else
-                        int nameWidth = QFontMetrics(QFont{}).horizontalAdvance(userName);
-#endif
-                        p.setFont(QFont{});
-
-                        // The limit is the space between the end of the username and the start of
-                        // the timestamp.
-                        int descriptionLimit =
-                          std::max(0,
-                                   width() - 3 * wm.padding - bottomLineWidthLimit - wm.iconSize -
-                                     nameWidth - 5);
+                        int descriptionLimit = std::max(
+                          0, width() - 3 * wm.padding - bottomLineWidthLimit - wm.iconSize);
                         auto description =
                           metrics.elidedText(lastMsgInfo_.body, Qt::ElideRight, descriptionLimit);
-                        p.drawText(QPoint(2 * wm.padding + wm.iconSize + nameWidth, bottom_y),
-                                   description);
+                        p.drawText(QPoint(2 * wm.padding + wm.iconSize, bottom_y), description);
 
                         // We show the last message timestamp.
                         p.save();
diff --git a/src/TextInputWidget.cpp b/src/TextInputWidget.cpp
index f723c01a..66700dbc 100644
--- a/src/TextInputWidget.cpp
+++ b/src/TextInputWidget.cpp
@@ -458,21 +458,16 @@ FilteredTextEdit::textChanged()
 }
 
 void
-FilteredTextEdit::uploadData(const QByteArray data, const QString &media, const QString &filename)
+FilteredTextEdit::uploadData(const QByteArray data,
+                             const QString &mediaType,
+                             const QString &filename)
 {
         QSharedPointer buffer{new QBuffer{this}};
         buffer->setData(data);
 
         emit startedUpload();
 
-        if (media == "image")
-                emit image(buffer, filename);
-        else if (media == "audio")
-                emit audio(buffer, filename);
-        else if (media == "video")
-                emit video(buffer, filename);
-        else
-                emit file(buffer, filename);
+        emit media(buffer, mediaType, filename);
 }
 
 void
@@ -580,10 +575,7 @@ TextInputWidget::TextInputWidget(QWidget *parent)
         connect(input_, &FilteredTextEdit::message, this, &TextInputWidget::sendTextMessage);
         connect(input_, &FilteredTextEdit::reply, this, &TextInputWidget::sendReplyMessage);
         connect(input_, &FilteredTextEdit::command, this, &TextInputWidget::command);
-        connect(input_, &FilteredTextEdit::image, this, &TextInputWidget::uploadImage);
-        connect(input_, &FilteredTextEdit::audio, this, &TextInputWidget::uploadAudio);
-        connect(input_, &FilteredTextEdit::video, this, &TextInputWidget::uploadVideo);
-        connect(input_, &FilteredTextEdit::file, this, &TextInputWidget::uploadFile);
+        connect(input_, &FilteredTextEdit::media, this, &TextInputWidget::uploadMedia);
         connect(emojiBtn_,
                 SIGNAL(emojiSelected(const QString &)),
                 this,
@@ -642,14 +634,8 @@ TextInputWidget::openFileSelection()
         const auto format = mime.name().split("/")[0];
 
         QSharedPointer file{new QFile{fileName, this}};
-        if (format == "image")
-                emit uploadImage(file, fileName);
-        else if (format == "audio")
-                emit uploadAudio(file, fileName);
-        else if (format == "video")
-                emit uploadVideo(file, fileName);
-        else
-                emit uploadFile(file, fileName);
+
+        emit uploadMedia(file, format, fileName);
 
         showUploadSpinner();
 }
diff --git a/src/TextInputWidget.h b/src/TextInputWidget.h
index 71f794d1..d498be72 100644
--- a/src/TextInputWidget.h
+++ b/src/TextInputWidget.h
@@ -63,10 +63,7 @@ signals:
         void message(QString);
         void reply(QString, const RelatedInfo &);
         void command(QString name, QString args);
-        void image(QSharedPointer data, const QString &filename);
-        void audio(QSharedPointer data, const QString &filename);
-        void video(QSharedPointer data, const QString &filename);
-        void file(QSharedPointer data, const QString &filename);
+        void media(QSharedPointer data, QString mimeClass, const QString &filename);
 
         //! Trigger the suggestion popup.
         void showSuggestions(const QString &query);
@@ -179,10 +176,9 @@ signals:
         void sendEmoteMessage(QString msg);
         void heightChanged(int height);
 
-        void uploadImage(const QSharedPointer data, const QString &filename);
-        void uploadFile(const QSharedPointer data, const QString &filename);
-        void uploadAudio(const QSharedPointer data, const QString &filename);
-        void uploadVideo(const QSharedPointer data, const QString &filename);
+        void uploadMedia(const QSharedPointer data,
+                         QString mimeClass,
+                         const QString &filename);
 
         void sendJoinRoomRequest(const QString &room);
 
diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp
index 9fd033e9..1caea449 100644
--- a/src/UserSettingsPage.cpp
+++ b/src/UserSettingsPage.cpp
@@ -53,7 +53,7 @@ UserSettings::load()
         isReadReceiptsEnabled_        = settings.value("user/read_receipts", true).toBool();
         theme_                        = settings.value("user/theme", defaultTheme_).toString();
         font_                         = settings.value("user/font_family", "default").toString();
-        avatarCircles_                = settings.value("user/avatar/circles", true).toBool();
+        avatarCircles_                = settings.value("user/avatar_circles", true).toBool();
         emojiFont_    = settings.value("user/emoji_font_family", "default").toString();
         baseFontSize_ = settings.value("user/font_size", QFont().pointSizeF()).toDouble();
 
@@ -119,9 +119,7 @@ UserSettings::save()
         settings.setValue("start_in_tray", isStartInTrayEnabled_);
         settings.endGroup();
 
-        settings.beginGroup("avatar");
-        settings.setValue("circles", avatarCircles_);
-        settings.endGroup();
+        settings.setValue("avatar_circles", avatarCircles_);
 
         settings.setValue("font_size", baseFontSize_);
         settings.setValue("typing_notifications", isTypingNotificationsEnabled_);
diff --git a/src/Utils.cpp b/src/Utils.cpp
index c60adb58..3e59d912 100644
--- a/src/Utils.cpp
+++ b/src/Utils.cpp
@@ -40,9 +40,8 @@ utils::replaceEmoji(const QString &body)
         for (auto &code : utf32_string) {
                 // TODO: Be more precise here.
                 if (code > 9000)
-                        fmtBody +=
-                          QString("") +
-                          QString::fromUcs4(&code, 1) + "";
+                        fmtBody += QString("") +
+                                   QString::fromUcs4(&code, 1) + "";
                 else
                         fmtBody += QString::fromUcs4(&code, 1);
         }
@@ -147,11 +146,6 @@ utils::getMessageDescription(const TimelineEvent &event,
                 const auto ts       = QDateTime::fromMSecsSinceEpoch(msg.origin_server_ts);
 
                 DescInfo info;
-                if (sender == localUser)
-                        info.username = QCoreApplication::translate("utils", "You");
-                else
-                        info.username = username;
-
                 info.userid    = sender;
                 info.body      = QString(" %1").arg(messageDescription());
                 info.timestamp = utils::descriptiveTime(ts);
@@ -324,19 +318,29 @@ utils::linkifyMessage(const QString &body)
         return doc;
 }
 
-QByteArray escapeRawHtml(const QByteArray &data) {
-      QByteArray buffer;
-      const size_t length = data.size();
-      buffer.reserve(length);
-      for(size_t pos = 0; pos != length; ++pos) {
-            switch(data.at(pos)) {
-                  case '&':  buffer.append("&");      break;
-                  case '<':  buffer.append("<");       break;
-                  case '>':  buffer.append(">");       break;
-                  default:   buffer.append(data.at(pos)); break;
-            }
-      }
-     return buffer;
+QByteArray
+escapeRawHtml(const QByteArray &data)
+{
+        QByteArray buffer;
+        const size_t length = data.size();
+        buffer.reserve(length);
+        for (size_t pos = 0; pos != length; ++pos) {
+                switch (data.at(pos)) {
+                case '&':
+                        buffer.append("&");
+                        break;
+                case '<':
+                        buffer.append("<");
+                        break;
+                case '>':
+                        buffer.append(">");
+                        break;
+                default:
+                        buffer.append(data.at(pos));
+                        break;
+                }
+        }
+        return buffer;
 }
 
 QString
@@ -362,7 +366,7 @@ utils::getFormattedQuoteBody(const RelatedInfo &related, const QString &html)
 {
         return QString("
In reply " - "to* %4
%4%5
") .arg(related.room, QString::fromStdString(related.related_event), @@ -378,9 +382,6 @@ utils::getQuoteBody(const RelatedInfo &related) using MsgType = mtx::events::MessageType; switch (related.type) { - case MsgType::Text: { - return markdownToHtml(related.quoted_body); - } case MsgType::File: { return QString(QCoreApplication::translate("utils", "sent a file.")); } diff --git a/src/Utils.h b/src/Utils.h index 225754be..bdb51844 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -4,10 +4,6 @@ #include "Cache.h" #include "RoomInfoListItem.h" -#include "timeline/widgets/AudioItem.h" -#include "timeline/widgets/FileItem.h" -#include "timeline/widgets/ImageItem.h" -#include "timeline/widgets/VideoItem.h" #include #include @@ -94,38 +90,72 @@ messageDescription(const QString &username = "", using Video = mtx::events::RoomEvent; using Encrypted = mtx::events::EncryptedEvent; - // Sometimes the verb form of sent changes in some languages depending on the actor. - auto remoteSent = QCoreApplication::translate( - "message-description: ", "sent", "For when you are the sender"); - auto localSent = QCoreApplication::translate( - "message-description:", "sent", "For when someone else is the sender"); - QString sentVerb = isLocal ? localSent : remoteSent; - if (std::is_same::value || std::is_same::value) { - return QCoreApplication::translate("message-description sent:", "%1 an audio clip") - .arg(sentVerb); - } else if (std::is_same::value || std::is_same::value) { - return QCoreApplication::translate("message-description sent:", "%1 an image") - .arg(sentVerb); - } else if (std::is_same::value || std::is_same::value) { - return QCoreApplication::translate("message-description sent:", "%1 a file") - .arg(sentVerb); - } else if (std::is_same::value || std::is_same::value) { - return QCoreApplication::translate("message-description sent:", "%1 a video clip") - .arg(sentVerb); - } else if (std::is_same::value || std::is_same::value) { - return QCoreApplication::translate("message-description sent:", "%1 a sticker") - .arg(sentVerb); + if (std::is_same::value) { + if (isLocal) + return QCoreApplication::translate("message-description sent:", + "You sent an audio clip"); + else + return QCoreApplication::translate("message-description sent:", + "%1 sent an audio clip") + .arg(username); + } else if (std::is_same::value) { + if (isLocal) + return QCoreApplication::translate("message-description sent:", + "You sent an image"); + else + return QCoreApplication::translate("message-description sent:", + "%1 sent an image") + .arg(username); + } else if (std::is_same::value) { + if (isLocal) + return QCoreApplication::translate("message-description sent:", + "You sent a file"); + else + return QCoreApplication::translate("message-description sent:", + "%1 sent a file") + .arg(username); + } else if (std::is_same::value) { + if (isLocal) + return QCoreApplication::translate("message-description sent:", + "You sent a video"); + else + return QCoreApplication::translate("message-description sent:", + "%1 sent a video") + .arg(username); + } else if (std::is_same::value) { + if (isLocal) + return QCoreApplication::translate("message-description sent:", + "You sent a sticker"); + else + return QCoreApplication::translate("message-description sent:", + "%1 sent a sticker") + .arg(username); } else if (std::is_same::value) { - return QCoreApplication::translate("message-description sent:", "%1 a notification") - .arg(sentVerb); + if (isLocal) + return QCoreApplication::translate("message-description sent:", + "You sent a notification"); + else + return QCoreApplication::translate("message-description sent:", + "%1 sent a notification") + .arg(username); } else if (std::is_same::value) { - return QString(": %1").arg(body); + if (isLocal) + return QCoreApplication::translate("message-description sent:", "You: %1") + .arg(body); + else + return QCoreApplication::translate("message-description sent:", "%1: %2") + .arg(username) + .arg(body); } else if (std::is_same::value) { return QString("* %1 %2").arg(username).arg(body); } else if (std::is_same::value) { - return QCoreApplication::translate("message-description sent:", - "%1 an encrypted message") - .arg(sentVerb); + if (isLocal) + return QCoreApplication::translate("message-description sent:", + "You sent an encrypted message"); + else + return QCoreApplication::translate("message-description sent:", + "%1 sent an encrypted message") + .arg(username); } else { return QCoreApplication::translate("utils", "Unknown Message Type"); } @@ -135,29 +165,19 @@ template DescInfo createDescriptionInfo(const Event &event, const QString &localUser, const QString &room_id) { - using Text = mtx::events::RoomEvent; - using Emote = mtx::events::RoomEvent; - const auto msg = boost::get(event); const auto sender = QString::fromStdString(msg.sender); const auto username = Cache::displayName(room_id, sender); const auto ts = QDateTime::fromMSecsSinceEpoch(msg.origin_server_ts); - bool isText = std::is_same::value; - bool isEmote = std::is_same::value; - - return DescInfo{ - QString::fromStdString(msg.event_id), - isEmote ? "" - : (sender == localUser ? QCoreApplication::translate("utils", "You") : username), - sender, - (isText || isEmote) - ? messageDescription( - username, QString::fromStdString(msg.content.body).trimmed(), sender == localUser) - : QString(" %1").arg(messageDescription()), - utils::descriptiveTime(ts), - ts}; + return DescInfo{QString::fromStdString(msg.event_id), + sender, + messageDescription(username, + QString::fromStdString(msg.content.body).trimmed(), + sender == localUser), + utils::descriptiveTime(ts), + ts}; } //! Scale down an image to fit to the given width & height limitations. diff --git a/src/dialogs/ImageOverlay.cpp b/src/dialogs/ImageOverlay.cpp index dd9cd03a..cbdd351c 100644 --- a/src/dialogs/ImageOverlay.cpp +++ b/src/dialogs/ImageOverlay.cpp @@ -41,7 +41,6 @@ ImageOverlay::ImageOverlay(QPixmap image, QWidget *parent) setAttribute(Qt::WA_DeleteOnClose, true); setWindowState(Qt::WindowFullScreen); - // Deprecated in 5.13: screen_ = QApplication::desktop()->availableGeometry(); screen_ = QGuiApplication::primaryScreen()->availableGeometry(); move(QApplication::desktop()->mapToGlobal(screen_.topLeft())); diff --git a/src/dialogs/MemberList.cpp b/src/dialogs/MemberList.cpp index 9e973efa..f62cf9fe 100644 --- a/src/dialogs/MemberList.cpp +++ b/src/dialogs/MemberList.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include diff --git a/src/dialogs/RoomSettings.cpp b/src/dialogs/RoomSettings.cpp index 00b034cc..25909cd8 100644 --- a/src/dialogs/RoomSettings.cpp +++ b/src/dialogs/RoomSettings.cpp @@ -488,7 +488,7 @@ RoomSettings::retrieveRoomInfo() usesEncryption_ = cache::client()->isRoomEncrypted(room_id_.toStdString()); info_ = cache::client()->singleRoomInfo(room_id_.toStdString()); setAvatar(); - } catch (const lmdb::error &e) { + } catch (const lmdb::error &) { nhlog::db()->warn("failed to retrieve room info from cache: {}", room_id_.toStdString()); } diff --git a/src/popups/UserMentions.cpp b/src/popups/UserMentions.cpp index 3480959a..3be5c462 100644 --- a/src/popups/UserMentions.cpp +++ b/src/popups/UserMentions.cpp @@ -7,7 +7,7 @@ #include "ChatPage.h" #include "Logging.h" #include "UserMentions.h" -#include "timeline/TimelineItem.h" +//#include "timeline/TimelineItem.h" using namespace popups; @@ -116,39 +116,46 @@ UserMentions::pushItem(const QString &event_id, const QString &room_id, const QString ¤t_room_id) { - setUpdatesEnabled(false); - - // Add to the 'all' section - TimelineItem *view_item = new TimelineItem( - mtx::events::MessageType::Text, user_id, body, true, room_id, all_scroll_widget_); - view_item->setEventId(event_id); - view_item->hide(); - - all_scroll_layout_->addWidget(view_item); - QTimer::singleShot(0, this, [view_item, this]() { - view_item->show(); - view_item->adjustSize(); - setUpdatesEnabled(true); - }); - - // if it matches the current room... add it to the current room as well. - if (QString::compare(room_id, current_room_id, Qt::CaseInsensitive) == 0) { - // Add to the 'local' section - TimelineItem *local_view_item = new TimelineItem(mtx::events::MessageType::Text, - user_id, - body, - true, - room_id, - local_scroll_widget_); - local_view_item->setEventId(event_id); - local_view_item->hide(); - local_scroll_layout_->addWidget(local_view_item); - - QTimer::singleShot(0, this, [local_view_item]() { - local_view_item->show(); - local_view_item->adjustSize(); - }); - } + (void)event_id; + (void)user_id; + (void)body; + (void)room_id; + (void)current_room_id; + // setUpdatesEnabled(false); + // + // // Add to the 'all' section + // TimelineItem *view_item = new TimelineItem( + // mtx::events::MessageType::Text, user_id, body, true, room_id, + // all_scroll_widget_); + // view_item->setEventId(event_id); + // view_item->hide(); + // + // all_scroll_layout_->addWidget(view_item); + // QTimer::singleShot(0, this, [view_item, this]() { + // view_item->show(); + // view_item->adjustSize(); + // setUpdatesEnabled(true); + // }); + // + // // if it matches the current room... add it to the current room as well. + // if (QString::compare(room_id, current_room_id, Qt::CaseInsensitive) == 0) { + // // Add to the 'local' section + // TimelineItem *local_view_item = new + // TimelineItem(mtx::events::MessageType::Text, + // user_id, + // body, + // true, + // room_id, + // local_scroll_widget_); + // local_view_item->setEventId(event_id); + // local_view_item->hide(); + // local_scroll_layout_->addWidget(local_view_item); + // + // QTimer::singleShot(0, this, [local_view_item]() { + // local_view_item->show(); + // local_view_item->adjustSize(); + // }); + // } } void @@ -158,4 +165,4 @@ UserMentions::paintEvent(QPaintEvent *) opt.init(this); QPainter p(this); style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); -} \ No newline at end of file +} diff --git a/src/timeline/.TimelineItem.cpp.swp b/src/timeline/.TimelineItem.cpp.swp deleted file mode 100644 index 75e03aeb..00000000 Binary files a/src/timeline/.TimelineItem.cpp.swp and /dev/null differ diff --git a/src/timeline/DelegateChooser.cpp b/src/timeline/DelegateChooser.cpp new file mode 100644 index 00000000..632a2a64 --- /dev/null +++ b/src/timeline/DelegateChooser.cpp @@ -0,0 +1,138 @@ +#include "DelegateChooser.h" + +#include "Logging.h" + +// uses private API, which moved between versions +#include +#include + +QQmlComponent * +DelegateChoice::delegate() const +{ + return delegate_; +} + +void +DelegateChoice::setDelegate(QQmlComponent *delegate) +{ + if (delegate != delegate_) { + delegate_ = delegate; + emit delegateChanged(); + emit changed(); + } +} + +QVariant +DelegateChoice::roleValue() const +{ + return roleValue_; +} + +void +DelegateChoice::setRoleValue(const QVariant &value) +{ + if (value != roleValue_) { + roleValue_ = value; + emit roleValueChanged(); + emit changed(); + } +} + +QVariant +DelegateChooser::roleValue() const +{ + return roleValue_; +} + +void +DelegateChooser::setRoleValue(const QVariant &value) +{ + if (value != roleValue_) { + roleValue_ = value; + recalcChild(); + emit roleValueChanged(); + } +} + +QQmlListProperty +DelegateChooser::choices() +{ + return QQmlListProperty(this, + this, + &DelegateChooser::appendChoice, + &DelegateChooser::choiceCount, + &DelegateChooser::choice, + &DelegateChooser::clearChoices); +} + +void +DelegateChooser::appendChoice(QQmlListProperty *p, DelegateChoice *c) +{ + DelegateChooser *dc = static_cast(p->object); + dc->choices_.append(c); +} + +int +DelegateChooser::choiceCount(QQmlListProperty *p) +{ + return static_cast(p->object)->choices_.count(); +} +DelegateChoice * +DelegateChooser::choice(QQmlListProperty *p, int index) +{ + return static_cast(p->object)->choices_.at(index); +} +void +DelegateChooser::clearChoices(QQmlListProperty *p) +{ + static_cast(p->object)->choices_.clear(); +} + +void +DelegateChooser::recalcChild() +{ + for (const auto choice : choices_) { + auto choiceValue = choice->roleValue(); + if (!roleValue_.isValid() || !choiceValue.isValid() || choiceValue == roleValue_) { + if (child) { + child->setParentItem(nullptr); + child = nullptr; + } + + choice->delegate()->create(incubator, QQmlEngine::contextForObject(this)); + return; + } + } +} + +void +DelegateChooser::componentComplete() +{ + QQuickItem::componentComplete(); + recalcChild(); +} + +void +DelegateChooser::DelegateIncubator::statusChanged(QQmlIncubator::Status status) +{ + if (status == QQmlIncubator::Ready) { + chooser.child = dynamic_cast(object()); + if (chooser.child == nullptr) { + nhlog::ui()->error("Delegate has to be derived of Item!"); + return; + } + + chooser.child->setParentItem(&chooser); + connect(chooser.child, &QQuickItem::heightChanged, &chooser, [this]() { + chooser.setHeight(chooser.child->height()); + }); + chooser.setHeight(chooser.child->height()); + QQmlEngine::setObjectOwnership(chooser.child, + QQmlEngine::ObjectOwnership::JavaScriptOwnership); + + } else if (status == QQmlIncubator::Error) { + for (const auto &e : errors()) + nhlog::ui()->error("Error instantiating delegate: {}", + e.toString().toStdString()); + } +} diff --git a/src/timeline/DelegateChooser.h b/src/timeline/DelegateChooser.h new file mode 100644 index 00000000..68ebeb04 --- /dev/null +++ b/src/timeline/DelegateChooser.h @@ -0,0 +1,82 @@ +// A DelegateChooser like the one, that was added to Qt5.12 (in labs), but compatible with older Qt +// versions see KDE/kquickitemviews see qtdeclarative/qqmldelagatecomponent + +#pragma once + +#include +#include +#include +#include +#include +#include + +class QQmlAdaptorModel; + +class DelegateChoice : public QObject +{ + Q_OBJECT + Q_CLASSINFO("DefaultProperty", "delegate") + +public: + Q_PROPERTY(QVariant roleValue READ roleValue WRITE setRoleValue NOTIFY roleValueChanged) + Q_PROPERTY(QQmlComponent *delegate READ delegate WRITE setDelegate NOTIFY delegateChanged) + + QQmlComponent *delegate() const; + void setDelegate(QQmlComponent *delegate); + + QVariant roleValue() const; + void setRoleValue(const QVariant &value); + +signals: + void delegateChanged(); + void roleValueChanged(); + void changed(); + +private: + QVariant roleValue_; + QQmlComponent *delegate_ = nullptr; +}; + +class DelegateChooser : public QQuickItem +{ + Q_OBJECT + Q_CLASSINFO("DefaultProperty", "choices") + +public: + Q_PROPERTY(QQmlListProperty choices READ choices CONSTANT) + Q_PROPERTY(QVariant roleValue READ roleValue WRITE setRoleValue NOTIFY roleValueChanged) + + QQmlListProperty choices(); + + QVariant roleValue() const; + void setRoleValue(const QVariant &value); + + void recalcChild(); + void componentComplete() override; + +signals: + void roleChanged(); + void roleValueChanged(); + +private: + struct DelegateIncubator : public QQmlIncubator + { + DelegateIncubator(DelegateChooser &parent) + : QQmlIncubator(QQmlIncubator::AsynchronousIfNested) + , chooser(parent) + {} + void statusChanged(QQmlIncubator::Status status) override; + + DelegateChooser &chooser; + }; + + QVariant roleValue_; + QList choices_; + QQuickItem *child = nullptr; + DelegateIncubator incubator{*this}; + + static void appendChoice(QQmlListProperty *, DelegateChoice *); + static int choiceCount(QQmlListProperty *); + static DelegateChoice *choice(QQmlListProperty *, int index); + static void clearChoices(QQmlListProperty *); +}; diff --git a/src/timeline/TimelineItem.cpp b/src/timeline/TimelineItem.cpp deleted file mode 100644 index 7916bd80..00000000 --- a/src/timeline/TimelineItem.cpp +++ /dev/null @@ -1,960 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -#include - -#include -#include -#include -#include -#include -#include - -#include "ChatPage.h" -#include "Config.h" -#include "Logging.h" -#include "MainWindow.h" -#include "Olm.h" -#include "ui/Avatar.h" -#include "ui/Painter.h" -#include "ui/TextLabel.h" - -#include "timeline/TimelineItem.h" -#include "timeline/widgets/AudioItem.h" -#include "timeline/widgets/FileItem.h" -#include "timeline/widgets/ImageItem.h" -#include "timeline/widgets/VideoItem.h" - -#include "dialogs/RawMessage.h" -#include "mtx/identifiers.hpp" - -constexpr int MSG_RIGHT_MARGIN = 7; -constexpr int MSG_PADDING = 20; - -StatusIndicator::StatusIndicator(QWidget *parent) - : QWidget(parent) -{ - lockIcon_.addFile(":/icons/icons/ui/lock.png"); - clockIcon_.addFile(":/icons/icons/ui/clock.png"); - checkmarkIcon_.addFile(":/icons/icons/ui/checkmark.png"); - doubleCheckmarkIcon_.addFile(":/icons/icons/ui/double-tick-indicator.png"); -} - -void -StatusIndicator::paintIcon(QPainter &p, QIcon &icon) -{ - auto pixmap = icon.pixmap(width()); - - QPainter painter(&pixmap); - painter.setCompositionMode(QPainter::CompositionMode_SourceIn); - painter.fillRect(pixmap.rect(), p.pen().color()); - - QIcon(pixmap).paint(&p, rect(), Qt::AlignCenter, QIcon::Normal); -} - -void -StatusIndicator::paintEvent(QPaintEvent *) -{ - if (state_ == StatusIndicatorState::Empty) - return; - - Painter p(this); - PainterHighQualityEnabler hq(p); - - p.setPen(iconColor_); - - switch (state_) { - case StatusIndicatorState::Sent: { - paintIcon(p, clockIcon_); - break; - } - case StatusIndicatorState::Encrypted: - paintIcon(p, lockIcon_); - break; - case StatusIndicatorState::Received: { - paintIcon(p, checkmarkIcon_); - break; - } - case StatusIndicatorState::Read: { - paintIcon(p, doubleCheckmarkIcon_); - break; - } - case StatusIndicatorState::Empty: - break; - } -} - -void -StatusIndicator::setState(StatusIndicatorState state) -{ - state_ = state; - - switch (state) { - case StatusIndicatorState::Encrypted: - setToolTip(tr("Encrypted")); - break; - case StatusIndicatorState::Received: - setToolTip(tr("Delivered")); - break; - case StatusIndicatorState::Read: - setToolTip(tr("Seen")); - break; - case StatusIndicatorState::Sent: - setToolTip(tr("Sent")); - break; - case StatusIndicatorState::Empty: - setToolTip(""); - break; - } - - update(); -} - -void -TimelineItem::adjustMessageLayoutForWidget() -{ - messageLayout_->addLayout(widgetLayout_, 1); - actionLayout_->addWidget(replyBtn_); - actionLayout_->addWidget(contextBtn_); - messageLayout_->addLayout(actionLayout_); - messageLayout_->addWidget(statusIndicator_); - messageLayout_->addWidget(timestamp_); - - actionLayout_->setAlignment(replyBtn_, Qt::AlignTop | Qt::AlignRight); - actionLayout_->setAlignment(contextBtn_, Qt::AlignTop | Qt::AlignRight); - messageLayout_->setAlignment(statusIndicator_, Qt::AlignTop); - messageLayout_->setAlignment(timestamp_, Qt::AlignTop); - messageLayout_->setAlignment(actionLayout_, Qt::AlignTop); - - mainLayout_->addLayout(messageLayout_); -} - -void -TimelineItem::adjustMessageLayout() -{ - messageLayout_->addWidget(body_, 1); - actionLayout_->addWidget(replyBtn_); - actionLayout_->addWidget(contextBtn_); - messageLayout_->addLayout(actionLayout_); - messageLayout_->addWidget(statusIndicator_); - messageLayout_->addWidget(timestamp_); - - actionLayout_->setAlignment(replyBtn_, Qt::AlignTop | Qt::AlignRight); - actionLayout_->setAlignment(contextBtn_, Qt::AlignTop | Qt::AlignRight); - messageLayout_->setAlignment(statusIndicator_, Qt::AlignTop); - messageLayout_->setAlignment(timestamp_, Qt::AlignTop); - messageLayout_->setAlignment(actionLayout_, Qt::AlignTop); - - mainLayout_->addLayout(messageLayout_); -} - -void -TimelineItem::init() -{ - userAvatar_ = nullptr; - timestamp_ = nullptr; - userName_ = nullptr; - body_ = nullptr; - auto buttonSize_ = 32; - - contextMenu_ = new QMenu(this); - showReadReceipts_ = new QAction("Read receipts", this); - markAsRead_ = new QAction("Mark as read", this); - viewRawMessage_ = new QAction("View raw message", this); - redactMsg_ = new QAction("Redact message", this); - contextMenu_->addAction(showReadReceipts_); - contextMenu_->addAction(viewRawMessage_); - contextMenu_->addAction(markAsRead_); - contextMenu_->addAction(redactMsg_); - - connect(showReadReceipts_, &QAction::triggered, this, [this]() { - if (!event_id_.isEmpty()) - MainWindow::instance()->openReadReceiptsDialog(event_id_); - }); - - connect(this, &TimelineItem::eventRedacted, this, [this](const QString &event_id) { - emit ChatPage::instance()->removeTimelineEvent(room_id_, event_id); - }); - connect(this, &TimelineItem::redactionFailed, this, [](const QString &msg) { - emit ChatPage::instance()->showNotification(msg); - }); - connect(redactMsg_, &QAction::triggered, this, [this]() { - if (!event_id_.isEmpty()) - http::client()->redact_event( - room_id_.toStdString(), - event_id_.toStdString(), - [this](const mtx::responses::EventId &, mtx::http::RequestErr err) { - if (err) { - emit redactionFailed(tr("Message redaction failed: %1") - .arg(QString::fromStdString( - err->matrix_error.error))); - return; - } - - emit eventRedacted(event_id_); - }); - }); - connect( - ChatPage::instance(), &ChatPage::themeChanged, this, &TimelineItem::refreshAuthorColor); - connect(markAsRead_, &QAction::triggered, this, &TimelineItem::sendReadReceipt); - connect(viewRawMessage_, &QAction::triggered, this, &TimelineItem::openRawMessageViewer); - - colorGenerating_ = new QFutureWatcher(this); - connect(colorGenerating_, - &QFutureWatcher::finished, - this, - &TimelineItem::finishedGeneratingColor); - - topLayout_ = new QHBoxLayout(this); - mainLayout_ = new QVBoxLayout; - messageLayout_ = new QHBoxLayout; - actionLayout_ = new QHBoxLayout; - messageLayout_->setContentsMargins(0, 0, MSG_RIGHT_MARGIN, 0); - messageLayout_->setSpacing(MSG_PADDING); - - actionLayout_->setContentsMargins(13, 1, 13, 0); - actionLayout_->setSpacing(0); - - topLayout_->setContentsMargins( - conf::timeline::msgLeftMargin, conf::timeline::msgTopMargin, 0, 0); - topLayout_->setSpacing(0); - topLayout_->addLayout(mainLayout_); - - mainLayout_->setContentsMargins(conf::timeline::headerLeftMargin, 0, 0, 0); - mainLayout_->setSpacing(0); - - replyBtn_ = new FlatButton(this); - replyBtn_->setToolTip(tr("Reply")); - replyBtn_->setFixedSize(buttonSize_, buttonSize_); - replyBtn_->setCornerRadius(buttonSize_ / 2); - - QIcon reply_icon; - reply_icon.addFile(":/icons/icons/ui/mail-reply.png"); - replyBtn_->setIcon(reply_icon); - replyBtn_->setIconSize(QSize(buttonSize_ / 2, buttonSize_ / 2)); - connect(replyBtn_, &FlatButton::clicked, this, &TimelineItem::replyAction); - - contextBtn_ = new FlatButton(this); - contextBtn_->setToolTip(tr("Options")); - contextBtn_->setFixedSize(buttonSize_, buttonSize_); - contextBtn_->setCornerRadius(buttonSize_ / 2); - - QIcon context_icon; - context_icon.addFile(":/icons/icons/ui/vertical-ellipsis.png"); - contextBtn_->setIcon(context_icon); - contextBtn_->setIconSize(QSize(buttonSize_ / 2, buttonSize_ / 2)); - contextBtn_->setMenu(contextMenu_); - - timestampFont_.setPointSizeF(timestampFont_.pointSizeF() * 0.9); - timestampFont_.setFamily("Monospace"); - timestampFont_.setStyleHint(QFont::Monospace); - - QFontMetrics tsFm(timestampFont_); - - statusIndicator_ = new StatusIndicator(this); - statusIndicator_->setFixedWidth(tsFm.height() - tsFm.leading()); - statusIndicator_->setFixedHeight(tsFm.height() - tsFm.leading()); - - parentWidget()->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); -} - -/* - * For messages created locally. - */ -TimelineItem::TimelineItem(mtx::events::MessageType ty, - const QString &userid, - QString body, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , message_type_(ty) - , room_id_{room_id} -{ - init(); - addReplyAction(); - - auto displayName = Cache::displayName(room_id_, userid); - auto timestamp = QDateTime::currentDateTime(); - - // Generate the html body to be rendered. - auto formatted_body = utils::markdownToHtml(body); - - // Escape html if the input is not formatted. - if (formatted_body == body.trimmed().toHtmlEscaped()) - formatted_body = body.toHtmlEscaped(); - - QString emptyEventId; - - if (ty == mtx::events::MessageType::Emote) { - formatted_body = QString("%1").arg(formatted_body); - descriptionMsg_ = {emptyEventId, - "", - userid, - QString("* %1 %2").arg(displayName).arg(body), - utils::descriptiveTime(timestamp), - timestamp}; - } else { - descriptionMsg_ = {emptyEventId, - "You: ", - userid, - body, - utils::descriptiveTime(timestamp), - timestamp}; - } - - formatted_body = utils::linkifyMessage(formatted_body); - formatted_body.replace("mx-reply", "div"); - - generateTimestamp(timestamp); - - if (withSender) { - generateBody(userid, displayName, formatted_body); - setupAvatarLayout(displayName); - - setUserAvatar(userid); - } else { - generateBody(formatted_body); - setupSimpleLayout(); - } - - adjustMessageLayout(); -} - -TimelineItem::TimelineItem(ImageItem *image, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget{parent} - , message_type_(mtx::events::MessageType::Image) - , room_id_{room_id} -{ - init(); - - setupLocalWidgetLayout(image, userid, withSender); - - addSaveImageAction(image); -} - -TimelineItem::TimelineItem(FileItem *file, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget{parent} - , message_type_(mtx::events::MessageType::File) - , room_id_{room_id} -{ - init(); - - setupLocalWidgetLayout(file, userid, withSender); -} - -TimelineItem::TimelineItem(AudioItem *audio, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget{parent} - , message_type_(mtx::events::MessageType::Audio) - , room_id_{room_id} -{ - init(); - - setupLocalWidgetLayout(audio, userid, withSender); -} - -TimelineItem::TimelineItem(VideoItem *video, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget{parent} - , message_type_(mtx::events::MessageType::Video) - , room_id_{room_id} -{ - init(); - - setupLocalWidgetLayout(video, userid, withSender); -} - -TimelineItem::TimelineItem(ImageItem *image, - const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , message_type_(mtx::events::MessageType::Image) - , room_id_{room_id} -{ - setupWidgetLayout, ImageItem>( - image, event, with_sender); - - markOwnMessagesAsReceived(event.sender); - - addSaveImageAction(image); -} - -TimelineItem::TimelineItem(StickerItem *image, - const mtx::events::Sticker &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - setupWidgetLayout(image, event, with_sender); - - markOwnMessagesAsReceived(event.sender); - - addSaveImageAction(image); -} - -TimelineItem::TimelineItem(FileItem *file, - const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , message_type_(mtx::events::MessageType::File) - , room_id_{room_id} -{ - setupWidgetLayout, FileItem>( - file, event, with_sender); - - markOwnMessagesAsReceived(event.sender); -} - -TimelineItem::TimelineItem(AudioItem *audio, - const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , message_type_(mtx::events::MessageType::Audio) - , room_id_{room_id} -{ - setupWidgetLayout, AudioItem>( - audio, event, with_sender); - - markOwnMessagesAsReceived(event.sender); -} - -TimelineItem::TimelineItem(VideoItem *video, - const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , message_type_(mtx::events::MessageType::Video) - , room_id_{room_id} -{ - setupWidgetLayout, VideoItem>( - video, event, with_sender); - - markOwnMessagesAsReceived(event.sender); -} - -/* - * Used to display remote notice messages. - */ -TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , message_type_(mtx::events::MessageType::Notice) - , room_id_{room_id} -{ - init(); - addReplyAction(); - - markOwnMessagesAsReceived(event.sender); - - event_id_ = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - const auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); - - auto formatted_body = utils::linkifyMessage(utils::getMessageBody(event).trimmed()); - auto body = QString::fromStdString(event.content.body).trimmed().toHtmlEscaped(); - - descriptionMsg_ = {event_id_, - Cache::displayName(room_id_, sender), - sender, - " sent a notification", - utils::descriptiveTime(timestamp), - timestamp}; - - generateTimestamp(timestamp); - - if (with_sender) { - auto displayName = Cache::displayName(room_id_, sender); - - generateBody(sender, displayName, formatted_body); - setupAvatarLayout(displayName); - - setUserAvatar(sender); - } else { - generateBody(formatted_body); - setupSimpleLayout(); - } - - adjustMessageLayout(); -} - -/* - * Used to display remote emote messages. - */ -TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , message_type_(mtx::events::MessageType::Emote) - , room_id_{room_id} -{ - init(); - addReplyAction(); - - markOwnMessagesAsReceived(event.sender); - - event_id_ = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - - auto formatted_body = utils::linkifyMessage(utils::getMessageBody(event).trimmed()); - auto body = QString::fromStdString(event.content.body).trimmed().toHtmlEscaped(); - - auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); - auto displayName = Cache::displayName(room_id_, sender); - formatted_body = QString("%1").arg(formatted_body); - - descriptionMsg_ = {event_id_, - "", - sender, - QString("* %1 %2").arg(displayName).arg(body), - utils::descriptiveTime(timestamp), - timestamp}; - - generateTimestamp(timestamp); - - if (with_sender) { - generateBody(sender, displayName, formatted_body); - setupAvatarLayout(displayName); - - setUserAvatar(sender); - } else { - generateBody(formatted_body); - setupSimpleLayout(); - } - - adjustMessageLayout(); -} - -/* - * Used to display remote text messages. - */ -TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , message_type_(mtx::events::MessageType::Text) - , room_id_{room_id} -{ - init(); - addReplyAction(); - - markOwnMessagesAsReceived(event.sender); - - event_id_ = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - - auto formatted_body = utils::linkifyMessage(utils::getMessageBody(event).trimmed()); - auto body = QString::fromStdString(event.content.body).trimmed().toHtmlEscaped(); - - auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); - auto displayName = Cache::displayName(room_id_, sender); - - QSettings settings; - descriptionMsg_ = {event_id_, - sender == settings.value("auth/user_id") ? "You" : displayName, - sender, - QString(": %1").arg(body), - utils::descriptiveTime(timestamp), - timestamp}; - - generateTimestamp(timestamp); - - if (with_sender) { - generateBody(sender, displayName, formatted_body); - setupAvatarLayout(displayName); - - setUserAvatar(sender); - } else { - generateBody(formatted_body); - setupSimpleLayout(); - } - - adjustMessageLayout(); -} - -TimelineItem::~TimelineItem() -{ - colorGenerating_->cancel(); - colorGenerating_->waitForFinished(); -} - -void -TimelineItem::markSent() -{ - statusIndicator_->setState(StatusIndicatorState::Sent); -} - -void -TimelineItem::markOwnMessagesAsReceived(const std::string &sender) -{ - QSettings settings; - if (sender == settings.value("auth/user_id").toString().toStdString()) - statusIndicator_->setState(StatusIndicatorState::Received); -} - -void -TimelineItem::markRead() -{ - if (statusIndicator_->state() != StatusIndicatorState::Encrypted) - statusIndicator_->setState(StatusIndicatorState::Read); -} - -void -TimelineItem::markReceived(bool isEncrypted) -{ - isReceived_ = true; - - if (isEncrypted) - statusIndicator_->setState(StatusIndicatorState::Encrypted); - else - statusIndicator_->setState(StatusIndicatorState::Received); - - sendReadReceipt(); -} - -// Only the body is displayed. -void -TimelineItem::generateBody(const QString &body) -{ - body_ = new TextLabel(utils::replaceEmoji(body), this); - body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); - - connect(body_, &TextLabel::userProfileTriggered, this, [](const QString &user_id) { - MainWindow::instance()->openUserProfile(user_id, - ChatPage::instance()->currentRoom()); - }); -} - -void -TimelineItem::refreshAuthorColor() -{ - // Cancel and wait if we are already generating the color. - if (colorGenerating_->isRunning()) { - colorGenerating_->cancel(); - colorGenerating_->waitForFinished(); - } - if (userName_) { - // generate user's unique color. - std::function generate = [this]() { - QString userColor = utils::generateContrastingHexColor( - userName_->toolTip(), backgroundColor().name()); - return userColor; - }; - - QString userColor = Cache::userColor(userName_->toolTip()); - - // If the color is empty, then generate it asynchronously - if (userColor.isEmpty()) { - colorGenerating_->setFuture(QtConcurrent::run(generate)); - } else { - userName_->setStyleSheet("QLabel { color : " + userColor + "; }"); - } - } -} - -void -TimelineItem::finishedGeneratingColor() -{ - nhlog::ui()->debug("finishedGeneratingColor for: {}", userName_->toolTip().toStdString()); - QString userColor = colorGenerating_->result(); - - if (!userColor.isEmpty()) { - // another TimelineItem might have inserted in the meantime. - if (Cache::userColor(userName_->toolTip()).isEmpty()) { - Cache::insertUserColor(userName_->toolTip(), userColor); - } - userName_->setStyleSheet("QLabel { color : " + userColor + "; }"); - } -} -// The username/timestamp is displayed along with the message body. -void -TimelineItem::generateBody(const QString &user_id, const QString &displayname, const QString &body) -{ - generateUserName(user_id, displayname); - generateBody(body); -} - -void -TimelineItem::generateUserName(const QString &user_id, const QString &displayname) -{ - auto sender = displayname; - - if (displayname.startsWith("@")) { - // TODO: Fix this by using a UserId type. - if (displayname.split(":")[0].split("@").size() > 1) - sender = displayname.split(":")[0].split("@")[1]; - } - - QFont usernameFont; - usernameFont.setPointSizeF(usernameFont.pointSizeF() * 1.1); - usernameFont.setWeight(QFont::Medium); - - QFontMetrics fm(usernameFont); - - userName_ = new QLabel(this); - userName_->setFont(usernameFont); - userName_->setText(utils::replaceEmoji(fm.elidedText(sender, Qt::ElideRight, 500))); - userName_->setToolTip(user_id); - userName_->setToolTipDuration(1500); - userName_->setAttribute(Qt::WA_Hover); - userName_->setAlignment(Qt::AlignLeft | Qt::AlignTop); -#if QT_VERSION < QT_VERSION_CHECK(5, 11, 0) - // width deprecated in 5.13: - userName_->setFixedWidth(QFontMetrics(userName_->font()).width(userName_->text())); -#else - userName_->setFixedWidth( - QFontMetrics(userName_->font()).horizontalAdvance(userName_->text())); -#endif - // Set the user color asynchronously if it hasn't been generated yet, - // otherwise this will just set it. - refreshAuthorColor(); - - auto filter = new UserProfileFilter(user_id, userName_); - userName_->installEventFilter(filter); - userName_->setCursor(Qt::PointingHandCursor); - - connect(filter, &UserProfileFilter::hoverOn, this, [this]() { - QFont f = userName_->font(); - f.setUnderline(true); - userName_->setFont(f); - }); - - connect(filter, &UserProfileFilter::hoverOff, this, [this]() { - QFont f = userName_->font(); - f.setUnderline(false); - userName_->setFont(f); - }); - - connect(filter, &UserProfileFilter::clicked, this, [this, user_id]() { - MainWindow::instance()->openUserProfile(user_id, room_id_); - }); -} - -void -TimelineItem::generateTimestamp(const QDateTime &time) -{ - timestamp_ = new QLabel(this); - timestamp_->setFont(timestampFont_); - timestamp_->setText( - QString(" %1 ").arg(time.toString("HH:mm"))); -} - -void -TimelineItem::setupAvatarLayout(const QString &userName) -{ - topLayout_->setContentsMargins( - conf::timeline::msgLeftMargin, conf::timeline::msgAvatarTopMargin, 0, 0); - - QFont f; - f.setPointSizeF(f.pointSizeF()); - - userAvatar_ = new Avatar(this, QFontMetrics(f).height() * 2); - userAvatar_->setLetter(QChar(userName[0]).toUpper()); - - // TODO: The provided user name should be a UserId class - if (userName[0] == '@' && userName.size() > 1) - userAvatar_->setLetter(QChar(userName[1]).toUpper()); - - topLayout_->insertWidget(0, userAvatar_); - topLayout_->setAlignment(userAvatar_, Qt::AlignTop | Qt::AlignLeft); - - if (userName_) - mainLayout_->insertWidget(0, userName_, Qt::AlignTop | Qt::AlignLeft); -} - -void -TimelineItem::setupSimpleLayout() -{ - QFont f; - f.setPointSizeF(f.pointSizeF()); - - topLayout_->setContentsMargins(conf::timeline::msgLeftMargin + - QFontMetrics(f).height() * 2 + 2, - conf::timeline::msgTopMargin, - 0, - 0); -} - -void -TimelineItem::setUserAvatar(const QString &userid) -{ - if (userAvatar_ == nullptr) - return; - - userAvatar_->setImage(room_id_, userid); -} - -void -TimelineItem::contextMenuEvent(QContextMenuEvent *event) -{ - if (contextMenu_) - contextMenu_->exec(event->globalPos()); -} - -void -TimelineItem::paintEvent(QPaintEvent *) -{ - QStyleOption opt; - opt.init(this); - QPainter p(this); - style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); -} - -void -TimelineItem::addSaveImageAction(ImageItem *image) -{ - if (contextMenu_) { - auto saveImage = new QAction("Save image", this); - contextMenu_->addAction(saveImage); - - connect(saveImage, &QAction::triggered, image, &ImageItem::saveAs); - } -} - -void -TimelineItem::addReplyAction() -{ - if (contextMenu_) { - auto replyAction = new QAction("Reply", this); - contextMenu_->addAction(replyAction); - - connect(replyAction, &QAction::triggered, this, &TimelineItem::replyAction); - } -} - -void -TimelineItem::replyAction() -{ - if (!body_) - return; - - RelatedInfo related; - related.type = message_type_; - related.quoted_body = body_->toPlainText(); - related.quoted_user = descriptionMsg_.userid; - related.related_event = eventId().toStdString(); - related.room = room_id_; - - emit ChatPage::instance()->messageReply(related); -} - -void -TimelineItem::addKeyRequestAction() -{ - if (contextMenu_) { - auto requestKeys = new QAction("Request encryption keys", this); - contextMenu_->addAction(requestKeys); - - connect(requestKeys, &QAction::triggered, this, [this]() { - olm::request_keys(room_id_.toStdString(), event_id_.toStdString()); - }); - } -} - -void -TimelineItem::addAvatar() -{ - if (userAvatar_) - return; - - // TODO: should be replaced with the proper event struct. - auto userid = descriptionMsg_.userid; - auto displayName = Cache::displayName(room_id_, userid); - - generateUserName(userid, displayName); - - setupAvatarLayout(displayName); - - setUserAvatar(userid); -} - -void -TimelineItem::sendReadReceipt() const -{ - if (!event_id_.isEmpty()) - http::client()->read_event(room_id_.toStdString(), - event_id_.toStdString(), - [this](mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn( - "failed to read_event ({}, {})", - room_id_.toStdString(), - event_id_.toStdString()); - } - }); -} - -void -TimelineItem::openRawMessageViewer() const -{ - const auto event_id = event_id_.toStdString(); - const auto room_id = room_id_.toStdString(); - - auto proxy = std::make_shared(); - connect(proxy.get(), &EventProxy::eventRetrieved, this, [](const nlohmann::json &obj) { - auto dialog = new dialogs::RawMessage{QString::fromStdString(obj.dump(4))}; - Q_UNUSED(dialog); - }); - - http::client()->get_event( - room_id, - event_id, - [event_id, room_id, proxy = std::move(proxy)]( - const mtx::events::collections::TimelineEvents &res, mtx::http::RequestErr err) { - using namespace mtx::events; - - if (err) { - nhlog::net()->warn( - "failed to retrieve event {} from {}", event_id, room_id); - return; - } - - try { - emit proxy->eventRetrieved(utils::serialize_event(res)); - } catch (const nlohmann::json::exception &e) { - nhlog::net()->warn( - "failed to serialize event ({}, {})", room_id, event_id); - } - }); -} diff --git a/src/timeline/TimelineItem.h b/src/timeline/TimelineItem.h deleted file mode 100644 index 356976e5..00000000 --- a/src/timeline/TimelineItem.h +++ /dev/null @@ -1,389 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "mtx/events.hpp" - -#include "AvatarProvider.h" -#include "RoomInfoListItem.h" -#include "Utils.h" - -#include "Cache.h" -#include "MatrixClient.h" - -#include "ui/FlatButton.h" - -class ImageItem; -class StickerItem; -class AudioItem; -class VideoItem; -class FileItem; -class Avatar; -class TextLabel; - -enum class StatusIndicatorState -{ - //! The encrypted message was received by the server. - Encrypted, - //! The plaintext message was received by the server. - Received, - //! At least one of the participants has read the message. - Read, - //! The client sent the message. Not yet received. - Sent, - //! When the message is loaded from cache or backfill. - Empty, -}; - -//! -//! Used to notify the user about the status of a message. -//! -class StatusIndicator : public QWidget -{ - Q_OBJECT - -public: - explicit StatusIndicator(QWidget *parent); - void setState(StatusIndicatorState state); - StatusIndicatorState state() const { return state_; } - -protected: - void paintEvent(QPaintEvent *event) override; - -private: - void paintIcon(QPainter &p, QIcon &icon); - - QIcon lockIcon_; - QIcon clockIcon_; - QIcon checkmarkIcon_; - QIcon doubleCheckmarkIcon_; - - QColor iconColor_ = QColor("#999"); - - StatusIndicatorState state_ = StatusIndicatorState::Empty; - - static constexpr int MaxWidth = 24; -}; - -class EventProxy : public QObject -{ - Q_OBJECT - -signals: - void eventRetrieved(const nlohmann::json &); -}; - -class UserProfileFilter : public QObject -{ - Q_OBJECT - -public: - explicit UserProfileFilter(const QString &user_id, QLabel *parent) - : QObject(parent) - , user_id_{user_id} - {} - -signals: - void hoverOff(); - void hoverOn(); - void clicked(); - -protected: - bool eventFilter(QObject *obj, QEvent *event) - { - if (event->type() == QEvent::MouseButtonRelease) { - emit clicked(); - return true; - } else if (event->type() == QEvent::HoverLeave) { - emit hoverOff(); - return true; - } else if (event->type() == QEvent::HoverEnter) { - emit hoverOn(); - return true; - } - - return QObject::eventFilter(obj, event); - } - -private: - QString user_id_; -}; - -class TimelineItem : public QWidget -{ - Q_OBJECT - Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor) - -public: - TimelineItem(const mtx::events::RoomEvent &e, - bool with_sender, - const QString &room_id, - QWidget *parent = 0); - TimelineItem(const mtx::events::RoomEvent &e, - bool with_sender, - const QString &room_id, - QWidget *parent = 0); - TimelineItem(const mtx::events::RoomEvent &e, - bool with_sender, - const QString &room_id, - QWidget *parent = 0); - - // For local messages. - // m.text & m.emote - TimelineItem(mtx::events::MessageType ty, - const QString &userid, - QString body, - bool withSender, - const QString &room_id, - QWidget *parent = 0); - // m.image - TimelineItem(ImageItem *item, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent = 0); - TimelineItem(FileItem *item, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent = 0); - TimelineItem(AudioItem *item, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent = 0); - TimelineItem(VideoItem *item, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent = 0); - - TimelineItem(ImageItem *img, - const mtx::events::RoomEvent &e, - bool with_sender, - const QString &room_id, - QWidget *parent); - TimelineItem(StickerItem *img, - const mtx::events::Sticker &e, - bool with_sender, - const QString &room_id, - QWidget *parent); - TimelineItem(FileItem *file, - const mtx::events::RoomEvent &e, - bool with_sender, - const QString &room_id, - QWidget *parent); - TimelineItem(AudioItem *audio, - const mtx::events::RoomEvent &e, - bool with_sender, - const QString &room_id, - QWidget *parent); - TimelineItem(VideoItem *video, - const mtx::events::RoomEvent &e, - bool with_sender, - const QString &room_id, - QWidget *parent); - - ~TimelineItem(); - - void setBackgroundColor(const QColor &color) { backgroundColor_ = color; } - QColor backgroundColor() const { return backgroundColor_; } - - void setUserAvatar(const QString &userid); - DescInfo descriptionMessage() const { return descriptionMsg_; } - QString eventId() const { return event_id_; } - void setEventId(const QString &event_id) { event_id_ = event_id; } - void markReceived(bool isEncrypted); - void markRead(); - void markSent(); - bool isReceived() { return isReceived_; }; - void setRoomId(QString room_id) { room_id_ = room_id; } - void sendReadReceipt() const; - void openRawMessageViewer() const; - void replyAction(); - - //! Add a user avatar for this event. - void addAvatar(); - void addKeyRequestAction(); - -signals: - void eventRedacted(const QString &event_id); - void redactionFailed(const QString &msg); - -public slots: - void refreshAuthorColor(); - void finishedGeneratingColor(); - -protected: - void paintEvent(QPaintEvent *event) override; - void contextMenuEvent(QContextMenuEvent *event) override; - -private: - //! If we are the sender of the message the event wil be marked as received by the server. - void markOwnMessagesAsReceived(const std::string &sender); - void init(); - //! Add a context menu option to save the image of the timeline item. - void addSaveImageAction(ImageItem *image); - //! Add the reply action in the context menu for widgets that support it. - void addReplyAction(); - - template - void setupLocalWidgetLayout(Widget *widget, const QString &userid, bool withSender); - - template - void setupWidgetLayout(Widget *widget, const Event &event, bool withSender); - - void generateBody(const QString &body); - void generateBody(const QString &user_id, const QString &displayname, const QString &body); - void generateTimestamp(const QDateTime &time); - void generateUserName(const QString &userid, const QString &displayname); - - void setupAvatarLayout(const QString &userName); - void setupSimpleLayout(); - - void adjustMessageLayout(); - void adjustMessageLayoutForWidget(); - - //! Whether or not the event associated with the widget - //! has been acknowledged by the server. - bool isReceived_ = false; - - QFutureWatcher *colorGenerating_; - - QString event_id_; - mtx::events::MessageType message_type_ = mtx::events::MessageType::Unknown; - QString room_id_; - - DescInfo descriptionMsg_; - - QMenu *contextMenu_; - QAction *showReadReceipts_; - QAction *markAsRead_; - QAction *redactMsg_; - QAction *viewRawMessage_; - QAction *replyMsg_; - - QHBoxLayout *topLayout_ = nullptr; - QHBoxLayout *messageLayout_ = nullptr; - QHBoxLayout *actionLayout_ = nullptr; - QVBoxLayout *mainLayout_ = nullptr; - QHBoxLayout *widgetLayout_ = nullptr; - - Avatar *userAvatar_; - - QFont timestampFont_; - - StatusIndicator *statusIndicator_; - - QLabel *timestamp_; - QLabel *userName_; - TextLabel *body_; - - QColor backgroundColor_; - - FlatButton *replyBtn_; - FlatButton *contextBtn_; -}; - -template -void -TimelineItem::setupLocalWidgetLayout(Widget *widget, const QString &userid, bool withSender) -{ - auto displayName = Cache::displayName(room_id_, userid); - auto timestamp = QDateTime::currentDateTime(); - - descriptionMsg_ = {"", // No event_id up until this point. - "You", - userid, - QString(" %1").arg(utils::messageDescription()), - utils::descriptiveTime(timestamp), - timestamp}; - - generateTimestamp(timestamp); - - widgetLayout_ = new QHBoxLayout; - widgetLayout_->setContentsMargins(0, 2, 0, 2); - widgetLayout_->addWidget(widget); - widgetLayout_->addStretch(1); - - if (withSender) { - generateBody(userid, displayName, ""); - setupAvatarLayout(displayName); - - setUserAvatar(userid); - } else { - setupSimpleLayout(); - } - - adjustMessageLayoutForWidget(); -} - -template -void -TimelineItem::setupWidgetLayout(Widget *widget, const Event &event, bool withSender) -{ - init(); - - // if (event.type == mtx::events::EventType::RoomMessage) { - // message_type_ = mtx::events::getMessageType(event.content.msgtype); - //} - // TODO: Fix this. - message_type_ = mtx::events::MessageType::Unknown; - event_id_ = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - - auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); - auto displayName = Cache::displayName(room_id_, sender); - - QSettings settings; - descriptionMsg_ = {event_id_, - sender == settings.value("auth/user_id") ? "You" : displayName, - sender, - QString(" %1").arg(utils::messageDescription()), - utils::descriptiveTime(timestamp), - timestamp}; - - generateTimestamp(timestamp); - - widgetLayout_ = new QHBoxLayout(); - widgetLayout_->setContentsMargins(0, 2, 0, 2); - widgetLayout_->addWidget(widget); - widgetLayout_->addStretch(1); - - if (withSender) { - generateBody(sender, displayName, ""); - setupAvatarLayout(displayName); - - setUserAvatar(sender); - } else { - setupSimpleLayout(); - } - - adjustMessageLayoutForWidget(); -} diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp new file mode 100644 index 00000000..e3d87ae6 --- /dev/null +++ b/src/timeline/TimelineModel.cpp @@ -0,0 +1,1541 @@ +#include "TimelineModel.h" + +#include +#include + +#include +#include +#include +#include + +#include "ChatPage.h" +#include "Logging.h" +#include "MainWindow.h" +#include "MxcImageProvider.h" +#include "Olm.h" +#include "TimelineViewManager.h" +#include "Utils.h" +#include "dialogs/RawMessage.h" + +Q_DECLARE_METATYPE(QModelIndex) + +namespace { +template +QString +eventId(const mtx::events::RoomEvent &event) +{ + return QString::fromStdString(event.event_id); +} +template +QString +roomId(const mtx::events::Event &event) +{ + return QString::fromStdString(event.room_id); +} +template +QString +senderId(const mtx::events::RoomEvent &event) +{ + return QString::fromStdString(event.sender); +} + +template +QDateTime +eventTimestamp(const mtx::events::RoomEvent &event) +{ + return QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); +} + +template +std::string +eventMsgType(const mtx::events::Event &) +{ + return ""; +} +template +auto +eventMsgType(const mtx::events::RoomEvent &e) -> decltype(e.content.msgtype) +{ + return e.content.msgtype; +} + +template +QString +eventBody(const mtx::events::Event &) +{ + return QString(""); +} +template +auto +eventBody(const mtx::events::RoomEvent &e) + -> std::enable_if_t::value, QString> +{ + return QString::fromStdString(e.content.body); +} + +template +QString +eventFormattedBody(const mtx::events::Event &) +{ + return QString(""); +} +template +auto +eventFormattedBody(const mtx::events::RoomEvent &e) + -> std::enable_if_t::value, QString> +{ + auto temp = e.content.formatted_body; + if (!temp.empty()) { + return QString::fromStdString(temp); + } else { + return QString::fromStdString(e.content.body).toHtmlEscaped().replace("\n", "
"); + } +} + +template +boost::optional +eventEncryptionInfo(const mtx::events::Event &) +{ + return boost::none; +} + +template +auto +eventEncryptionInfo(const mtx::events::RoomEvent &e) -> std::enable_if_t< + std::is_same>::value, + boost::optional> +{ + return e.content.file; +} + +template +QString +eventUrl(const mtx::events::Event &) +{ + return ""; +} + +QString +eventUrl(const mtx::events::StateEvent &e) +{ + return QString::fromStdString(e.content.url); +} + +template +auto +eventUrl(const mtx::events::RoomEvent &e) + -> std::enable_if_t::value, QString> +{ + if (e.content.file) + return QString::fromStdString(e.content.file->url); + return QString::fromStdString(e.content.url); +} + +template +QString +eventThumbnailUrl(const mtx::events::Event &) +{ + return ""; +} +template +auto +eventThumbnailUrl(const mtx::events::RoomEvent &e) + -> std::enable_if_t::value, + QString> +{ + return QString::fromStdString(e.content.info.thumbnail_url); +} + +template +QString +eventFilename(const mtx::events::Event &) +{ + return ""; +} +QString +eventFilename(const mtx::events::RoomEvent &e) +{ + // body may be the original filename + return QString::fromStdString(e.content.body); +} +QString +eventFilename(const mtx::events::RoomEvent &e) +{ + // body may be the original filename + return QString::fromStdString(e.content.body); +} +QString +eventFilename(const mtx::events::RoomEvent &e) +{ + // body may be the original filename + return QString::fromStdString(e.content.body); +} +QString +eventFilename(const mtx::events::RoomEvent &e) +{ + // body may be the original filename + if (!e.content.filename.empty()) + return QString::fromStdString(e.content.filename); + return QString::fromStdString(e.content.body); +} + +template +auto +eventFilesize(const mtx::events::RoomEvent &e) -> decltype(e.content.info.size) +{ + return e.content.info.size; +} + +template +int64_t +eventFilesize(const mtx::events::Event &) +{ + return 0; +} + +template +QString +eventMimeType(const mtx::events::Event &) +{ + return QString(); +} +template +auto +eventMimeType(const mtx::events::RoomEvent &e) + -> std::enable_if_t::value, QString> +{ + return QString::fromStdString(e.content.info.mimetype); +} + +template +QString +eventRelatesTo(const mtx::events::Event &) +{ + return QString(); +} +template +auto +eventRelatesTo(const mtx::events::RoomEvent &e) -> std::enable_if_t< + std::is_same::value, + QString> +{ + return QString::fromStdString(e.content.relates_to.in_reply_to.event_id); +} + +template +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &e) +{ + using mtx::events::EventType; + switch (e.type) { + case EventType::RoomKeyRequest: + return qml_mtx_events::EventType::KeyRequest; + case EventType::RoomAliases: + return qml_mtx_events::EventType::Aliases; + case EventType::RoomAvatar: + return qml_mtx_events::EventType::Avatar; + case EventType::RoomCanonicalAlias: + return qml_mtx_events::EventType::CanonicalAlias; + case EventType::RoomCreate: + return qml_mtx_events::EventType::Create; + case EventType::RoomEncrypted: + return qml_mtx_events::EventType::Encrypted; + case EventType::RoomEncryption: + return qml_mtx_events::EventType::Encryption; + case EventType::RoomGuestAccess: + return qml_mtx_events::EventType::GuestAccess; + case EventType::RoomHistoryVisibility: + return qml_mtx_events::EventType::HistoryVisibility; + case EventType::RoomJoinRules: + return qml_mtx_events::EventType::JoinRules; + case EventType::RoomMember: + return qml_mtx_events::EventType::Member; + case EventType::RoomMessage: + return qml_mtx_events::EventType::UnknownMessage; + case EventType::RoomName: + return qml_mtx_events::EventType::Name; + case EventType::RoomPowerLevels: + return qml_mtx_events::EventType::PowerLevels; + case EventType::RoomTopic: + return qml_mtx_events::EventType::Topic; + case EventType::RoomTombstone: + return qml_mtx_events::EventType::Tombstone; + case EventType::RoomRedaction: + return qml_mtx_events::EventType::Redaction; + case EventType::RoomPinnedEvents: + return qml_mtx_events::EventType::PinnedEvents; + case EventType::Sticker: + return qml_mtx_events::EventType::Sticker; + case EventType::Tag: + return qml_mtx_events::EventType::Tag; + case EventType::Unsupported: + default: + return qml_mtx_events::EventType::Unsupported; + } +} +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &) +{ + return qml_mtx_events::EventType::AudioMessage; +} +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &) +{ + return qml_mtx_events::EventType::EmoteMessage; +} +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &) +{ + return qml_mtx_events::EventType::FileMessage; +} +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &) +{ + return qml_mtx_events::EventType::ImageMessage; +} +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &) +{ + return qml_mtx_events::EventType::NoticeMessage; +} +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &) +{ + return qml_mtx_events::EventType::TextMessage; +} +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &) +{ + return qml_mtx_events::EventType::VideoMessage; +} + +qml_mtx_events::EventType +toRoomEventType(const mtx::events::Event &) +{ + return qml_mtx_events::EventType::Redacted; +} +// ::EventType::Type toRoomEventType(const Event &e) { return +// ::EventType::LocationMessage; } + +template +uint64_t +eventHeight(const mtx::events::Event &) +{ + return -1; +} +template +auto +eventHeight(const mtx::events::RoomEvent &e) -> decltype(e.content.info.h) +{ + return e.content.info.h; +} +template +uint64_t +eventWidth(const mtx::events::Event &) +{ + return -1; +} +template +auto +eventWidth(const mtx::events::RoomEvent &e) -> decltype(e.content.info.w) +{ + return e.content.info.w; +} + +template +double +eventPropHeight(const mtx::events::RoomEvent &e) +{ + auto w = eventWidth(e); + if (w == 0) + w = 1; + + double prop = eventHeight(e) / (double)w; + + return prop > 0 ? prop : 1.; +} +} + +TimelineModel::TimelineModel(TimelineViewManager *manager, QString room_id, QObject *parent) + : QAbstractListModel(parent) + , room_id_(room_id) + , manager_(manager) +{ + connect( + this, &TimelineModel::oldMessagesRetrieved, this, &TimelineModel::addBackwardsEvents); + connect(this, &TimelineModel::messageFailed, this, [this](QString txn_id) { + pending.removeOne(txn_id); + failed.insert(txn_id); + int idx = idToIndex(txn_id); + if (idx < 0) { + nhlog::ui()->warn("Failed index out of range"); + return; + } + isProcessingPending = false; + emit dataChanged(index(idx, 0), index(idx, 0)); + }); + connect(this, &TimelineModel::messageSent, this, [this](QString txn_id, QString event_id) { + pending.removeOne(txn_id); + int idx = idToIndex(txn_id); + if (idx < 0) { + nhlog::ui()->warn("Sent index out of range"); + return; + } + eventOrder[idx] = event_id; + auto ev = events.value(txn_id); + ev = boost::apply_visitor( + [event_id](const auto &e) -> mtx::events::collections::TimelineEvents { + auto eventCopy = e; + eventCopy.event_id = event_id.toStdString(); + return eventCopy; + }, + ev); + events.remove(txn_id); + events.insert(event_id, ev); + + // mark our messages as read + readEvent(event_id.toStdString()); + + // ask to be notified for read receipts + cache::client()->addPendingReceipt(room_id_, event_id); + + isProcessingPending = false; + emit dataChanged(index(idx, 0), index(idx, 0)); + + if (pending.size() > 0) + emit nextPendingMessage(); + }); + connect(this, &TimelineModel::redactionFailed, this, [](const QString &msg) { + emit ChatPage::instance()->showNotification(msg); + }); + + connect( + this, &TimelineModel::nextPendingMessage, this, &TimelineModel::processOnePendingMessage); + connect(this, &TimelineModel::newMessageToSend, this, &TimelineModel::addPendingMessage); +} + +QHash +TimelineModel::roleNames() const +{ + return { + {Section, "section"}, + {Type, "type"}, + {Body, "body"}, + {FormattedBody, "formattedBody"}, + {UserId, "userId"}, + {UserName, "userName"}, + {Timestamp, "timestamp"}, + {Url, "url"}, + {ThumbnailUrl, "thumbnailUrl"}, + {Filename, "filename"}, + {Filesize, "filesize"}, + {MimeType, "mimetype"}, + {Height, "height"}, + {Width, "width"}, + {ProportionalHeight, "proportionalHeight"}, + {Id, "id"}, + {State, "state"}, + {IsEncrypted, "isEncrypted"}, + {ReplyTo, "replyTo"}, + }; +} +int +TimelineModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return (int)this->eventOrder.size(); +} + +QVariant +TimelineModel::data(const QModelIndex &index, int role) const +{ + if (index.row() < 0 && index.row() >= (int)eventOrder.size()) + return QVariant(); + + QString id = eventOrder[index.row()]; + + mtx::events::collections::TimelineEvents event = events.value(id); + + if (auto e = boost::get>(&event)) { + event = decryptEvent(*e).event; + } + + switch (role) { + case Section: { + QDateTime date = boost::apply_visitor( + [](const auto &e) -> QDateTime { return eventTimestamp(e); }, event); + date.setTime(QTime()); + + QString userId = + boost::apply_visitor([](const auto &e) -> QString { return senderId(e); }, event); + + for (int r = index.row() - 1; r > 0; r--) { + auto tempEv = events.value(eventOrder[r]); + QDateTime prevDate = boost::apply_visitor( + [](const auto &e) -> QDateTime { return eventTimestamp(e); }, tempEv); + prevDate.setTime(QTime()); + if (prevDate != date) + return QString("%2 %1").arg(date.toMSecsSinceEpoch()).arg(userId); + + QString prevUserId = boost::apply_visitor( + [](const auto &e) -> QString { return senderId(e); }, tempEv); + if (userId != prevUserId) + break; + } + + return QString("%1").arg(userId); + } + case UserId: + return QVariant(boost::apply_visitor( + [](const auto &e) -> QString { return senderId(e); }, event)); + case UserName: + return QVariant(displayName(boost::apply_visitor( + [](const auto &e) -> QString { return senderId(e); }, event))); + + case Timestamp: + return QVariant(boost::apply_visitor( + [](const auto &e) -> QDateTime { return eventTimestamp(e); }, event)); + case Type: + return QVariant(boost::apply_visitor( + [](const auto &e) -> qml_mtx_events::EventType { return toRoomEventType(e); }, + event)); + case Body: + return QVariant(utils::replaceEmoji(boost::apply_visitor( + [](const auto &e) -> QString { return eventBody(e); }, event))); + case FormattedBody: + return QVariant( + utils::replaceEmoji( + utils::linkifyMessage(boost::apply_visitor( + [](const auto &e) -> QString { return eventFormattedBody(e); }, event))) + .remove("") + .remove("")); + case Url: + return QVariant(boost::apply_visitor( + [](const auto &e) -> QString { return eventUrl(e); }, event)); + case ThumbnailUrl: + return QVariant(boost::apply_visitor( + [](const auto &e) -> QString { return eventThumbnailUrl(e); }, event)); + case Filename: + return QVariant(boost::apply_visitor( + [](const auto &e) -> QString { return eventFilename(e); }, event)); + case Filesize: + return QVariant(boost::apply_visitor( + [](const auto &e) -> QString { + return utils::humanReadableFileSize(eventFilesize(e)); + }, + event)); + case MimeType: + return QVariant(boost::apply_visitor( + [](const auto &e) -> QString { return eventMimeType(e); }, event)); + case Height: + return QVariant(boost::apply_visitor( + [](const auto &e) -> qulonglong { return eventHeight(e); }, event)); + case Width: + return QVariant(boost::apply_visitor( + [](const auto &e) -> qulonglong { return eventWidth(e); }, event)); + case ProportionalHeight: + return QVariant(boost::apply_visitor( + [](const auto &e) -> double { return eventPropHeight(e); }, event)); + case Id: + return id; + case State: + // only show read receipts for messages not from us + if (boost::apply_visitor([](const auto &e) -> QString { return senderId(e); }, + event) + .toStdString() != http::client()->user_id().to_string()) + return qml_mtx_events::Empty; + else if (failed.contains(id)) + return qml_mtx_events::Failed; + else if (pending.contains(id)) + return qml_mtx_events::Sent; + else if (read.contains(id) || + cache::client()->readReceipts(id, room_id_).size() > 1) + return qml_mtx_events::Read; + else + return qml_mtx_events::Received; + case IsEncrypted: { + auto tempEvent = events[id]; + return boost::get>( + &tempEvent) != nullptr; + } + case ReplyTo: { + QString evId = boost::apply_visitor( + [](const auto &e) -> QString { return eventRelatesTo(e); }, event); + return QVariant(evId); + } + default: + return QVariant(); + } +} + +void +TimelineModel::addEvents(const mtx::responses::Timeline &timeline) +{ + if (isInitialSync) { + prev_batch_token_ = QString::fromStdString(timeline.prev_batch); + isInitialSync = false; + } + + if (timeline.events.empty()) + return; + + std::vector ids = internalAddEvents(timeline.events); + + if (ids.empty()) + return; + + beginInsertRows(QModelIndex(), + static_cast(this->eventOrder.size()), + static_cast(this->eventOrder.size() + ids.size() - 1)); + this->eventOrder.insert(this->eventOrder.end(), ids.begin(), ids.end()); + endInsertRows(); + + updateLastMessage(); +} + +template +auto +isMessage(const mtx::events::RoomEvent &e) + -> std::enable_if_t::value, bool> +{ + return true; +} + +template +auto +isMessage(const mtx::events::Event &) +{ + return false; +} + +void +TimelineModel::updateLastMessage() +{ + for (auto it = eventOrder.rbegin(); it != eventOrder.rend(); ++it) { + auto event = events.value(*it); + if (auto e = boost::get>( + &event)) { + event = decryptEvent(*e).event; + } + + if (!boost::apply_visitor([](const auto &e) -> bool { return isMessage(e); }, + event)) + continue; + + auto description = utils::getMessageDescription( + event, QString::fromStdString(http::client()->user_id().to_string()), room_id_); + emit manager_->updateRoomsLastMessage(room_id_, description); + return; + } +} + +std::vector +TimelineModel::internalAddEvents( + const std::vector &timeline) +{ + std::vector ids; + for (const auto &e : timeline) { + QString id = + boost::apply_visitor([](const auto &e) -> QString { return eventId(e); }, e); + + if (this->events.contains(id)) { + this->events.insert(id, e); + int idx = idToIndex(id); + emit dataChanged(index(idx, 0), index(idx, 0)); + continue; + } + + if (auto redaction = + boost::get>(&e)) { + QString redacts = QString::fromStdString(redaction->redacts); + auto redacted = std::find(eventOrder.begin(), eventOrder.end(), redacts); + + if (redacted != eventOrder.end()) { + auto redactedEvent = boost::apply_visitor( + [](const auto &ev) + -> mtx::events::RoomEvent { + mtx::events::RoomEvent + replacement = {}; + replacement.event_id = ev.event_id; + replacement.room_id = ev.room_id; + replacement.sender = ev.sender; + replacement.origin_server_ts = ev.origin_server_ts; + replacement.type = ev.type; + return replacement; + }, + e); + events.insert(redacts, redactedEvent); + + int row = (int)std::distance(eventOrder.begin(), redacted); + emit dataChanged(index(row, 0), index(row, 0)); + } + + continue; // don't insert redaction into timeline + } + + if (auto event = + boost::get>(&e)) { + auto temp = decryptEvent(*event).event; + auto encInfo = boost::apply_visitor( + [](const auto &ev) -> boost::optional { + return eventEncryptionInfo(ev); + }, + temp); + + if (encInfo) + emit newEncryptedImage(encInfo.value()); + } + + this->events.insert(id, e); + ids.push_back(id); + } + return ids; +} + +void +TimelineModel::fetchHistory() +{ + if (paginationInProgress) { + nhlog::ui()->warn("Already loading older messages"); + return; + } + + paginationInProgress = true; + mtx::http::MessagesOpts opts; + opts.room_id = room_id_.toStdString(); + opts.from = prev_batch_token_.toStdString(); + + nhlog::ui()->info("Paginationg room {}", opts.room_id); + + http::client()->messages( + opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) { + if (err) { + nhlog::net()->error("failed to call /messages ({}): {} - {}", + opts.room_id, + mtx::errors::to_string(err->matrix_error.errcode), + err->matrix_error.error); + paginationInProgress = false; + return; + } + + emit oldMessagesRetrieved(std::move(res)); + paginationInProgress = false; + }); +} + +void +TimelineModel::setCurrentIndex(int index) +{ + auto oldIndex = idToIndex(currentId); + currentId = indexToId(index); + emit currentIndexChanged(index); + + if (oldIndex < index && !pending.contains(currentId) && + ChatPage::instance()->isActiveWindow()) { + readEvent(currentId.toStdString()); + } +} + +void +TimelineModel::readEvent(const std::string &id) +{ + http::client()->read_event(room_id_.toStdString(), id, [this](mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to read_event ({}, {})", + room_id_.toStdString(), + currentId.toStdString()); + } + }); +} + +void +TimelineModel::addBackwardsEvents(const mtx::responses::Messages &msgs) +{ + std::vector ids = internalAddEvents(msgs.chunk); + + if (!ids.empty()) { + beginInsertRows(QModelIndex(), 0, static_cast(ids.size() - 1)); + this->eventOrder.insert(this->eventOrder.begin(), ids.rbegin(), ids.rend()); + endInsertRows(); + } + + prev_batch_token_ = QString::fromStdString(msgs.end); +} + +QColor +TimelineModel::userColor(QString id, QColor background) +{ + if (!userColors.contains(id)) + userColors.insert( + id, QColor(utils::generateContrastingHexColor(id, background.name()))); + return userColors.value(id); +} + +QString +TimelineModel::displayName(QString id) const +{ + return Cache::displayName(room_id_, id); +} + +QString +TimelineModel::avatarUrl(QString id) const +{ + return Cache::avatarUrl(room_id_, id); +} + +QString +TimelineModel::formatDateSeparator(QDate date) const +{ + auto now = QDateTime::currentDateTime(); + + QString fmt = QLocale::system().dateFormat(QLocale::LongFormat); + + if (now.date().year() == date.year()) { + QRegularExpression rx("[^a-zA-Z]*y+[^a-zA-Z]*"); + fmt = fmt.remove(rx); + } + + return date.toString(fmt); +} + +QString +TimelineModel::escapeEmoji(QString str) const +{ + return utils::replaceEmoji(str); +} + +void +TimelineModel::viewRawMessage(QString id) const +{ + std::string ev = utils::serialize_event(events.value(id)).dump(4); + auto dialog = new dialogs::RawMessage(QString::fromStdString(ev)); + Q_UNUSED(dialog); +} + +void + +TimelineModel::openUserProfile(QString userid) const +{ + MainWindow::instance()->openUserProfile(userid, room_id_); +} + +DecryptionResult +TimelineModel::decryptEvent(const mtx::events::EncryptedEvent &e) const +{ + MegolmSessionIndex index; + index.room_id = room_id_.toStdString(); + index.session_id = e.content.session_id; + index.sender_key = e.content.sender_key; + + mtx::events::RoomEvent dummy; + dummy.origin_server_ts = e.origin_server_ts; + dummy.event_id = e.event_id; + dummy.sender = e.sender; + dummy.content.body = + tr("-- Encrypted Event (No keys found for decryption) --", + "Placeholder, when the message was not decrypted yet or can't be decrypted") + .toStdString(); + + try { + if (!cache::client()->inboundMegolmSessionExists(index)) { + nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})", + index.room_id, + index.session_id, + e.sender); + // TODO: request megolm session_id & session_key from the sender. + return {dummy, false}; + } + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to check megolm session's existence: {}", e.what()); + dummy.content.body = tr("-- Decryption Error (failed to communicate with DB) --", + "Placeholder, when the message can't be decrypted, because " + "the DB access failed when trying to lookup the session.") + .toStdString(); + return {dummy, false}; + } + + std::string msg_str; + try { + auto session = cache::client()->getInboundMegolmSession(index); + auto res = olm::client()->decrypt_group_message(session, e.content.ciphertext); + msg_str = std::string((char *)res.data.data(), res.data.size()); + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to retrieve megolm session with index ({}, {}, {})", + index.room_id, + index.session_id, + index.sender_key, + e.what()); + dummy.content.body = + tr("-- Decryption Error (failed to retrieve megolm keys from db) --", + "Placeholder, when the message can't be decrypted, because the DB access " + "failed.") + .toStdString(); + return {dummy, false}; + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->critical("failed to decrypt message with index ({}, {}, {}): {}", + index.room_id, + index.session_id, + index.sender_key, + e.what()); + dummy.content.body = + tr("-- Decryption Error (%1) --", + "Placeholder, when the message can't be decrypted. In this case, the Olm " + "decrytion returned an error, which is passed ad %1") + .arg(e.what()) + .toStdString(); + return {dummy, false}; + } + + // Add missing fields for the event. + json body = json::parse(msg_str); + body["event_id"] = e.event_id; + body["sender"] = e.sender; + body["origin_server_ts"] = e.origin_server_ts; + body["unsigned"] = e.unsigned_data; + + json event_array = json::array(); + event_array.push_back(body); + + std::vector temp_events; + mtx::responses::utils::parse_timeline_events(event_array, temp_events); + + if (temp_events.size() == 1) + return {temp_events.at(0), true}; + + dummy.content.body = + tr("-- Encrypted Event (Unknown event type) --", + "Placeholder, when the message was decrypted, but we couldn't parse it, because " + "Nheko/mtxclient don't support that event type yet") + .toStdString(); + return {dummy, false}; +} + +void +TimelineModel::replyAction(QString id) +{ + auto event = events.value(id); + if (auto e = boost::get>(&event)) { + event = decryptEvent(*e).event; + } + + RelatedInfo related = boost::apply_visitor( + [](const auto &ev) -> RelatedInfo { + RelatedInfo related_ = {}; + related_.quoted_user = QString::fromStdString(ev.sender); + related_.related_event = ev.event_id; + return related_; + }, + event); + related.type = mtx::events::getMessageType(boost::apply_visitor( + [](const auto &e) -> std::string { return eventMsgType(e); }, event)); + related.quoted_body = boost::apply_visitor( + [](const auto &e) -> QString { return eventFormattedBody(e); }, event); + related.quoted_body.remove(QRegularExpression( + ".*", QRegularExpression::DotMatchesEverythingOption)); + nhlog::ui()->debug("after replacement: {}", related.quoted_body.toStdString()); + related.room = room_id_; + + if (related.quoted_body.isEmpty()) + return; + + ChatPage::instance()->messageReply(related); +} + +void +TimelineModel::readReceiptsAction(QString id) const +{ + MainWindow::instance()->openReadReceiptsDialog(id); +} + +void +TimelineModel::redactEvent(QString id) +{ + if (!id.isEmpty()) + http::client()->redact_event( + room_id_.toStdString(), + id.toStdString(), + [this, id](const mtx::responses::EventId &, mtx::http::RequestErr err) { + if (err) { + emit redactionFailed( + tr("Message redaction failed: %1") + .arg(QString::fromStdString(err->matrix_error.error))); + return; + } + + emit eventRedacted(id); + }); +} + +int +TimelineModel::idToIndex(QString id) const +{ + if (id.isEmpty()) + return -1; + for (int i = 0; i < (int)eventOrder.size(); i++) + if (id == eventOrder[i]) + return i; + return -1; +} + +QString +TimelineModel::indexToId(int index) const +{ + if (index < 0 || index >= (int)eventOrder.size()) + return ""; + return eventOrder[index]; +} + +// Note: this will only be called for our messages +void +TimelineModel::markEventsAsRead(const std::vector &event_ids) +{ + for (const auto &id : event_ids) { + read.insert(id); + int idx = idToIndex(id); + if (idx < 0) { + nhlog::ui()->warn("Read index out of range"); + return; + } + emit dataChanged(index(idx, 0), index(idx, 0)); + } +} + +void +TimelineModel::sendEncryptedMessage(const std::string &txn_id, nlohmann::json content) +{ + const auto room_id = room_id_.toStdString(); + + using namespace mtx::events; + using namespace mtx::identifiers; + + json doc{{"type", "m.room.message"}, {"content", content}, {"room_id", room_id}}; + + try { + // Check if we have already an outbound megolm session then we can use. + if (cache::client()->outboundMegolmSessionExists(room_id)) { + auto data = olm::encrypt_group_message( + room_id, http::client()->device_id(), doc.dump()); + + http::client()->send_room_message( + room_id, + txn_id, + data, + [this, txn_id](const mtx::responses::EventId &res, + mtx::http::RequestErr err) { + if (err) { + const int status_code = + static_cast(err->status_code); + nhlog::net()->warn("[{}] failed to send message: {} {}", + txn_id, + err->matrix_error.error, + status_code); + emit messageFailed(QString::fromStdString(txn_id)); + } + emit messageSent( + QString::fromStdString(txn_id), + QString::fromStdString(res.event_id.to_string())); + }); + return; + } + + nhlog::ui()->debug("creating new outbound megolm session"); + + // Create a new outbound megolm session. + auto outbound_session = olm::client()->init_outbound_group_session(); + const auto session_id = mtx::crypto::session_id(outbound_session.get()); + const auto session_key = mtx::crypto::session_key(outbound_session.get()); + + // TODO: needs to be moved in the lib. + auto megolm_payload = json{{"algorithm", "m.megolm.v1.aes-sha2"}, + {"room_id", room_id}, + {"session_id", session_id}, + {"session_key", session_key}}; + + // Saving the new megolm session. + // TODO: Maybe it's too early to save. + OutboundGroupSessionData session_data; + session_data.session_id = session_id; + session_data.session_key = session_key; + session_data.message_index = 0; // TODO Update me + cache::client()->saveOutboundMegolmSession( + room_id, session_data, std::move(outbound_session)); + + const auto members = cache::client()->roomMembers(room_id); + nhlog::ui()->info("retrieved {} members for {}", members.size(), room_id); + + auto keeper = + std::make_shared([megolm_payload, room_id, doc, txn_id, this]() { + try { + auto data = olm::encrypt_group_message( + room_id, http::client()->device_id(), doc.dump()); + + http::client() + ->send_room_message( + room_id, + txn_id, + data, + [this, txn_id](const mtx::responses::EventId &res, + mtx::http::RequestErr err) { + if (err) { + const int status_code = + static_cast(err->status_code); + nhlog::net()->warn( + "[{}] failed to send message: {} {}", + txn_id, + err->matrix_error.error, + status_code); + emit messageFailed( + QString::fromStdString(txn_id)); + } + emit messageSent( + QString::fromStdString(txn_id), + QString::fromStdString(res.event_id.to_string())); + }); + } catch (const lmdb::error &e) { + nhlog::db()->critical( + "failed to save megolm outbound session: {}", e.what()); + emit messageFailed(QString::fromStdString(txn_id)); + } + }); + + mtx::requests::QueryKeys req; + for (const auto &member : members) + req.device_keys[member] = {}; + + http::client()->query_keys( + req, + [keeper = std::move(keeper), megolm_payload, txn_id, this]( + const mtx::responses::QueryKeys &res, mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to query device keys: {} {}", + err->matrix_error.error, + static_cast(err->status_code)); + // TODO: Mark the event as failed. Communicate with the UI. + emit messageFailed(QString::fromStdString(txn_id)); + return; + } + + for (const auto &user : res.device_keys) { + // Mapping from a device_id with valid identity keys to the + // generated room_key event used for sharing the megolm session. + std::map room_key_msgs; + std::map deviceKeys; + + room_key_msgs.clear(); + deviceKeys.clear(); + + for (const auto &dev : user.second) { + const auto user_id = ::UserId(dev.second.user_id); + const auto device_id = DeviceId(dev.second.device_id); + + const auto device_keys = dev.second.keys; + const auto curveKey = "curve25519:" + device_id.get(); + const auto edKey = "ed25519:" + device_id.get(); + + if ((device_keys.find(curveKey) == device_keys.end()) || + (device_keys.find(edKey) == device_keys.end())) { + nhlog::net()->debug( + "ignoring malformed keys for device {}", + device_id.get()); + continue; + } + + DevicePublicKeys pks; + pks.ed25519 = device_keys.at(edKey); + pks.curve25519 = device_keys.at(curveKey); + + try { + if (!mtx::crypto::verify_identity_signature( + json(dev.second), device_id, user_id)) { + nhlog::crypto()->warn( + "failed to verify identity keys: {}", + json(dev.second).dump(2)); + continue; + } + } catch (const json::exception &e) { + nhlog::crypto()->warn( + "failed to parse device key json: {}", + e.what()); + continue; + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->warn( + "failed to verify device key json: {}", + e.what()); + continue; + } + + auto room_key = olm::client() + ->create_room_key_event( + user_id, pks.ed25519, megolm_payload) + .dump(); + + room_key_msgs.emplace(device_id, room_key); + deviceKeys.emplace(device_id, pks); + } + + std::vector valid_devices; + valid_devices.reserve(room_key_msgs.size()); + for (auto const &d : room_key_msgs) { + valid_devices.push_back(d.first); + + nhlog::net()->info("{}", d.first); + nhlog::net()->info(" curve25519 {}", + deviceKeys.at(d.first).curve25519); + nhlog::net()->info(" ed25519 {}", + deviceKeys.at(d.first).ed25519); + } + + nhlog::net()->info( + "sending claim request for user {} with {} devices", + user.first, + valid_devices.size()); + + http::client()->claim_keys( + user.first, + valid_devices, + std::bind(&TimelineModel::handleClaimedKeys, + this, + keeper, + room_key_msgs, + deviceKeys, + user.first, + std::placeholders::_1, + std::placeholders::_2)); + + // TODO: Wait before sending the next batch of requests. + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + }); + + // TODO: Let the user know about the errors. + } catch (const lmdb::error &e) { + nhlog::db()->critical( + "failed to open outbound megolm session ({}): {}", room_id, e.what()); + emit messageFailed(QString::fromStdString(txn_id)); + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->critical( + "failed to open outbound megolm session ({}): {}", room_id, e.what()); + emit messageFailed(QString::fromStdString(txn_id)); + } +} + +void +TimelineModel::handleClaimedKeys(std::shared_ptr keeper, + const std::map &room_keys, + const std::map &pks, + const std::string &user_id, + const mtx::responses::ClaimKeys &res, + mtx::http::RequestErr err) +{ + if (err) { + nhlog::net()->warn("claim keys error: {} {} {}", + err->matrix_error.error, + err->parse_error, + static_cast(err->status_code)); + return; + } + + nhlog::net()->debug("claimed keys for {}", user_id); + + if (res.one_time_keys.size() == 0) { + nhlog::net()->debug("no one-time keys found for user_id: {}", user_id); + return; + } + + if (res.one_time_keys.find(user_id) == res.one_time_keys.end()) { + nhlog::net()->debug("no one-time keys found for user_id: {}", user_id); + return; + } + + auto retrieved_devices = res.one_time_keys.at(user_id); + + // Payload with all the to_device message to be sent. + json body; + body["messages"][user_id] = json::object(); + + for (const auto &rd : retrieved_devices) { + const auto device_id = rd.first; + nhlog::net()->debug("{} : \n {}", device_id, rd.second.dump(2)); + + // TODO: Verify signatures + auto otk = rd.second.begin()->at("key"); + + if (pks.find(device_id) == pks.end()) { + nhlog::net()->critical("couldn't find public key for device: {}", + device_id); + continue; + } + + auto id_key = pks.at(device_id).curve25519; + auto s = olm::client()->create_outbound_session(id_key, otk); + + if (room_keys.find(device_id) == room_keys.end()) { + nhlog::net()->critical("couldn't find m.room_key for device: {}", + device_id); + continue; + } + + auto device_msg = olm::client()->create_olm_encrypted_content( + s.get(), room_keys.at(device_id), pks.at(device_id).curve25519); + + try { + cache::client()->saveOlmSession(id_key, std::move(s)); + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to save outbound olm session: {}", e.what()); + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->critical("failed to pickle outbound olm session: {}", + e.what()); + } + + body["messages"][user_id][device_id] = device_msg; + } + + nhlog::net()->info("send_to_device: {}", user_id); + + http::client()->send_to_device( + "m.room.encrypted", body, [keeper](mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to send " + "send_to_device " + "message: {}", + err->matrix_error.error); + } + + (void)keeper; + }); +} + +struct SendMessageVisitor +{ + SendMessageVisitor(const QString &txn_id, TimelineModel *model) + : txn_id_qstr_(txn_id) + , model_(model) + {} + + template + void operator()(const mtx::events::Event &) + {} + + template::value, int> = 0> + void operator()(const mtx::events::RoomEvent &msg) + + { + if (cache::client()->isRoomEncrypted(model_->room_id_.toStdString())) { + model_->sendEncryptedMessage(txn_id_qstr_.toStdString(), + nlohmann::json(msg.content)); + } else { + QString txn_id_qstr = txn_id_qstr_; + TimelineModel *model = model_; + http::client()->send_room_message( + model->room_id_.toStdString(), + txn_id_qstr.toStdString(), + msg.content, + [txn_id_qstr, model](const mtx::responses::EventId &res, + mtx::http::RequestErr err) { + if (err) { + const int status_code = + static_cast(err->status_code); + nhlog::net()->warn("[{}] failed to send message: {} {}", + txn_id_qstr.toStdString(), + err->matrix_error.error, + status_code); + emit model->messageFailed(txn_id_qstr); + } + emit model->messageSent( + txn_id_qstr, QString::fromStdString(res.event_id.to_string())); + }); + } + } + + QString txn_id_qstr_; + TimelineModel *model_; +}; + +void +TimelineModel::processOnePendingMessage() +{ + if (isProcessingPending || pending.isEmpty()) + return; + + isProcessingPending = true; + + QString txn_id_qstr = pending.first(); + + auto event = events.value(txn_id_qstr); + boost::apply_visitor(SendMessageVisitor{txn_id_qstr, this}, event); +} + +void +TimelineModel::addPendingMessage(mtx::events::collections::TimelineEvents event) +{ + internalAddEvents({event}); + + QString txn_id_qstr = + boost::apply_visitor([](const auto &e) -> QString { return eventId(e); }, event); + beginInsertRows(QModelIndex(), + static_cast(this->eventOrder.size()), + static_cast(this->eventOrder.size())); + pending.push_back(txn_id_qstr); + this->eventOrder.insert(this->eventOrder.end(), txn_id_qstr); + endInsertRows(); + updateLastMessage(); + + if (!isProcessingPending) + emit nextPendingMessage(); +} + +void +TimelineModel::saveMedia(QString eventId) const +{ + mtx::events::collections::TimelineEvents event = events.value(eventId); + + if (auto e = boost::get>(&event)) { + event = decryptEvent(*e).event; + } + + QString mxcUrl = + boost::apply_visitor([](const auto &e) -> QString { return eventUrl(e); }, event); + QString originalFilename = + boost::apply_visitor([](const auto &e) -> QString { return eventFilename(e); }, event); + QString mimeType = + boost::apply_visitor([](const auto &e) -> QString { return eventMimeType(e); }, event); + + using EncF = boost::optional; + EncF encryptionInfo = + boost::apply_visitor([](const auto &e) -> EncF { return eventEncryptionInfo(e); }, event); + + qml_mtx_events::EventType eventType = boost::apply_visitor( + [](const auto &e) -> qml_mtx_events::EventType { return toRoomEventType(e); }, event); + + QString dialogTitle; + if (eventType == qml_mtx_events::EventType::ImageMessage) { + dialogTitle = tr("Save image"); + } else if (eventType == qml_mtx_events::EventType::VideoMessage) { + dialogTitle = tr("Save video"); + } else if (eventType == qml_mtx_events::EventType::AudioMessage) { + dialogTitle = tr("Save audio"); + } else { + dialogTitle = tr("Save file"); + } + + QString filterString = QMimeDatabase().mimeTypeForName(mimeType).filterString(); + + auto filename = QFileDialog::getSaveFileName( + manager_->getWidget(), dialogTitle, originalFilename, filterString); + + if (filename.isEmpty()) + return; + + const auto url = mxcUrl.toStdString(); + + http::client()->download( + url, + [filename, url, encryptionInfo](const std::string &data, + const std::string &, + const std::string &, + mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to retrieve image {}: {} {}", + url, + err->matrix_error.error, + static_cast(err->status_code)); + return; + } + + try { + auto temp = data; + if (encryptionInfo) + temp = mtx::crypto::to_string( + mtx::crypto::decrypt_file(temp, encryptionInfo.value())); + + QFile file(filename); + + if (!file.open(QIODevice::WriteOnly)) + return; + + file.write(QByteArray(temp.data(), (int)temp.size())); + file.close(); + } catch (const std::exception &e) { + nhlog::ui()->warn("Error while saving file to: {}", e.what()); + } + }); +} + +void +TimelineModel::cacheMedia(QString eventId) +{ + mtx::events::collections::TimelineEvents event = events.value(eventId); + + if (auto e = boost::get>(&event)) { + event = decryptEvent(*e).event; + } + + QString mxcUrl = + boost::apply_visitor([](const auto &e) -> QString { return eventUrl(e); }, event); + QString mimeType = + boost::apply_visitor([](const auto &e) -> QString { return eventMimeType(e); }, event); + + using EncF = boost::optional; + EncF encryptionInfo = + boost::apply_visitor([](const auto &e) -> EncF { return eventEncryptionInfo(e); }, event); + + // If the message is a link to a non mxcUrl, don't download it + if (!mxcUrl.startsWith("mxc://")) { + emit mediaCached(mxcUrl, mxcUrl); + return; + } + + QString suffix = QMimeDatabase().mimeTypeForName(mimeType).preferredSuffix(); + + const auto url = mxcUrl.toStdString(); + QFileInfo filename(QString("%1/media_cache/%2.%3") + .arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)) + .arg(QString(mxcUrl).remove("mxc://")) + .arg(suffix)); + if (QDir::cleanPath(filename.path()) != filename.path()) { + nhlog::net()->warn("mxcUrl '{}' is not safe, not downloading file", url); + return; + } + + QDir().mkpath(filename.path()); + + if (filename.isReadable()) { + emit mediaCached(mxcUrl, filename.filePath()); + return; + } + + http::client()->download( + url, + [this, mxcUrl, filename, url, encryptionInfo](const std::string &data, + const std::string &, + const std::string &, + mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to retrieve image {}: {} {}", + url, + err->matrix_error.error, + static_cast(err->status_code)); + return; + } + + try { + auto temp = data; + if (encryptionInfo) + temp = mtx::crypto::to_string( + mtx::crypto::decrypt_file(temp, encryptionInfo.value())); + + QFile file(filename.filePath()); + + if (!file.open(QIODevice::WriteOnly)) + return; + + file.write(QByteArray(temp.data(), temp.size())); + file.close(); + } catch (const std::exception &e) { + nhlog::ui()->warn("Error while saving file to: {}", e.what()); + } + + emit mediaCached(mxcUrl, filename.filePath()); + }); +} diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h new file mode 100644 index 00000000..06c64acf --- /dev/null +++ b/src/timeline/TimelineModel.h @@ -0,0 +1,242 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "Cache.h" +#include "Logging.h" +#include "MatrixClient.h" + +namespace qml_mtx_events { +Q_NAMESPACE + +enum EventType +{ + // Unsupported event + Unsupported, + /// m.room_key_request + KeyRequest, + /// m.room.aliases + Aliases, + /// m.room.avatar + Avatar, + /// m.room.canonical_alias + CanonicalAlias, + /// m.room.create + Create, + /// m.room.encrypted. + Encrypted, + /// m.room.encryption. + Encryption, + /// m.room.guest_access + GuestAccess, + /// m.room.history_visibility + HistoryVisibility, + /// m.room.join_rules + JoinRules, + /// m.room.member + Member, + /// m.room.name + Name, + /// m.room.power_levels + PowerLevels, + /// m.room.tombstone + Tombstone, + /// m.room.topic + Topic, + /// m.room.redaction + Redaction, + /// m.room.pinned_events + PinnedEvents, + // m.sticker + Sticker, + // m.tag + Tag, + /// m.room.message + AudioMessage, + EmoteMessage, + FileMessage, + ImageMessage, + LocationMessage, + NoticeMessage, + TextMessage, + VideoMessage, + Redacted, + UnknownMessage, +}; +Q_ENUM_NS(EventType) + +enum EventState +{ + //! The plaintext message was received by the server. + Received, + //! At least one of the participants has read the message. + Read, + //! The client sent the message. Not yet received. + Sent, + //! When the message is loaded from cache or backfill. + Empty, + //! When the message failed to send + Failed, +}; +Q_ENUM_NS(EventState) +} + +class StateKeeper +{ +public: + StateKeeper(std::function &&fn) + : fn_(std::move(fn)) + {} + + ~StateKeeper() { fn_(); } + +private: + std::function fn_; +}; + +struct DecryptionResult +{ + //! The decrypted content as a normal plaintext event. + mtx::events::collections::TimelineEvents event; + //! Whether or not the decryption was successful. + bool isDecrypted = false; +}; + +class TimelineViewManager; + +class TimelineModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY( + int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged) + +public: + explicit TimelineModel(TimelineViewManager *manager, QString room_id, QObject *parent = 0); + + enum Roles + { + Section, + Type, + Body, + FormattedBody, + UserId, + UserName, + Timestamp, + Url, + ThumbnailUrl, + Filename, + Filesize, + MimeType, + Height, + Width, + ProportionalHeight, + Id, + State, + IsEncrypted, + ReplyTo, + }; + + QHash roleNames() const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + Q_INVOKABLE QColor userColor(QString id, QColor background); + Q_INVOKABLE QString displayName(QString id) const; + Q_INVOKABLE QString avatarUrl(QString id) const; + Q_INVOKABLE QString formatDateSeparator(QDate date) const; + + Q_INVOKABLE QString escapeEmoji(QString str) const; + Q_INVOKABLE void viewRawMessage(QString id) const; + Q_INVOKABLE void openUserProfile(QString userid) const; + Q_INVOKABLE void replyAction(QString id); + Q_INVOKABLE void readReceiptsAction(QString id) const; + Q_INVOKABLE void redactEvent(QString id); + Q_INVOKABLE int idToIndex(QString id) const; + Q_INVOKABLE QString indexToId(int index) const; + Q_INVOKABLE void cacheMedia(QString eventId); + Q_INVOKABLE void saveMedia(QString eventId) const; + + void addEvents(const mtx::responses::Timeline &events); + template + void sendMessage(const T &msg); + +public slots: + void fetchHistory(); + void setCurrentIndex(int index); + int currentIndex() const { return idToIndex(currentId); } + void markEventsAsRead(const std::vector &event_ids); + +private slots: + // Add old events at the top of the timeline. + void addBackwardsEvents(const mtx::responses::Messages &msgs); + void processOnePendingMessage(); + void addPendingMessage(mtx::events::collections::TimelineEvents event); + +signals: + void oldMessagesRetrieved(const mtx::responses::Messages &res); + void messageFailed(QString txn_id); + void messageSent(QString txn_id, QString event_id); + void currentIndexChanged(int index); + void redactionFailed(QString id); + void eventRedacted(QString id); + void nextPendingMessage(); + void newMessageToSend(mtx::events::collections::TimelineEvents event); + void mediaCached(QString mxcUrl, QString cacheUrl); + void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo); + +private: + DecryptionResult decryptEvent( + const mtx::events::EncryptedEvent &e) const; + std::vector internalAddEvents( + const std::vector &timeline); + void sendEncryptedMessage(const std::string &txn_id, nlohmann::json content); + void handleClaimedKeys(std::shared_ptr keeper, + const std::map &room_key, + const std::map &pks, + const std::string &user_id, + const mtx::responses::ClaimKeys &res, + mtx::http::RequestErr err); + void updateLastMessage(); + void readEvent(const std::string &id); + + QHash events; + QSet failed, read; + QList pending; + std::vector eventOrder; + + QString room_id_; + QString prev_batch_token_; + + bool isInitialSync = true; + bool paginationInProgress = false; + bool isProcessingPending = false; + + QHash userColors; + QString currentId; + + TimelineViewManager *manager_; + + friend struct SendMessageVisitor; +}; + +template +void +TimelineModel::sendMessage(const T &msg) +{ + auto txn_id = http::client()->generate_txn_id(); + mtx::events::RoomEvent msgCopy = {}; + msgCopy.content = msg; + msgCopy.type = mtx::events::EventType::RoomMessage; + msgCopy.event_id = txn_id; + msgCopy.sender = http::client()->user_id().to_string(); + msgCopy.origin_server_ts = QDateTime::currentMSecsSinceEpoch(); + + emit newMessageToSend(msgCopy); +} diff --git a/src/timeline/TimelineView.cpp b/src/timeline/TimelineView.cpp deleted file mode 100644 index ed783e90..00000000 --- a/src/timeline/TimelineView.cpp +++ /dev/null @@ -1,1627 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include - -#include -#include -#include -#include - -#include "Cache.h" -#include "ChatPage.h" -#include "Config.h" -#include "Logging.h" -#include "Olm.h" -#include "UserSettingsPage.h" -#include "Utils.h" -#include "ui/FloatingButton.h" -#include "ui/InfoMessage.h" - -#include "timeline/TimelineView.h" -#include "timeline/widgets/AudioItem.h" -#include "timeline/widgets/FileItem.h" -#include "timeline/widgets/ImageItem.h" -#include "timeline/widgets/VideoItem.h" - -using TimelineEvent = mtx::events::collections::TimelineEvents; - -//! Maximum number of widgets to keep in the timeline layout. -constexpr int MAX_RETAINED_WIDGETS = 100; -constexpr int MIN_SCROLLBAR_HANDLE = 60; - -//! Retrieve the timestamp of the event represented by the given widget. -QDateTime -getDate(QWidget *widget) -{ - auto item = qobject_cast(widget); - if (item) - return item->descriptionMessage().datetime; - - auto infoMsg = qobject_cast(widget); - if (infoMsg) - return infoMsg->datetime(); - - return QDateTime(); -} - -TimelineView::TimelineView(const mtx::responses::Timeline &timeline, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - init(); - addEvents(timeline); -} - -TimelineView::TimelineView(const QString &room_id, QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - init(); - getMessages(); -} - -void -TimelineView::sliderRangeChanged(int min, int max) -{ - Q_UNUSED(min); - - if (!scroll_area_->verticalScrollBar()->isVisible()) { - scroll_area_->verticalScrollBar()->setValue(max); - return; - } - - // If the scrollbar is close to the bottom and a new message - // is added we move the scrollbar. - if (max - scroll_area_->verticalScrollBar()->value() < SCROLL_BAR_GAP) { - scroll_area_->verticalScrollBar()->setValue(max); - return; - } - - int currentHeight = scroll_widget_->size().height(); - int diff = currentHeight - oldHeight_; - int newPosition = oldPosition_ + diff; - - // Keep the scroll bar to the bottom if it hasn't been activated yet. - if (oldPosition_ == 0 && !scroll_area_->verticalScrollBar()->isVisible()) - newPosition = max; - - if (lastMessageDirection_ == TimelineDirection::Top) - scroll_area_->verticalScrollBar()->setValue(newPosition); -} - -void -TimelineView::fetchHistory() -{ - if (!isScrollbarActivated() && !isTimelineFinished) { - if (!isVisible()) - return; - - isPaginationInProgress_ = true; - getMessages(); - paginationTimer_->start(2000); - - return; - } - - paginationTimer_->stop(); -} - -void -TimelineView::scrollDown() -{ - int current = scroll_area_->verticalScrollBar()->value(); - int max = scroll_area_->verticalScrollBar()->maximum(); - - // The first time we enter the room move the scroll bar to the bottom. - if (!isInitialized) { - scroll_area_->verticalScrollBar()->setValue(max); - isInitialized = true; - return; - } - - // If the gap is small enough move the scroll bar down. e.g when a new - // message appears. - if (max - current < SCROLL_BAR_GAP) - scroll_area_->verticalScrollBar()->setValue(max); -} - -void -TimelineView::sliderMoved(int position) -{ - if (!scroll_area_->verticalScrollBar()->isVisible()) - return; - - toggleScrollDownButton(); - - // The scrollbar is high enough so we can start retrieving old events. - if (position < SCROLL_BAR_GAP) { - if (isTimelineFinished) - return; - - // Prevent user from moving up when there is pagination in - // progress. - if (isPaginationInProgress_) - return; - - isPaginationInProgress_ = true; - - getMessages(); - } -} - -bool -TimelineView::isStartOfTimeline(const mtx::responses::Messages &msgs) -{ - return (msgs.chunk.size() == 0 && (msgs.end.empty() || msgs.end == msgs.start)); -} - -void -TimelineView::addBackwardsEvents(const mtx::responses::Messages &msgs) -{ - // We've reached the start of the timline and there're no more messages. - if (isStartOfTimeline(msgs)) { - nhlog::ui()->info("[{}] start of timeline reached, no more messages to fetch", - room_id_.toStdString()); - isTimelineFinished = true; - return; - } - - isTimelineFinished = false; - - // Queue incoming messages to be rendered later. - topMessages_.insert(topMessages_.end(), - std::make_move_iterator(msgs.chunk.begin()), - std::make_move_iterator(msgs.chunk.end())); - - // The RoomList message preview will be updated only if this - // is the first batch of messages received through /messages - // i.e there are no other messages currently present. - if (!topMessages_.empty() && scroll_layout_->count() == 0) - notifyForLastEvent(findFirstViewableEvent(topMessages_)); - - if (isVisible()) { - renderTopEvents(topMessages_); - - // Free up space for new messages. - topMessages_.clear(); - - // Send a read receipt for the last event. - if (isActiveWindow()) - readLastEvent(); - } - - prev_batch_token_ = QString::fromStdString(msgs.end); - isPaginationInProgress_ = false; -} - -QWidget * -TimelineView::parseMessageEvent(const mtx::events::collections::TimelineEvents &event, - TimelineDirection direction) -{ - using namespace mtx::events; - - using AudioEvent = RoomEvent; - using EmoteEvent = RoomEvent; - using FileEvent = RoomEvent; - using ImageEvent = RoomEvent; - using NoticeEvent = RoomEvent; - using TextEvent = RoomEvent; - using VideoEvent = RoomEvent; - - if (boost::get>(&event) != nullptr) { - auto redaction_event = boost::get>(event); - const auto event_id = QString::fromStdString(redaction_event.redacts); - - QTimer::singleShot(0, this, [event_id, this]() { - if (eventIds_.contains(event_id)) - removeEvent(event_id); - }); - - return nullptr; - } else if (boost::get>(&event) != nullptr) { - auto msg = boost::get>(event); - auto event_id = QString::fromStdString(msg.event_id); - - if (eventIds_.contains(event_id)) - return nullptr; - - auto item = new InfoMessage(tr("Encryption is enabled"), this); - item->saveDatetime(QDateTime::fromMSecsSinceEpoch(msg.origin_server_ts)); - eventIds_[event_id] = item; - - // Force the next message to have avatar by not providing the current username. - saveMessageInfo("", msg.origin_server_ts, direction); - - return item; - } else if (boost::get>(&event) != nullptr) { - auto audio = boost::get>(event); - return processMessageEvent(audio, direction); - } else if (boost::get>(&event) != nullptr) { - auto emote = boost::get>(event); - return processMessageEvent(emote, direction); - } else if (boost::get>(&event) != nullptr) { - auto file = boost::get>(event); - return processMessageEvent(file, direction); - } else if (boost::get>(&event) != nullptr) { - auto image = boost::get>(event); - return processMessageEvent(image, direction); - } else if (boost::get>(&event) != nullptr) { - auto notice = boost::get>(event); - return processMessageEvent(notice, direction); - } else if (boost::get>(&event) != nullptr) { - auto text = boost::get>(event); - return processMessageEvent(text, direction); - } else if (boost::get>(&event) != nullptr) { - auto video = boost::get>(event); - return processMessageEvent(video, direction); - } else if (boost::get(&event) != nullptr) { - return processMessageEvent(boost::get(event), - direction); - } else if (boost::get>(&event) != nullptr) { - auto res = parseEncryptedEvent(boost::get>(event)); - auto widget = parseMessageEvent(res.event, direction); - - if (widget == nullptr) - return nullptr; - - auto item = qobject_cast(widget); - - if (item && res.isDecrypted) - item->markReceived(true); - else if (item && !res.isDecrypted) - item->addKeyRequestAction(); - - return widget; - } - - return nullptr; -} - -DecryptionResult -TimelineView::parseEncryptedEvent(const mtx::events::EncryptedEvent &e) -{ - MegolmSessionIndex index; - index.room_id = room_id_.toStdString(); - index.session_id = e.content.session_id; - index.sender_key = e.content.sender_key; - - mtx::events::RoomEvent dummy; - dummy.origin_server_ts = e.origin_server_ts; - dummy.event_id = e.event_id; - dummy.sender = e.sender; - dummy.content.body = - tr("-- Encrypted Event (No keys found for decryption) --", - "Placeholder, when the message was not decrypted yet or can't be decrypted") - .toStdString(); - - try { - if (!cache::client()->inboundMegolmSessionExists(index)) { - nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})", - index.room_id, - index.session_id, - e.sender); - // TODO: request megolm session_id & session_key from the sender. - return {dummy, false}; - } - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to check megolm session's existence: {}", e.what()); - dummy.content.body = tr("-- Decryption Error (failed to communicate with DB) --", - "Placeholder, when the message can't be decrypted, because " - "the DB access failed when trying to lookup the session.") - .toStdString(); - return {dummy, false}; - } - - std::string msg_str; - try { - auto session = cache::client()->getInboundMegolmSession(index); - auto res = olm::client()->decrypt_group_message(session, e.content.ciphertext); - msg_str = std::string((char *)res.data.data(), res.data.size()); - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to retrieve megolm session with index ({}, {}, {})", - index.room_id, - index.session_id, - index.sender_key, - e.what()); - dummy.content.body = - tr("-- Decryption Error (failed to retrieve megolm keys from db) --", - "Placeholder, when the message can't be decrypted, because the DB access " - "failed.") - .toStdString(); - return {dummy, false}; - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->critical("failed to decrypt message with index ({}, {}, {}): {}", - index.room_id, - index.session_id, - index.sender_key, - e.what()); - dummy.content.body = - tr("-- Decryption Error (%1) --", - "Placeholder, when the message can't be decrypted. In this case, the Olm " - "decrytion returned an error, which is passed ad %1") - .arg(e.what()) - .toStdString(); - return {dummy, false}; - } - - // Add missing fields for the event. - json body = json::parse(msg_str); - body["event_id"] = e.event_id; - body["sender"] = e.sender; - body["origin_server_ts"] = e.origin_server_ts; - body["unsigned"] = e.unsigned_data; - - nhlog::crypto()->debug("decrypted event: {}", e.event_id); - - json event_array = json::array(); - event_array.push_back(body); - - std::vector events; - mtx::responses::utils::parse_timeline_events(event_array, events); - - if (events.size() == 1) - return {events.at(0), true}; - - dummy.content.body = - tr("-- Encrypted Event (Unknown event type) --", - "Placeholder, when the message was decrypted, but we couldn't parse it, because " - "Nheko/mtxclient don't support that event type yet") - .toStdString(); - return {dummy, false}; -} - -void -TimelineView::displayReadReceipts(std::vector events) -{ - QtConcurrent::run( - [events = std::move(events), room_id = room_id_, local_user = local_user_, this]() { - std::vector event_ids; - - for (const auto &e : events) { - if (utils::event_sender(e) == local_user) - event_ids.emplace_back( - QString::fromStdString(utils::event_id(e))); - } - - auto readEvents = - cache::client()->filterReadEvents(room_id, event_ids, local_user.toStdString()); - - if (!readEvents.empty()) - emit markReadEvents(readEvents); - }); -} - -void -TimelineView::renderBottomEvents(const std::vector &events) -{ - int counter = 0; - - for (const auto &event : events) { - QWidget *item = parseMessageEvent(event, TimelineDirection::Bottom); - - if (item != nullptr) { - addTimelineItem(item, TimelineDirection::Bottom); - counter++; - - // Prevent blocking of the event-loop - // by calling processEvents every 10 items we render. - if (counter % 4 == 0) - QApplication::processEvents(); - } - } - - lastMessageDirection_ = TimelineDirection::Bottom; - - displayReadReceipts(events); - - QApplication::processEvents(); -} - -void -TimelineView::renderTopEvents(const std::vector &events) -{ - std::vector items; - - // Reset the sender of the first message in the timeline - // cause we're about to insert a new one. - firstSender_.clear(); - firstMsgTimestamp_ = QDateTime(); - - // Parse in reverse order to determine where we should not show sender's name. - for (auto it = events.rbegin(); it != events.rend(); ++it) { - auto item = parseMessageEvent(*it, TimelineDirection::Top); - - if (item != nullptr) - items.push_back(item); - } - - // Reverse again to render them. - std::reverse(items.begin(), items.end()); - - oldPosition_ = scroll_area_->verticalScrollBar()->value(); - oldHeight_ = scroll_widget_->size().height(); - - for (const auto &item : items) - addTimelineItem(item, TimelineDirection::Top); - - lastMessageDirection_ = TimelineDirection::Top; - - QApplication::processEvents(); - - displayReadReceipts(events); - - // If this batch is the first being rendered (i.e the first and the last - // events originate from this batch), set the last sender. - if (lastSender_.isEmpty() && !items.empty()) { - for (const auto &w : items) { - auto timelineItem = qobject_cast(w); - if (timelineItem) { - saveLastMessageInfo(timelineItem->descriptionMessage().userid, - timelineItem->descriptionMessage().datetime); - break; - } - } - } -} - -void -TimelineView::addEvents(const mtx::responses::Timeline &timeline) -{ - if (isInitialSync) { - prev_batch_token_ = QString::fromStdString(timeline.prev_batch); - isInitialSync = false; - } - - bottomMessages_.insert(bottomMessages_.end(), - std::make_move_iterator(timeline.events.begin()), - std::make_move_iterator(timeline.events.end())); - - if (!bottomMessages_.empty()) - notifyForLastEvent(findLastViewableEvent(bottomMessages_)); - - // If the current timeline is open and there are messages to be rendered. - if (isVisible() && !bottomMessages_.empty()) { - renderBottomEvents(bottomMessages_); - - // Free up space for new messages. - bottomMessages_.clear(); - - // Send a read receipt for the last event. - if (isActiveWindow()) - readLastEvent(); - } -} - -void -TimelineView::init() -{ - local_user_ = utils::localUser(); - - QIcon icon; - icon.addFile(":/icons/icons/ui/angle-arrow-down.png"); - scrollDownBtn_ = new FloatingButton(icon, this); - scrollDownBtn_->hide(); - - connect(scrollDownBtn_, &QPushButton::clicked, this, [this]() { - const int max = scroll_area_->verticalScrollBar()->maximum(); - scroll_area_->verticalScrollBar()->setValue(max); - }); - top_layout_ = new QVBoxLayout(this); - top_layout_->setSpacing(0); - top_layout_->setMargin(0); - - scroll_area_ = new QScrollArea(this); - scroll_area_->setWidgetResizable(true); - scroll_area_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - scroll_widget_ = new QWidget(this); - scroll_widget_->setObjectName("scroll_widget"); - - // Height of the typing display. - QFont f; - f.setPointSizeF(f.pointSizeF() * 0.9); - const int bottomMargin = QFontMetrics(f).height() + 6; - - scroll_layout_ = new QVBoxLayout(scroll_widget_); - scroll_layout_->setContentsMargins(4, 0, 15, bottomMargin); - scroll_layout_->setSpacing(0); - scroll_layout_->setObjectName("timelinescrollarea"); - - scroll_area_->setWidget(scroll_widget_); - scroll_area_->setAlignment(Qt::AlignBottom); - - top_layout_->addWidget(scroll_area_); - - setLayout(top_layout_); - - paginationTimer_ = new QTimer(this); - connect(paginationTimer_, &QTimer::timeout, this, &TimelineView::fetchHistory); - - connect(this, &TimelineView::messagesRetrieved, this, &TimelineView::addBackwardsEvents); - - connect(this, &TimelineView::messageFailed, this, &TimelineView::handleFailedMessage); - connect(this, &TimelineView::messageSent, this, &TimelineView::updatePendingMessage); - - connect( - this, &TimelineView::markReadEvents, this, [this](const std::vector &event_ids) { - for (const auto &event : event_ids) { - if (eventIds_.contains(event)) { - auto widget = eventIds_[event]; - if (!widget) - return; - - auto item = qobject_cast(widget); - if (!item) - return; - - item->markRead(); - } - } - }); - - connect(scroll_area_->verticalScrollBar(), - SIGNAL(valueChanged(int)), - this, - SLOT(sliderMoved(int))); - connect(scroll_area_->verticalScrollBar(), - SIGNAL(rangeChanged(int, int)), - this, - SLOT(sliderRangeChanged(int, int))); -} - -void -TimelineView::getMessages() -{ - mtx::http::MessagesOpts opts; - opts.room_id = room_id_.toStdString(); - opts.from = prev_batch_token_.toStdString(); - - http::client()->messages( - opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) { - if (err) { - nhlog::net()->error("failed to call /messages ({}): {} - {}", - opts.room_id, - mtx::errors::to_string(err->matrix_error.errcode), - err->matrix_error.error); - return; - } - - emit messagesRetrieved(std::move(res)); - }); -} - -void -TimelineView::updateLastSender(const QString &user_id, TimelineDirection direction) -{ - if (direction == TimelineDirection::Bottom) - lastSender_ = user_id; - else - firstSender_ = user_id; -} - -bool -TimelineView::isSenderRendered(const QString &user_id, - uint64_t origin_server_ts, - TimelineDirection direction) -{ - if (direction == TimelineDirection::Bottom) { - return (lastSender_ != user_id) || - isDateDifference(lastMsgTimestamp_, - QDateTime::fromMSecsSinceEpoch(origin_server_ts)); - } else { - return (firstSender_ != user_id) || - isDateDifference(firstMsgTimestamp_, - QDateTime::fromMSecsSinceEpoch(origin_server_ts)); - } -} - -void -TimelineView::addTimelineItem(QWidget *item, TimelineDirection direction) -{ - const auto newDate = getDate(item); - - if (direction == TimelineDirection::Bottom) { - QWidget *lastItem = nullptr; - int lastItemPosition = 0; - - if (scroll_layout_->count() > 0) { - lastItemPosition = scroll_layout_->count() - 1; - lastItem = scroll_layout_->itemAt(lastItemPosition)->widget(); - } - - if (lastItem) { - const auto oldDate = getDate(lastItem); - - if (oldDate.daysTo(newDate) != 0) { - auto separator = new DateSeparator(newDate, this); - - if (separator) - pushTimelineItem(separator, direction); - } - } - - pushTimelineItem(item, direction); - } else { - if (scroll_layout_->count() > 0) { - const auto firstItem = scroll_layout_->itemAt(0)->widget(); - - if (firstItem) { - const auto oldDate = getDate(firstItem); - - if (newDate.daysTo(oldDate) != 0) { - auto separator = new DateSeparator(oldDate); - - if (separator) - pushTimelineItem(separator, direction); - } - } - } - - pushTimelineItem(item, direction); - } -} - -void -TimelineView::updatePendingMessage(const std::string &txn_id, const QString &event_id) -{ - nhlog::ui()->debug("[{}] message was received by the server", txn_id); - if (!pending_msgs_.isEmpty() && - pending_msgs_.head().txn_id == txn_id) { // We haven't received it yet - auto msg = pending_msgs_.dequeue(); - msg.event_id = event_id; - - if (msg.widget) { - msg.widget->setEventId(event_id); - eventIds_[event_id] = msg.widget; - - // If the response comes after we have received the event from sync - // we've already marked the widget as received. - if (!msg.widget->isReceived()) { - msg.widget->markReceived(msg.is_encrypted); - cache::client()->addPendingReceipt(room_id_, event_id); - pending_sent_msgs_.append(msg); - } - } else { - nhlog::ui()->warn("[{}] received message response for invalid widget", - txn_id); - } - } - - sendNextPendingMessage(); -} - -void -TimelineView::addUserMessage(mtx::events::MessageType ty, - const QString &body, - const RelatedInfo &related = RelatedInfo()) -{ - auto with_sender = (lastSender_ != local_user_) || isDateDifference(lastMsgTimestamp_); - - QString full_body; - if (related.related_event.empty()) { - full_body = body; - } else { - full_body = utils::getFormattedQuoteBody(related, body); - } - TimelineItem *view_item = - new TimelineItem(ty, local_user_, full_body, with_sender, room_id_, scroll_widget_); - - PendingMessage message; - message.ty = ty; - message.txn_id = http::client()->generate_txn_id(); - message.body = body; - message.related = related; - message.widget = view_item; - - try { - message.is_encrypted = cache::client()->isRoomEncrypted(room_id_.toStdString()); - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to check encryption status of room {}", e.what()); - view_item->deleteLater(); - - // TODO: Send a notification to the user. - - return; - } - - addTimelineItem(view_item); - - lastMessageDirection_ = TimelineDirection::Bottom; - - saveLastMessageInfo(local_user_, QDateTime::currentDateTime()); - handleNewUserMessage(message); -} - -void -TimelineView::addUserMessage(mtx::events::MessageType ty, const QString &body) -{ - addUserMessage(ty, body, RelatedInfo()); -} - -void -TimelineView::handleNewUserMessage(PendingMessage msg) -{ - pending_msgs_.enqueue(msg); - if (pending_msgs_.size() == 1 && pending_sent_msgs_.isEmpty()) - sendNextPendingMessage(); -} - -void -TimelineView::sendNextPendingMessage() -{ - if (pending_msgs_.size() == 0) - return; - - using namespace mtx::events; - - PendingMessage &m = pending_msgs_.head(); - - nhlog::ui()->debug("[{}] sending next queued message", m.txn_id); - - if (m.widget) - m.widget->markSent(); - - if (m.is_encrypted) { - nhlog::ui()->debug("[{}] sending encrypted event", m.txn_id); - prepareEncryptedMessage(std::move(m)); - return; - } - - switch (m.ty) { - case mtx::events::MessageType::Audio: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::Image: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::Video: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::File: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::Text: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::Emote: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - break; - } - default: - nhlog::ui()->warn("cannot send unknown message type: {}", m.body.toStdString()); - break; - } -} - -void -TimelineView::notifyForLastEvent() -{ - if (scroll_layout_->count() == 0) { - nhlog::ui()->error("notifyForLastEvent called with empty timeline"); - return; - } - - auto lastItem = scroll_layout_->itemAt(scroll_layout_->count() - 1); - - if (!lastItem) - return; - - auto *lastTimelineItem = qobject_cast(lastItem->widget()); - - if (lastTimelineItem) - emit updateLastTimelineMessage(room_id_, lastTimelineItem->descriptionMessage()); - else - nhlog::ui()->warn("cast to TimelineItem failed: {}", room_id_.toStdString()); -} - -void -TimelineView::notifyForLastEvent(const TimelineEvent &event) -{ - auto descInfo = utils::getMessageDescription(event, local_user_, room_id_); - - if (!descInfo.timestamp.isEmpty()) - emit updateLastTimelineMessage(room_id_, descInfo); -} - -bool -TimelineView::isPendingMessage(const std::string &txn_id, - const QString &sender, - const QString &local_userid) -{ - if (sender != local_userid) - return false; - - auto match_txnid = [txn_id](const auto &msg) -> bool { return msg.txn_id == txn_id; }; - - return std::any_of(pending_msgs_.cbegin(), pending_msgs_.cend(), match_txnid) || - std::any_of(pending_sent_msgs_.cbegin(), pending_sent_msgs_.cend(), match_txnid); -} - -void -TimelineView::removePendingMessage(const std::string &txn_id) -{ - if (txn_id.empty()) - return; - - for (auto it = pending_sent_msgs_.begin(); it != pending_sent_msgs_.end(); ++it) { - if (it->txn_id == txn_id) { - int index = std::distance(pending_sent_msgs_.begin(), it); - pending_sent_msgs_.removeAt(index); - - if (pending_sent_msgs_.isEmpty()) - sendNextPendingMessage(); - - nhlog::ui()->debug("[{}] removed message with sync", txn_id); - } - } - for (auto it = pending_msgs_.begin(); it != pending_msgs_.end(); ++it) { - if (it->txn_id == txn_id) { - if (it->widget) { - it->widget->markReceived(it->is_encrypted); - - // TODO: update when a solution for encrypted messages is available. - if (!it->is_encrypted) - cache::client()->addPendingReceipt(room_id_, it->event_id); - } - - nhlog::ui()->debug("[{}] received sync before message response", txn_id); - return; - } - } -} - -void -TimelineView::handleFailedMessage(const std::string &txn_id) -{ - Q_UNUSED(txn_id); - // Note: We do this even if the message has already been echoed. - QTimer::singleShot(2000, this, SLOT(sendNextPendingMessage())); -} - -void -TimelineView::paintEvent(QPaintEvent *) -{ - QStyleOption opt; - opt.init(this); - QPainter p(this); - style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); -} - -void -TimelineView::readLastEvent() const -{ - if (!ChatPage::instance()->userSettings()->isReadReceiptsEnabled()) - return; - - const auto eventId = getLastEventId(); - - if (!eventId.isEmpty()) - http::client()->read_event(room_id_.toStdString(), - eventId.toStdString(), - [this, eventId](mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn( - "failed to read event ({}, {})", - room_id_.toStdString(), - eventId.toStdString()); - } - }); -} - -QString -TimelineView::getLastEventId() const -{ - auto index = scroll_layout_->count(); - - // Search backwards for the first event that has a valid event id. - while (index > 0) { - --index; - - auto lastItem = scroll_layout_->itemAt(index); - auto *lastTimelineItem = qobject_cast(lastItem->widget()); - - if (lastTimelineItem && !lastTimelineItem->eventId().isEmpty()) - return lastTimelineItem->eventId(); - } - - return QString(""); -} - -void -TimelineView::showEvent(QShowEvent *event) -{ - if (!topMessages_.empty()) { - renderTopEvents(topMessages_); - topMessages_.clear(); - } - - if (!bottomMessages_.empty()) { - renderBottomEvents(bottomMessages_); - bottomMessages_.clear(); - scrollDown(); - } - - toggleScrollDownButton(); - - readLastEvent(); - - QWidget::showEvent(event); -} - -void -TimelineView::hideEvent(QHideEvent *event) -{ - const auto handleHeight = scroll_area_->verticalScrollBar()->sizeHint().height(); - const auto widgetsNum = scroll_layout_->count(); - - // Remove widgets from the timeline to reduce the memory footprint. - if (handleHeight < MIN_SCROLLBAR_HANDLE && widgetsNum > MAX_RETAINED_WIDGETS) - clearTimeline(); - - QWidget::hideEvent(event); -} - -bool -TimelineView::event(QEvent *event) -{ - if (event->type() == QEvent::WindowActivate) - readLastEvent(); - - return QWidget::event(event); -} - -void -TimelineView::clearTimeline() -{ - // Delete all widgets. - QLayoutItem *item; - while ((item = scroll_layout_->takeAt(0)) != nullptr) { - delete item->widget(); - delete item; - } - - // The next call to /messages will be without a prev token. - prev_batch_token_.clear(); - eventIds_.clear(); - - // Clear queues with pending messages to be rendered. - bottomMessages_.clear(); - topMessages_.clear(); - - firstSender_.clear(); - lastSender_.clear(); -} - -void -TimelineView::toggleScrollDownButton() -{ - const int maxScroll = scroll_area_->verticalScrollBar()->maximum(); - const int currentScroll = scroll_area_->verticalScrollBar()->value(); - - if (maxScroll - currentScroll > SCROLL_BAR_GAP) { - scrollDownBtn_->show(); - scrollDownBtn_->raise(); - } else { - scrollDownBtn_->hide(); - } -} - -void -TimelineView::removeEvent(const QString &event_id) -{ - if (!eventIds_.contains(event_id)) { - nhlog::ui()->warn("cannot remove widget with unknown event_id: {}", - event_id.toStdString()); - return; - } - - auto removedItem = eventIds_[event_id]; - - // Find the next and the previous widgets in the timeline - auto prevWidget = relativeWidget(removedItem, -1); - auto nextWidget = relativeWidget(removedItem, 1); - - // See if they are timeline items - auto prevItem = qobject_cast(prevWidget); - auto nextItem = qobject_cast(nextWidget); - - // ... or a date separator - auto prevLabel = qobject_cast(prevWidget); - - // If it's a TimelineItem add an avatar. - if (prevItem) { - prevItem->addAvatar(); - } - - if (nextItem) { - nextItem->addAvatar(); - } else if (prevLabel) { - // If there's no chat message after this, and we have a label before us, delete the - // label. - prevLabel->deleteLater(); - } - - // If we deleted the last item in the timeline... - if (!nextItem && prevItem) - saveLastMessageInfo(prevItem->descriptionMessage().userid, - prevItem->descriptionMessage().datetime); - - // If we deleted the first item in the timeline... - if (!prevItem && nextItem) - saveFirstMessageInfo(nextItem->descriptionMessage().userid, - nextItem->descriptionMessage().datetime); - - // If we deleted the only item in the timeline... - if (!prevItem && !nextItem) { - firstSender_.clear(); - firstMsgTimestamp_ = QDateTime(); - lastSender_.clear(); - lastMsgTimestamp_ = QDateTime(); - } - - // Finally remove the event. - removedItem->deleteLater(); - eventIds_.remove(event_id); - - // Update the room list with a view of the last message after - // all events have been processed. - QTimer::singleShot(0, this, [this]() { notifyForLastEvent(); }); -} - -QWidget * -TimelineView::relativeWidget(QWidget *item, int dt) const -{ - int pos = scroll_layout_->indexOf(item); - - if (pos == -1) - return nullptr; - - pos = pos + dt; - - bool isOutOfBounds = (pos < 0 || pos > scroll_layout_->count() - 1); - - return isOutOfBounds ? nullptr : scroll_layout_->itemAt(pos)->widget(); -} - -TimelineEvent -TimelineView::findFirstViewableEvent(const std::vector &events) -{ - auto it = std::find_if(events.begin(), events.end(), [](const auto &event) { - return mtx::events::EventType::RoomMessage == utils::event_type(event); - }); - - return (it == std::end(events)) ? events.front() : *it; -} - -TimelineEvent -TimelineView::findLastViewableEvent(const std::vector &events) -{ - auto it = std::find_if(events.rbegin(), events.rend(), [](const auto &event) { - return (mtx::events::EventType::RoomMessage == utils::event_type(event)) || - (mtx::events::EventType::RoomEncrypted == utils::event_type(event)); - }); - - return (it == std::rend(events)) ? events.back() : *it; -} - -void -TimelineView::saveMessageInfo(const QString &sender, - uint64_t origin_server_ts, - TimelineDirection direction) -{ - updateLastSender(sender, direction); - - if (direction == TimelineDirection::Bottom) - lastMsgTimestamp_ = QDateTime::fromMSecsSinceEpoch(origin_server_ts); - else - firstMsgTimestamp_ = QDateTime::fromMSecsSinceEpoch(origin_server_ts); -} - -bool -TimelineView::isDateDifference(const QDateTime &first, const QDateTime &second) const -{ - // Check if the dates are in a different day. - if (std::abs(first.daysTo(second)) != 0) - return true; - - const uint64_t diffInSeconds = std::abs(first.msecsTo(second)) / 1000; - constexpr uint64_t fifteenMins = 15 * 60; - - return diffInSeconds > fifteenMins; -} - -void -TimelineView::sendRoomMessageHandler(const std::string &txn_id, - const mtx::responses::EventId &res, - mtx::http::RequestErr err) -{ - if (err) { - const int status_code = static_cast(err->status_code); - nhlog::net()->warn("[{}] failed to send message: {} {}", - txn_id, - err->matrix_error.error, - status_code); - emit messageFailed(txn_id); - return; - } - - emit messageSent(txn_id, QString::fromStdString(res.event_id.to_string())); -} - -template<> -mtx::events::msg::Audio -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::Audio audio; - audio.info.mimetype = m.mime.toStdString(); - audio.info.size = m.media_size; - audio.body = m.filename.toStdString(); - audio.url = m.body.toStdString(); - return audio; -} - -template<> -mtx::events::msg::Image -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::Image image; - image.info.mimetype = m.mime.toStdString(); - image.info.size = m.media_size; - image.body = m.filename.toStdString(); - image.url = m.body.toStdString(); - image.info.h = m.dimensions.height(); - image.info.w = m.dimensions.width(); - return image; -} - -template<> -mtx::events::msg::Video -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::Video video; - video.info.mimetype = m.mime.toStdString(); - video.info.size = m.media_size; - video.body = m.filename.toStdString(); - video.url = m.body.toStdString(); - return video; -} - -template<> -mtx::events::msg::Emote -toRoomMessage(const PendingMessage &m) -{ - auto html = utils::markdownToHtml(m.body); - - mtx::events::msg::Emote emote; - emote.body = m.body.trimmed().toStdString(); - - if (html != m.body.trimmed().toHtmlEscaped()) - emote.formatted_body = html.toStdString(); - - return emote; -} - -template<> -mtx::events::msg::File -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::File file; - file.info.mimetype = m.mime.toStdString(); - file.info.size = m.media_size; - file.body = m.filename.toStdString(); - file.url = m.body.toStdString(); - return file; -} - -template<> -mtx::events::msg::Text -toRoomMessage(const PendingMessage &m) -{ - auto html = utils::markdownToHtml(m.body); - - mtx::events::msg::Text text; - - text.body = m.body.trimmed().toStdString(); - - if (html != m.body.trimmed().toHtmlEscaped()) { - if (!m.related.quoted_body.isEmpty()) { - text.formatted_body = - utils::getFormattedQuoteBody(m.related, html).toStdString(); - } else { - text.formatted_body = html.toStdString(); - } - } - - if (!m.related.related_event.empty()) { - text.relates_to.in_reply_to.event_id = m.related.related_event; - } - - return text; -} - -void -TimelineView::prepareEncryptedMessage(const PendingMessage &msg) -{ - const auto room_id = room_id_.toStdString(); - - using namespace mtx::events; - using namespace mtx::identifiers; - - json content; - - // Serialize the message to the plaintext that will be encrypted. - switch (msg.ty) { - case MessageType::Audio: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::Emote: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::File: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::Image: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::Text: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::Video: { - content = json(toRoomMessage(msg)); - break; - } - default: - break; - } - - json doc{{"type", "m.room.message"}, {"content", content}, {"room_id", room_id}}; - - try { - // Check if we have already an outbound megolm session then we can use. - if (cache::client()->outboundMegolmSessionExists(room_id)) { - auto data = olm::encrypt_group_message( - room_id, http::client()->device_id(), doc.dump()); - - http::client()->send_room_message( - room_id, - msg.txn_id, - data, - std::bind(&TimelineView::sendRoomMessageHandler, - this, - msg.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - return; - } - - nhlog::ui()->debug("creating new outbound megolm session"); - - // Create a new outbound megolm session. - auto outbound_session = olm::client()->init_outbound_group_session(); - const auto session_id = mtx::crypto::session_id(outbound_session.get()); - const auto session_key = mtx::crypto::session_key(outbound_session.get()); - - // TODO: needs to be moved in the lib. - auto megolm_payload = json{{"algorithm", "m.megolm.v1.aes-sha2"}, - {"room_id", room_id}, - {"session_id", session_id}, - {"session_key", session_key}}; - - // Saving the new megolm session. - // TODO: Maybe it's too early to save. - OutboundGroupSessionData session_data; - session_data.session_id = session_id; - session_data.session_key = session_key; - session_data.message_index = 0; // TODO Update me - cache::client()->saveOutboundMegolmSession( - room_id, session_data, std::move(outbound_session)); - - const auto members = cache::client()->roomMembers(room_id); - nhlog::ui()->info("retrieved {} members for {}", members.size(), room_id); - - auto keeper = std::make_shared( - [megolm_payload, room_id, doc, txn_id = msg.txn_id, this]() { - try { - auto data = olm::encrypt_group_message( - room_id, http::client()->device_id(), doc.dump()); - - http::client() - ->send_room_message( - room_id, - txn_id, - data, - std::bind(&TimelineView::sendRoomMessageHandler, - this, - txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - } catch (const lmdb::error &e) { - nhlog::db()->critical( - "failed to save megolm outbound session: {}", e.what()); - } - }); - - mtx::requests::QueryKeys req; - for (const auto &member : members) - req.device_keys[member] = {}; - - http::client()->query_keys( - req, - [keeper = std::move(keeper), megolm_payload, this]( - const mtx::responses::QueryKeys &res, mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn("failed to query device keys: {} {}", - err->matrix_error.error, - static_cast(err->status_code)); - // TODO: Mark the event as failed. Communicate with the UI. - return; - } - - for (const auto &user : res.device_keys) { - // Mapping from a device_id with valid identity keys to the - // generated room_key event used for sharing the megolm session. - std::map room_key_msgs; - std::map deviceKeys; - - room_key_msgs.clear(); - deviceKeys.clear(); - - for (const auto &dev : user.second) { - const auto user_id = UserId(dev.second.user_id); - const auto device_id = DeviceId(dev.second.device_id); - - const auto device_keys = dev.second.keys; - const auto curveKey = "curve25519:" + device_id.get(); - const auto edKey = "ed25519:" + device_id.get(); - - if ((device_keys.find(curveKey) == device_keys.end()) || - (device_keys.find(edKey) == device_keys.end())) { - nhlog::net()->debug( - "ignoring malformed keys for device {}", - device_id.get()); - continue; - } - - DevicePublicKeys pks; - pks.ed25519 = device_keys.at(edKey); - pks.curve25519 = device_keys.at(curveKey); - - try { - if (!mtx::crypto::verify_identity_signature( - json(dev.second), device_id, user_id)) { - nhlog::crypto()->warn( - "failed to verify identity keys: {}", - json(dev.second).dump(2)); - continue; - } - } catch (const json::exception &e) { - nhlog::crypto()->warn( - "failed to parse device key json: {}", - e.what()); - continue; - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->warn( - "failed to verify device key json: {}", - e.what()); - continue; - } - - auto room_key = olm::client() - ->create_room_key_event( - user_id, pks.ed25519, megolm_payload) - .dump(); - - room_key_msgs.emplace(device_id, room_key); - deviceKeys.emplace(device_id, pks); - } - - std::vector valid_devices; - valid_devices.reserve(room_key_msgs.size()); - for (auto const &d : room_key_msgs) { - valid_devices.push_back(d.first); - - nhlog::net()->info("{}", d.first); - nhlog::net()->info(" curve25519 {}", - deviceKeys.at(d.first).curve25519); - nhlog::net()->info(" ed25519 {}", - deviceKeys.at(d.first).ed25519); - } - - nhlog::net()->info( - "sending claim request for user {} with {} devices", - user.first, - valid_devices.size()); - - http::client()->claim_keys( - user.first, - valid_devices, - std::bind(&TimelineView::handleClaimedKeys, - this, - keeper, - room_key_msgs, - deviceKeys, - user.first, - std::placeholders::_1, - std::placeholders::_2)); - - // TODO: Wait before sending the next batch of requests. - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } - }); - - // TODO: Let the user know about the errors. - } catch (const lmdb::error &e) { - nhlog::db()->critical( - "failed to open outbound megolm session ({}): {}", room_id, e.what()); - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->critical( - "failed to open outbound megolm session ({}): {}", room_id, e.what()); - } -} - -void -TimelineView::handleClaimedKeys(std::shared_ptr keeper, - const std::map &room_keys, - const std::map &pks, - const std::string &user_id, - const mtx::responses::ClaimKeys &res, - mtx::http::RequestErr err) -{ - if (err) { - nhlog::net()->warn("claim keys error: {} {} {}", - err->matrix_error.error, - err->parse_error, - static_cast(err->status_code)); - return; - } - - nhlog::net()->debug("claimed keys for {}", user_id); - - if (res.one_time_keys.size() == 0) { - nhlog::net()->debug("no one-time keys found for user_id: {}", user_id); - return; - } - - if (res.one_time_keys.find(user_id) == res.one_time_keys.end()) { - nhlog::net()->debug("no one-time keys found for user_id: {}", user_id); - return; - } - - auto retrieved_devices = res.one_time_keys.at(user_id); - - // Payload with all the to_device message to be sent. - json body; - body["messages"][user_id] = json::object(); - - for (const auto &rd : retrieved_devices) { - const auto device_id = rd.first; - nhlog::net()->debug("{} : \n {}", device_id, rd.second.dump(2)); - - // TODO: Verify signatures - auto otk = rd.second.begin()->at("key"); - - if (pks.find(device_id) == pks.end()) { - nhlog::net()->critical("couldn't find public key for device: {}", - device_id); - continue; - } - - auto id_key = pks.at(device_id).curve25519; - auto s = olm::client()->create_outbound_session(id_key, otk); - - if (room_keys.find(device_id) == room_keys.end()) { - nhlog::net()->critical("couldn't find m.room_key for device: {}", - device_id); - continue; - } - - auto device_msg = olm::client()->create_olm_encrypted_content( - s.get(), room_keys.at(device_id), pks.at(device_id).curve25519); - - try { - cache::client()->saveOlmSession(id_key, std::move(s)); - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to save outbound olm session: {}", e.what()); - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->critical("failed to pickle outbound olm session: {}", - e.what()); - } - - body["messages"][user_id][device_id] = device_msg; - } - - nhlog::net()->info("send_to_device: {}", user_id); - - http::client()->send_to_device( - "m.room.encrypted", body, [keeper](mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn("failed to send " - "send_to_device " - "message: {}", - err->matrix_error.error); - } - - (void)keeper; - }); -} diff --git a/src/timeline/TimelineView.h b/src/timeline/TimelineView.h deleted file mode 100644 index 35796efd..00000000 --- a/src/timeline/TimelineView.h +++ /dev/null @@ -1,449 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "../Utils.h" -#include "MatrixClient.h" -#include "timeline/TimelineItem.h" - -class StateKeeper -{ -public: - StateKeeper(std::function &&fn) - : fn_(std::move(fn)) - {} - - ~StateKeeper() { fn_(); } - -private: - std::function fn_; -}; - -struct DecryptionResult -{ - //! The decrypted content as a normal plaintext event. - utils::TimelineEvent event; - //! Whether or not the decryption was successful. - bool isDecrypted = false; -}; - -class FloatingButton; -struct DescInfo; - -// Contains info about a message shown in the history view -// but not yet confirmed by the homeserver through sync. -struct PendingMessage -{ - mtx::events::MessageType ty; - std::string txn_id; - RelatedInfo related; - QString body; - QString filename; - QString mime; - uint64_t media_size; - QString event_id; - TimelineItem *widget; - QSize dimensions; - bool is_encrypted = false; -}; - -template -MessageT -toRoomMessage(const PendingMessage &) = delete; - -template<> -mtx::events::msg::Audio -toRoomMessage(const PendingMessage &m); - -template<> -mtx::events::msg::Emote -toRoomMessage(const PendingMessage &m); - -template<> -mtx::events::msg::File -toRoomMessage(const PendingMessage &); - -template<> -mtx::events::msg::Image -toRoomMessage(const PendingMessage &m); - -template<> -mtx::events::msg::Text -toRoomMessage(const PendingMessage &); - -template<> -mtx::events::msg::Video -toRoomMessage(const PendingMessage &m); - -// In which place new TimelineItems should be inserted. -enum class TimelineDirection -{ - Top, - Bottom, -}; - -class TimelineView : public QWidget -{ - Q_OBJECT - -public: - TimelineView(const mtx::responses::Timeline &timeline, - const QString &room_id, - QWidget *parent = 0); - TimelineView(const QString &room_id, QWidget *parent = 0); - - // Add new events at the end of the timeline. - void addEvents(const mtx::responses::Timeline &timeline); - void addUserMessage(mtx::events::MessageType ty, - const QString &body, - const RelatedInfo &related); - void addUserMessage(mtx::events::MessageType ty, const QString &msg); - - template - void addUserMessage(const QString &url, - const QString &filename, - const QString &mime, - uint64_t size, - const QSize &dimensions = QSize()); - void updatePendingMessage(const std::string &txn_id, const QString &event_id); - void scrollDown(); - - //! Remove an item from the timeline with the given Event ID. - void removeEvent(const QString &event_id); - void setPrevBatchToken(const QString &token) { prev_batch_token_ = token; } - -public slots: - void sliderRangeChanged(int min, int max); - void sliderMoved(int position); - void fetchHistory(); - - // Add old events at the top of the timeline. - void addBackwardsEvents(const mtx::responses::Messages &msgs); - - // Whether or not the initial batch has been loaded. - bool hasLoaded() { return scroll_layout_->count() > 0 || isTimelineFinished; } - - void handleFailedMessage(const std::string &txn_id); - -private slots: - void sendNextPendingMessage(); - -signals: - void updateLastTimelineMessage(const QString &user, const DescInfo &info); - void messagesRetrieved(const mtx::responses::Messages &res); - void messageFailed(const std::string &txn_id); - void messageSent(const std::string &txn_id, const QString &event_id); - void markReadEvents(const std::vector &event_ids); - -protected: - void paintEvent(QPaintEvent *event) override; - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override; - bool event(QEvent *event) override; - -private: - using TimelineEvent = mtx::events::collections::TimelineEvents; - - //! Mark our own widgets as read if they have more than one receipt. - void displayReadReceipts(std::vector events); - //! Determine if the start of the timeline is reached from the response of /messages. - bool isStartOfTimeline(const mtx::responses::Messages &msgs); - - QWidget *relativeWidget(QWidget *item, int dt) const; - - DecryptionResult parseEncryptedEvent( - const mtx::events::EncryptedEvent &e); - - void handleClaimedKeys(std::shared_ptr keeper, - const std::map &room_key, - const std::map &pks, - const std::string &user_id, - const mtx::responses::ClaimKeys &res, - mtx::http::RequestErr err); - - //! Callback for all message sending. - void sendRoomMessageHandler(const std::string &txn_id, - const mtx::responses::EventId &res, - mtx::http::RequestErr err); - void prepareEncryptedMessage(const PendingMessage &msg); - - //! Call the /messages endpoint to fill the timeline. - void getMessages(); - //! HACK: Fixing layout flickering when adding to the bottom - //! of the timeline. - void pushTimelineItem(QWidget *item, TimelineDirection dir) - { - setUpdatesEnabled(false); - item->hide(); - - if (dir == TimelineDirection::Top) - scroll_layout_->insertWidget(0, item); - else - scroll_layout_->addWidget(item); - - QTimer::singleShot(0, this, [item, this]() { - item->show(); - item->adjustSize(); - setUpdatesEnabled(true); - }); - } - - //! Decides whether or not to show or hide the scroll down button. - void toggleScrollDownButton(); - void init(); - void addTimelineItem(QWidget *item, - TimelineDirection direction = TimelineDirection::Bottom); - void updateLastSender(const QString &user_id, TimelineDirection direction); - void notifyForLastEvent(); - void notifyForLastEvent(const TimelineEvent &event); - //! Keep track of the sender and the timestamp of the current message. - void saveLastMessageInfo(const QString &sender, const QDateTime &datetime) - { - lastSender_ = sender; - lastMsgTimestamp_ = datetime; - } - void saveFirstMessageInfo(const QString &sender, const QDateTime &datetime) - { - firstSender_ = sender; - firstMsgTimestamp_ = datetime; - } - //! Keep track of the sender and the timestamp of the current message. - void saveMessageInfo(const QString &sender, - uint64_t origin_server_ts, - TimelineDirection direction); - - TimelineEvent findFirstViewableEvent(const std::vector &events); - TimelineEvent findLastViewableEvent(const std::vector &events); - - //! Mark the last event as read. - void readLastEvent() const; - //! Whether or not the scrollbar is visible (non-zero height). - bool isScrollbarActivated() { return scroll_area_->verticalScrollBar()->value() != 0; } - //! Retrieve the event id of the last item. - QString getLastEventId() const; - - template - TimelineItem *processMessageEvent(const Event &event, TimelineDirection direction); - - // TODO: Remove this eventually. - template - TimelineItem *processMessageEvent(const Event &event, TimelineDirection direction); - - // For events with custom display widgets. - template - TimelineItem *createTimelineItem(const Event &event, bool withSender); - - // For events without custom display widgets. - // TODO: All events should have custom widgets. - template - TimelineItem *createTimelineItem(const Event &event, bool withSender); - - // Used to determine whether or not we should prefix a message with the - // sender's name. - bool isSenderRendered(const QString &user_id, - uint64_t origin_server_ts, - TimelineDirection direction); - - bool isPendingMessage(const std::string &txn_id, - const QString &sender, - const QString &userid); - void removePendingMessage(const std::string &txn_id); - - bool isDuplicate(const QString &event_id) { return eventIds_.contains(event_id); } - - void handleNewUserMessage(PendingMessage msg); - bool isDateDifference(const QDateTime &first, - const QDateTime &second = QDateTime::currentDateTime()) const; - - // Return nullptr if the event couldn't be parsed. - QWidget *parseMessageEvent(const mtx::events::collections::TimelineEvents &event, - TimelineDirection direction); - - //! Store the event id associated with the given widget. - void saveEventId(QWidget *widget); - //! Remove all widgets from the timeline layout. - void clearTimeline(); - - QVBoxLayout *top_layout_; - QVBoxLayout *scroll_layout_; - - QScrollArea *scroll_area_; - QWidget *scroll_widget_; - - QString firstSender_; - QDateTime firstMsgTimestamp_; - QString lastSender_; - QDateTime lastMsgTimestamp_; - - QString room_id_; - QString prev_batch_token_; - QString local_user_; - - bool isPaginationInProgress_ = false; - - // Keeps track whether or not the user has visited the view. - bool isInitialized = false; - bool isTimelineFinished = false; - bool isInitialSync = true; - - const int SCROLL_BAR_GAP = 200; - - QTimer *paginationTimer_; - - int scroll_height_ = 0; - int previous_max_height_ = 0; - - int oldPosition_; - int oldHeight_; - - FloatingButton *scrollDownBtn_; - - TimelineDirection lastMessageDirection_; - - //! Messages received by sync not added to the timeline. - std::vector bottomMessages_; - //! Messages received by /messages not added to the timeline. - std::vector topMessages_; - - //! Render the given timeline events to the bottom of the timeline. - void renderBottomEvents(const std::vector &events); - //! Render the given timeline events to the top of the timeline. - void renderTopEvents(const std::vector &events); - - // The events currently rendered. Used for duplicate detection. - QMap eventIds_; - QQueue pending_msgs_; - QList pending_sent_msgs_; -}; - -template -void -TimelineView::addUserMessage(const QString &url, - const QString &filename, - const QString &mime, - uint64_t size, - const QSize &dimensions) -{ - auto with_sender = (lastSender_ != local_user_) || isDateDifference(lastMsgTimestamp_); - auto trimmed = QFileInfo{filename}.fileName(); // Trim file path. - - auto widget = new Widget(url, trimmed, size, this); - - TimelineItem *view_item = - new TimelineItem(widget, local_user_, with_sender, room_id_, scroll_widget_); - - addTimelineItem(view_item); - - lastMessageDirection_ = TimelineDirection::Bottom; - - // Keep track of the sender and the timestamp of the current message. - saveLastMessageInfo(local_user_, QDateTime::currentDateTime()); - - PendingMessage message; - message.ty = MsgType; - message.txn_id = http::client()->generate_txn_id(); - message.body = url; - message.filename = trimmed; - message.mime = mime; - message.media_size = size; - message.widget = view_item; - message.dimensions = dimensions; - - handleNewUserMessage(message); -} - -template -TimelineItem * -TimelineView::createTimelineItem(const Event &event, bool withSender) -{ - TimelineItem *item = new TimelineItem(event, withSender, room_id_, scroll_widget_); - return item; -} - -template -TimelineItem * -TimelineView::createTimelineItem(const Event &event, bool withSender) -{ - auto eventWidget = new Widget(event); - auto item = new TimelineItem(eventWidget, event, withSender, room_id_, scroll_widget_); - - return item; -} - -template -TimelineItem * -TimelineView::processMessageEvent(const Event &event, TimelineDirection direction) -{ - const auto event_id = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - - const auto txn_id = event.unsigned_data.transaction_id; - if ((!txn_id.empty() && isPendingMessage(txn_id, sender, local_user_)) || - isDuplicate(event_id)) { - removePendingMessage(txn_id); - return nullptr; - } - - auto with_sender = isSenderRendered(sender, event.origin_server_ts, direction); - - saveMessageInfo(sender, event.origin_server_ts, direction); - - auto item = createTimelineItem(event, with_sender); - - eventIds_[event_id] = item; - - return item; -} - -template -TimelineItem * -TimelineView::processMessageEvent(const Event &event, TimelineDirection direction) -{ - const auto event_id = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - - const auto txn_id = event.unsigned_data.transaction_id; - if ((!txn_id.empty() && isPendingMessage(txn_id, sender, local_user_)) || - isDuplicate(event_id)) { - removePendingMessage(txn_id); - return nullptr; - } - - auto with_sender = isSenderRendered(sender, event.origin_server_ts, direction); - - saveMessageInfo(sender, event.origin_server_ts, direction); - - auto item = createTimelineItem(event, with_sender); - - eventIds_[event_id] = item; - - return item; -} diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp index 86505481..6e18d111 100644 --- a/src/timeline/TimelineViewManager.cpp +++ b/src/timeline/TimelineViewManager.cpp @@ -1,340 +1,292 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ +#include "TimelineViewManager.h" -#include +#include +#include +#include -#include -#include -#include - -#include "Cache.h" +#include "ChatPage.h" +#include "ColorImageProvider.h" +#include "DelegateChooser.h" #include "Logging.h" -#include "Utils.h" -#include "timeline/TimelineView.h" -#include "timeline/TimelineViewManager.h" -#include "timeline/widgets/AudioItem.h" -#include "timeline/widgets/FileItem.h" -#include "timeline/widgets/ImageItem.h" -#include "timeline/widgets/VideoItem.h" +#include "MxcImageProvider.h" +#include "UserSettingsPage.h" +#include "dialogs/ImageOverlay.h" + +void +TimelineViewManager::updateColorPalette() +{ + UserSettings settings; + if (settings.theme() == "light") { + QPalette lightActive(/*windowText*/ QColor("#333"), + /*button*/ QColor("#333"), + /*light*/ QColor(), + /*dark*/ QColor(220, 220, 220, 120), + /*mid*/ QColor(), + /*text*/ QColor("#333"), + /*bright_text*/ QColor(), + /*base*/ QColor("white"), + /*window*/ QColor("white")); + view->rootContext()->setContextProperty("currentActivePalette", lightActive); + view->rootContext()->setContextProperty("currentInactivePalette", lightActive); + } else if (settings.theme() == "dark") { + QPalette darkActive(/*windowText*/ QColor("#caccd1"), + /*button*/ QColor("#caccd1"), + /*light*/ QColor(), + /*dark*/ QColor(45, 49, 57, 120), + /*mid*/ QColor(), + /*text*/ QColor("#caccd1"), + /*bright_text*/ QColor(), + /*base*/ QColor("#202228"), + /*window*/ QColor("#202228")); + darkActive.setColor(QPalette::Highlight, QColor("#e7e7e9")); + view->rootContext()->setContextProperty("currentActivePalette", darkActive); + view->rootContext()->setContextProperty("currentInactivePalette", darkActive); + } else { + view->rootContext()->setContextProperty("currentActivePalette", QPalette()); + view->rootContext()->setContextProperty("currentInactivePalette", nullptr); + } +} TimelineViewManager::TimelineViewManager(QWidget *parent) - : QStackedWidget(parent) -{} - -void -TimelineViewManager::updateReadReceipts(const QString &room_id, - const std::vector &event_ids) + : imgProvider(new MxcImageProvider()) + , colorImgProvider(new ColorImageProvider()) { - if (timelineViewExists(room_id)) { - auto view = views_[room_id]; - if (view) - emit view->markReadEvents(event_ids); - } -} + qmlRegisterUncreatableMetaObject(qml_mtx_events::staticMetaObject, + "im.nheko", + 1, + 0, + "MtxEvent", + "Can't instantiate enum!"); + qmlRegisterType("im.nheko", 1, 0, "DelegateChoice"); + qmlRegisterType("im.nheko", 1, 0, "DelegateChooser"); -void -TimelineViewManager::removeTimelineEvent(const QString &room_id, const QString &event_id) -{ - auto view = views_[room_id]; +#ifdef USE_QUICK_VIEW + view = new QQuickView(); + container = QWidget::createWindowContainer(view, parent); +#else + view = new QQuickWidget(parent); + container = view; + view->setResizeMode(QQuickWidget::SizeRootObjectToView); + container->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - if (view) - view->removeEvent(event_id); -} + connect(view, &QQuickWidget::statusChanged, this, [](QQuickWidget::Status status) { + nhlog::ui()->debug("Status changed to {}", status); + }); +#endif + container->setMinimumSize(200, 200); + view->rootContext()->setContextProperty("timelineManager", this); + updateColorPalette(); + view->engine()->addImageProvider("MxcImage", imgProvider); + view->engine()->addImageProvider("colorimage", colorImgProvider); + view->setSource(QUrl("qrc:///qml/TimelineView.qml")); -void -TimelineViewManager::queueTextMessage(const QString &msg) -{ - if (active_room_.isEmpty()) - return; - - auto room_id = active_room_; - auto view = views_[room_id]; - - view->addUserMessage(mtx::events::MessageType::Text, msg); -} - -void -TimelineViewManager::queueEmoteMessage(const QString &msg) -{ - if (active_room_.isEmpty()) - return; - - auto room_id = active_room_; - auto view = views_[room_id]; - - view->addUserMessage(mtx::events::MessageType::Emote, msg); -} - -void -TimelineViewManager::queueReplyMessage(const QString &reply, const RelatedInfo &related) -{ - if (active_room_.isEmpty()) - return; - - auto room_id = active_room_; - auto view = views_[room_id]; - - view->addUserMessage(mtx::events::MessageType::Text, reply, related); -} - -void -TimelineViewManager::queueImageMessage(const QString &roomid, - const QString &filename, - const QString &url, - const QString &mime, - uint64_t size, - const QSize &dimensions) -{ - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("Cannot send m.image message to a non-managed view"); - return; - } - - auto view = views_[roomid]; - - view->addUserMessage( - url, filename, mime, size, dimensions); -} - -void -TimelineViewManager::queueFileMessage(const QString &roomid, - const QString &filename, - const QString &url, - const QString &mime, - uint64_t size) -{ - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("cannot send m.file message to a non-managed view"); - return; - } - - auto view = views_[roomid]; - - view->addUserMessage(url, filename, mime, size); -} - -void -TimelineViewManager::queueAudioMessage(const QString &roomid, - const QString &filename, - const QString &url, - const QString &mime, - uint64_t size) -{ - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("cannot send m.audio message to a non-managed view"); - return; - } - - auto view = views_[roomid]; - - view->addUserMessage(url, filename, mime, size); -} - -void -TimelineViewManager::queueVideoMessage(const QString &roomid, - const QString &filename, - const QString &url, - const QString &mime, - uint64_t size) -{ - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("cannot send m.video message to a non-managed view"); - return; - } - - auto view = views_[roomid]; - - view->addUserMessage(url, filename, mime, size); -} - -void -TimelineViewManager::initialize(const mtx::responses::Rooms &rooms) -{ - for (auto it = rooms.join.cbegin(); it != rooms.join.cend(); ++it) { - addRoom(it->second, QString::fromStdString(it->first)); - } - - sync(rooms); -} - -void -TimelineViewManager::initWithMessages(const std::map &msgs) -{ - for (auto it = msgs.cbegin(); it != msgs.cend(); ++it) { - if (timelineViewExists(it->first)) - return; - - // Create a history view with the room events. - TimelineView *view = new TimelineView(it->second, it->first); - views_.emplace(it->first, QSharedPointer(view)); - - connect(view, - &TimelineView::updateLastTimelineMessage, - this, - &TimelineViewManager::updateRoomsLastMessage); - - // Add the view in the widget stack. - addWidget(view); - } -} - -void -TimelineViewManager::initialize(const std::vector &rooms) -{ - for (const auto &roomid : rooms) - addRoom(QString::fromStdString(roomid)); -} - -void -TimelineViewManager::addRoom(const mtx::responses::JoinedRoom &room, const QString &room_id) -{ - if (timelineViewExists(room_id)) - return; - - // Create a history view with the room events. - TimelineView *view = new TimelineView(room.timeline, room_id); - views_.emplace(room_id, QSharedPointer(view)); - - connect(view, - &TimelineView::updateLastTimelineMessage, + connect(dynamic_cast(parent), + &ChatPage::themeChanged, this, - &TimelineViewManager::updateRoomsLastMessage); - - // Add the view in the widget stack. - addWidget(view); -} - -void -TimelineViewManager::addRoom(const QString &room_id) -{ - if (timelineViewExists(room_id)) - return; - - // Create a history view without any events. - TimelineView *view = new TimelineView(room_id); - views_.emplace(room_id, QSharedPointer(view)); - - connect(view, - &TimelineView::updateLastTimelineMessage, - this, - &TimelineViewManager::updateRoomsLastMessage); - - // Add the view in the widget stack. - addWidget(view); + &TimelineViewManager::updateColorPalette); } void TimelineViewManager::sync(const mtx::responses::Rooms &rooms) { - for (const auto &room : rooms.join) { - auto roomid = QString::fromStdString(room.first); + for (auto it = rooms.join.cbegin(); it != rooms.join.cend(); ++it) { + // addRoom will only add the room, if it doesn't exist + addRoom(QString::fromStdString(it->first)); + models.value(QString::fromStdString(it->first))->addEvents(it->second.timeline); + } - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("ignoring event from unknown room: {}", - roomid.toStdString()); - continue; - } + this->isInitialSync_ = false; + emit initialSyncChanged(false); +} - auto view = views_.at(roomid); - - view->addEvents(room.second.timeline); +void +TimelineViewManager::addRoom(const QString &room_id) +{ + if (!models.contains(room_id)) { + QSharedPointer newRoom(new TimelineModel(this, room_id)); + connect(newRoom.data(), + &TimelineModel::newEncryptedImage, + imgProvider, + &MxcImageProvider::addEncryptionInfo); + models.insert(room_id, std::move(newRoom)); } } void TimelineViewManager::setHistoryView(const QString &room_id) { - if (!timelineViewExists(room_id)) { - nhlog::ui()->warn("room from RoomList is not present in ViewManager: {}", - room_id.toStdString()); - return; + nhlog::ui()->info("Trying to activate room {}", room_id.toStdString()); + + auto room = models.find(room_id); + if (room != models.end()) { + timeline_ = room.value().data(); + emit activeTimelineChanged(timeline_); + nhlog::ui()->info("Activated room {}", room_id.toStdString()); } - - active_room_ = room_id; - auto view = views_.at(room_id); - - setCurrentWidget(view.data()); - - view->fetchHistory(); - view->scrollDown(); } -QString -TimelineViewManager::chooseRandomColor() +void +TimelineViewManager::openImageOverlay(QString mxcUrl, QString eventId) const { - std::random_device random_device; - std::mt19937 engine{random_device()}; - std::uniform_real_distribution dist(0, 1); + QQuickImageResponse *imgResponse = + imgProvider->requestImageResponse(mxcUrl.remove("mxc://"), QSize()); + connect(imgResponse, &QQuickImageResponse::finished, this, [this, eventId, imgResponse]() { + if (!imgResponse->errorString().isEmpty()) { + nhlog::ui()->error("Error when retrieving image for overlay: {}", + imgResponse->errorString().toStdString()); + return; + } + auto pixmap = QPixmap::fromImage(imgResponse->textureFactory()->image()); - float hue = dist(engine); - float saturation = 0.9; - float value = 0.7; - - int hue_i = hue * 6; - - float f = hue * 6 - hue_i; - - float p = value * (1 - saturation); - float q = value * (1 - f * saturation); - float t = value * (1 - (1 - f) * saturation); - - float r = 0; - float g = 0; - float b = 0; - - if (hue_i == 0) { - r = value; - g = t; - b = p; - } else if (hue_i == 1) { - r = q; - g = value; - b = p; - } else if (hue_i == 2) { - r = p; - g = value; - b = t; - } else if (hue_i == 3) { - r = p; - g = q; - b = value; - } else if (hue_i == 4) { - r = t; - g = p; - b = value; - } else if (hue_i == 5) { - r = value; - g = p; - b = q; - } - - int ri = r * 256; - int gi = g * 256; - int bi = b * 256; - - QColor color(ri, gi, bi); - - return color.name(); -} - -bool -TimelineViewManager::hasLoaded() const -{ - return std::all_of(views_.cbegin(), views_.cend(), [](const auto &view) { - return view.second->hasLoaded(); + auto imgDialog = new dialogs::ImageOverlay(pixmap); + imgDialog->show(); + connect(imgDialog, &dialogs::ImageOverlay::saving, timeline_, [this, eventId]() { + timeline_->saveMedia(eventId); + }); }); } + +void +TimelineViewManager::updateReadReceipts(const QString &room_id, + const std::vector &event_ids) +{ + auto room = models.find(room_id); + if (room != models.end()) { + room.value()->markEventsAsRead(event_ids); + } +} + +void +TimelineViewManager::initWithMessages(const std::map &msgs) +{ + for (const auto &e : msgs) { + addRoom(e.first); + + models.value(e.first)->addEvents(e.second); + } +} + +void +TimelineViewManager::queueTextMessage(const QString &msg) +{ + mtx::events::msg::Text text = {}; + text.body = msg.trimmed().toStdString(); + text.format = "org.matrix.custom.html"; + text.formatted_body = utils::markdownToHtml(msg).toStdString(); + + if (timeline_) + timeline_->sendMessage(text); +} + +void +TimelineViewManager::queueReplyMessage(const QString &reply, const RelatedInfo &related) +{ + mtx::events::msg::Text text = {}; + + QString body; + bool firstLine = true; + for (const auto &line : related.quoted_body.split("\n")) { + if (firstLine) { + firstLine = false; + body = QString("> <%1> %2\n").arg(related.quoted_user).arg(line); + } else { + body = QString("%1\n> %2\n").arg(body).arg(line); + } + } + + text.body = QString("%1\n%2").arg(body).arg(reply).toStdString(); + text.format = "org.matrix.custom.html"; + text.formatted_body = + utils::getFormattedQuoteBody(related, utils::markdownToHtml(reply)).toStdString(); + text.relates_to.in_reply_to.event_id = related.related_event; + + if (timeline_) + timeline_->sendMessage(text); +} + +void +TimelineViewManager::queueEmoteMessage(const QString &msg) +{ + auto html = utils::markdownToHtml(msg); + + mtx::events::msg::Emote emote; + emote.body = msg.trimmed().toStdString(); + + if (html != msg.trimmed().toHtmlEscaped()) + emote.formatted_body = html.toStdString(); + + if (timeline_) + timeline_->sendMessage(emote); +} + +void +TimelineViewManager::queueImageMessage(const QString &roomid, + const QString &filename, + const boost::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize, + const QSize &dimensions) +{ + mtx::events::msg::Image image; + image.info.mimetype = mime.toStdString(); + image.info.size = dsize; + image.body = filename.toStdString(); + image.url = url.toStdString(); + image.info.h = dimensions.height(); + image.info.w = dimensions.width(); + image.file = file; + models.value(roomid)->sendMessage(image); +} + +void +TimelineViewManager::queueFileMessage( + const QString &roomid, + const QString &filename, + const boost::optional &encryptedFile, + const QString &url, + const QString &mime, + uint64_t dsize) +{ + mtx::events::msg::File file; + file.info.mimetype = mime.toStdString(); + file.info.size = dsize; + file.body = filename.toStdString(); + file.url = url.toStdString(); + file.file = encryptedFile; + models.value(roomid)->sendMessage(file); +} + +void +TimelineViewManager::queueAudioMessage(const QString &roomid, + const QString &filename, + const boost::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize) +{ + mtx::events::msg::Audio audio; + audio.info.mimetype = mime.toStdString(); + audio.info.size = dsize; + audio.body = filename.toStdString(); + audio.url = url.toStdString(); + audio.file = file; + models.value(roomid)->sendMessage(audio); +} + +void +TimelineViewManager::queueVideoMessage(const QString &roomid, + const QString &filename, + const boost::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize) +{ + mtx::events::msg::Video video; + video.info.mimetype = mime.toStdString(); + video.info.size = dsize; + video.body = filename.toStdString(); + video.url = url.toStdString(); + video.file = file; + models.value(roomid)->sendMessage(video); +} diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h index b52136d9..9e8de616 100644 --- a/src/timeline/TimelineViewManager.h +++ b/src/timeline/TimelineViewManager.h @@ -1,98 +1,97 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - #pragma once +#include +#include #include -#include +#include -#include +#include +#include +#include "Cache.h" +#include "Logging.h" +#include "TimelineModel.h" #include "Utils.h" -class QFile; +class MxcImageProvider; +class ColorImageProvider; -class RoomInfoListItem; -class TimelineView; -struct DescInfo; -struct SavedMessages; - -class TimelineViewManager : public QStackedWidget +class TimelineViewManager : public QObject { Q_OBJECT + Q_PROPERTY( + TimelineModel *timeline MEMBER timeline_ READ activeTimeline NOTIFY activeTimelineChanged) + Q_PROPERTY( + bool isInitialSync MEMBER isInitialSync_ READ isInitialSync NOTIFY initialSyncChanged) + public: - TimelineViewManager(QWidget *parent); - - // Initialize with timeline events. - void initialize(const mtx::responses::Rooms &rooms); - // Empty initialization. - void initialize(const std::vector &rooms); - - void addRoom(const mtx::responses::JoinedRoom &room, const QString &room_id); - void addRoom(const QString &room_id); + TimelineViewManager(QWidget *parent = 0); + QWidget *getWidget() const { return container; } void sync(const mtx::responses::Rooms &rooms); - void clearAll() { views_.clear(); } + void addRoom(const QString &room_id); - // Check if all the timelines have been loaded. - bool hasLoaded() const; + void clearAll() { models.clear(); } - static QString chooseRandomColor(); + Q_INVOKABLE TimelineModel *activeTimeline() const { return timeline_; } + Q_INVOKABLE bool isInitialSync() const { return isInitialSync_; } + Q_INVOKABLE void openImageOverlay(QString mxcUrl, QString eventId) const; signals: void clearRoomMessageCount(QString roomid); - void updateRoomsLastMessage(const QString &user, const DescInfo &info); + void updateRoomsLastMessage(QString roomid, const DescInfo &info); + void activeTimelineChanged(TimelineModel *timeline); + void initialSyncChanged(bool isInitialSync); public slots: void updateReadReceipts(const QString &room_id, const std::vector &event_ids); - void removeTimelineEvent(const QString &room_id, const QString &event_id); void initWithMessages(const std::map &msgs); void setHistoryView(const QString &room_id); + void updateColorPalette(); + void queueTextMessage(const QString &msg); void queueReplyMessage(const QString &reply, const RelatedInfo &related); void queueEmoteMessage(const QString &msg); void queueImageMessage(const QString &roomid, const QString &filename, + const boost::optional &file, const QString &url, const QString &mime, uint64_t dsize, const QSize &dimensions); void queueFileMessage(const QString &roomid, const QString &filename, + const boost::optional &file, const QString &url, const QString &mime, uint64_t dsize); void queueAudioMessage(const QString &roomid, const QString &filename, + const boost::optional &file, const QString &url, const QString &mime, uint64_t dsize); void queueVideoMessage(const QString &roomid, const QString &filename, + const boost::optional &file, const QString &url, const QString &mime, uint64_t dsize); private: - //! Check if the given room id is managed by a TimelineView. - bool timelineViewExists(const QString &id) { return views_.find(id) != views_.end(); } +#ifdef USE_QUICK_VIEW + QQuickView *view; +#else + QQuickWidget *view; +#endif + QWidget *container; - QString active_room_; - std::map> views_; + MxcImageProvider *imgProvider; + ColorImageProvider *colorImgProvider; + + QHash> models; + TimelineModel *timeline_ = nullptr; + bool isInitialSync_ = true; }; diff --git a/src/timeline/widgets/AudioItem.cpp b/src/timeline/widgets/AudioItem.cpp deleted file mode 100644 index 5d6431ee..00000000 --- a/src/timeline/widgets/AudioItem.cpp +++ /dev/null @@ -1,236 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include -#include -#include -#include -#include -#include - -#include "Logging.h" -#include "MatrixClient.h" -#include "Utils.h" - -#include "timeline/widgets/AudioItem.h" - -constexpr int MaxWidth = 400; -constexpr int Height = 70; -constexpr int IconRadius = 22; -constexpr int IconDiameter = IconRadius * 2; -constexpr int HorizontalPadding = 12; -constexpr int TextPadding = 15; -constexpr int ActionIconRadius = IconRadius - 4; - -constexpr double VerticalPadding = Height - 2 * IconRadius; -constexpr double IconYCenter = Height / 2; -constexpr double IconXCenter = HorizontalPadding + IconRadius; - -void -AudioItem::init() -{ - setMouseTracking(true); - setCursor(Qt::PointingHandCursor); - setAttribute(Qt::WA_Hover, true); - - playIcon_.addFile(":/icons/icons/ui/play-sign.png"); - pauseIcon_.addFile(":/icons/icons/ui/pause-symbol.png"); - - player_ = new QMediaPlayer; - player_->setMedia(QUrl(url_)); - player_->setVolume(100); - player_->setNotifyInterval(1000); - - connect(player_, &QMediaPlayer::stateChanged, this, [this](QMediaPlayer::State state) { - if (state == QMediaPlayer::StoppedState) { - state_ = AudioState::Play; - player_->setMedia(QUrl(url_)); - update(); - } - }); - - setFixedHeight(Height); -} - -AudioItem::AudioItem(const mtx::events::RoomEvent &event, QWidget *parent) - : QWidget(parent) - , url_{QUrl(QString::fromStdString(event.content.url))} - , text_{QString::fromStdString(event.content.body)} - , event_{event} -{ - readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); - - init(); -} - -AudioItem::AudioItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) - : QWidget(parent) - , url_{url} - , text_{filename} -{ - readableFileSize_ = utils::humanReadableFileSize(size); - - init(); -} - -QSize -AudioItem::sizeHint() const -{ - return QSize(MaxWidth, Height); -} - -void -AudioItem::mousePressEvent(QMouseEvent *event) -{ - if (event->button() != Qt::LeftButton) - return; - - auto point = event->pos(); - - // Click on the download icon. - if (QRect(HorizontalPadding, VerticalPadding / 2, IconDiameter, IconDiameter) - .contains(point)) { - if (state_ == AudioState::Play) { - state_ = AudioState::Pause; - player_->play(); - } else { - state_ = AudioState::Play; - player_->pause(); - } - - update(); - } else { - filenameToSave_ = QFileDialog::getSaveFileName(this, tr("Save File"), text_); - - if (filenameToSave_.isEmpty()) - return; - - auto proxy = std::make_shared(); - connect(proxy.get(), &MediaProxy::fileDownloaded, this, &AudioItem::fileDownloaded); - - http::client()->download( - url_.toString().toStdString(), - [proxy = std::move(proxy), url = url_](const std::string &data, - const std::string &, - const std::string &, - mtx::http::RequestErr err) { - if (err) { - nhlog::net()->info("failed to retrieve m.audio content: {}", - url.toString().toStdString()); - return; - } - - emit proxy->fileDownloaded(QByteArray(data.data(), data.size())); - }); - } -} - -void -AudioItem::fileDownloaded(const QByteArray &data) -{ - try { - QFile file(filenameToSave_); - - if (!file.open(QIODevice::WriteOnly)) - return; - - file.write(data); - file.close(); - } catch (const std::exception &e) { - nhlog::ui()->warn("error while saving file: {}", e.what()); - } -} - -void -AudioItem::resizeEvent(QResizeEvent *event) -{ - QFont font; - font.setWeight(QFont::Medium); - - QFontMetrics fm(font); -#if QT_VERSION < QT_VERSION_CHECK(5, 11, 0) - const int computedWidth = std::min( - fm.width(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, (double)MaxWidth); -#else - const int computedWidth = - std::min(fm.horizontalAdvance(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, - (double)MaxWidth); -#endif - resize(computedWidth, Height); - - event->accept(); -} - -void -AudioItem::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - QFont font; - font.setWeight(QFont::Medium); - - QFontMetrics fm(font); - - QPainterPath path; - path.addRoundedRect(QRectF(0, 0, width(), height()), 10, 10); - - painter.setPen(Qt::NoPen); - painter.fillPath(path, backgroundColor_); - painter.drawPath(path); - - QPainterPath circle; - circle.addEllipse(QPoint(IconXCenter, IconYCenter), IconRadius, IconRadius); - - painter.setPen(Qt::NoPen); - painter.fillPath(circle, iconColor_); - painter.drawPath(circle); - - QIcon icon_; - if (state_ == AudioState::Play) - icon_ = playIcon_; - else - icon_ = pauseIcon_; - - icon_.paint(&painter, - QRect(IconXCenter - ActionIconRadius / 2, - IconYCenter - ActionIconRadius / 2, - ActionIconRadius, - ActionIconRadius), - Qt::AlignCenter, - QIcon::Normal); - - const int textStartX = HorizontalPadding + 2 * IconRadius + TextPadding; - const int textStartY = VerticalPadding + fm.ascent() / 2; - - // Draw the filename. - QString elidedText = fm.elidedText( - text_, Qt::ElideRight, width() - HorizontalPadding * 2 - TextPadding - 2 * IconRadius); - - painter.setFont(font); - painter.setPen(QPen(textColor_)); - painter.drawText(QPoint(textStartX, textStartY), elidedText); - - // Draw the filesize. - font.setWeight(QFont::Normal); - painter.setFont(font); - painter.setPen(QPen(textColor_)); - painter.drawText(QPoint(textStartX, textStartY + 1.5 * fm.ascent()), readableFileSize_); -} diff --git a/src/timeline/widgets/AudioItem.h b/src/timeline/widgets/AudioItem.h deleted file mode 100644 index c32b7731..00000000 --- a/src/timeline/widgets/AudioItem.h +++ /dev/null @@ -1,104 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include -#include -#include -#include -#include - -#include - -class AudioItem : public QWidget -{ - Q_OBJECT - - Q_PROPERTY(QColor textColor WRITE setTextColor READ textColor) - Q_PROPERTY(QColor iconColor WRITE setIconColor READ iconColor) - Q_PROPERTY(QColor backgroundColor WRITE setBackgroundColor READ backgroundColor) - - Q_PROPERTY(QColor durationBackgroundColor WRITE setDurationBackgroundColor READ - durationBackgroundColor) - Q_PROPERTY(QColor durationForegroundColor WRITE setDurationForegroundColor READ - durationForegroundColor) - -public: - AudioItem(const mtx::events::RoomEvent &event, - QWidget *parent = nullptr); - - AudioItem(const QString &url, - const QString &filename, - uint64_t size, - QWidget *parent = nullptr); - - QSize sizeHint() const override; - - void setTextColor(const QColor &color) { textColor_ = color; } - void setIconColor(const QColor &color) { iconColor_ = color; } - void setBackgroundColor(const QColor &color) { backgroundColor_ = color; } - - void setDurationBackgroundColor(const QColor &color) { durationBgColor_ = color; } - void setDurationForegroundColor(const QColor &color) { durationFgColor_ = color; } - - QColor textColor() const { return textColor_; } - QColor iconColor() const { return iconColor_; } - QColor backgroundColor() const { return backgroundColor_; } - - QColor durationBackgroundColor() const { return durationBgColor_; } - QColor durationForegroundColor() const { return durationFgColor_; } - -protected: - void paintEvent(QPaintEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - -private slots: - void fileDownloaded(const QByteArray &data); - -private: - void init(); - - enum class AudioState - { - Play, - Pause, - }; - - AudioState state_ = AudioState::Play; - - QUrl url_; - QString text_; - QString readableFileSize_; - QString filenameToSave_; - - mtx::events::RoomEvent event_; - - QMediaPlayer *player_; - - QIcon playIcon_; - QIcon pauseIcon_; - - QColor textColor_ = QColor("white"); - QColor iconColor_ = QColor("#38A3D8"); - QColor backgroundColor_ = QColor("#333"); - - QColor durationBgColor_ = QColor("black"); - QColor durationFgColor_ = QColor("blue"); -}; diff --git a/src/timeline/widgets/FileItem.cpp b/src/timeline/widgets/FileItem.cpp deleted file mode 100644 index 1a555d1c..00000000 --- a/src/timeline/widgets/FileItem.cpp +++ /dev/null @@ -1,221 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include -#include -#include -#include -#include -#include - -#include "Logging.h" -#include "MatrixClient.h" -#include "Utils.h" - -#include "timeline/widgets/FileItem.h" - -constexpr int MaxWidth = 400; -constexpr int Height = 70; -constexpr int IconRadius = 22; -constexpr int IconDiameter = IconRadius * 2; -constexpr int HorizontalPadding = 12; -constexpr int TextPadding = 15; -constexpr int DownloadIconRadius = IconRadius - 4; - -constexpr double VerticalPadding = Height - 2 * IconRadius; -constexpr double IconYCenter = Height / 2; -constexpr double IconXCenter = HorizontalPadding + IconRadius; - -void -FileItem::init() -{ - setMouseTracking(true); - setCursor(Qt::PointingHandCursor); - setAttribute(Qt::WA_Hover, true); - - icon_.addFile(":/icons/icons/ui/arrow-pointing-down.png"); - - setFixedHeight(Height); -} - -FileItem::FileItem(const mtx::events::RoomEvent &event, QWidget *parent) - : QWidget(parent) - , url_{QString::fromStdString(event.content.url)} - , text_{QString::fromStdString(event.content.body)} - , event_{event} -{ - readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); - - init(); -} - -FileItem::FileItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) - : QWidget(parent) - , url_{url} - , text_{filename} -{ - readableFileSize_ = utils::humanReadableFileSize(size); - - init(); -} - -void -FileItem::openUrl() -{ - if (url_.toString().isEmpty()) - return; - - auto urlToOpen = utils::mxcToHttp( - url_, QString::fromStdString(http::client()->server()), http::client()->port()); - - if (!QDesktopServices::openUrl(urlToOpen)) - nhlog::ui()->warn("Could not open url: {}", urlToOpen.toStdString()); -} - -QSize -FileItem::sizeHint() const -{ - return QSize(MaxWidth, Height); -} - -void -FileItem::mousePressEvent(QMouseEvent *event) -{ - if (event->button() != Qt::LeftButton) - return; - - auto point = event->pos(); - - // Click on the download icon. - if (QRect(HorizontalPadding, VerticalPadding / 2, IconDiameter, IconDiameter) - .contains(point)) { - filenameToSave_ = QFileDialog::getSaveFileName(this, tr("Save File"), text_); - - if (filenameToSave_.isEmpty()) - return; - - auto proxy = std::make_shared(); - connect(proxy.get(), &MediaProxy::fileDownloaded, this, &FileItem::fileDownloaded); - - http::client()->download( - url_.toString().toStdString(), - [proxy = std::move(proxy), url = url_](const std::string &data, - const std::string &, - const std::string &, - mtx::http::RequestErr err) { - if (err) { - nhlog::ui()->warn("failed to retrieve m.file content: {}", - url.toString().toStdString()); - return; - } - - emit proxy->fileDownloaded(QByteArray(data.data(), data.size())); - }); - } else { - openUrl(); - } -} - -void -FileItem::fileDownloaded(const QByteArray &data) -{ - try { - QFile file(filenameToSave_); - - if (!file.open(QIODevice::WriteOnly)) - return; - - file.write(data); - file.close(); - } catch (const std::exception &e) { - nhlog::ui()->warn("Error while saving file to: {}", e.what()); - } -} - -void -FileItem::resizeEvent(QResizeEvent *event) -{ - QFont font; - font.setWeight(QFont::Medium); - - QFontMetrics fm(font); -#if QT_VERSION < QT_VERSION_CHECK(5, 11, 0) - const int computedWidth = std::min( - fm.width(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, (double)MaxWidth); -#else - const int computedWidth = - std::min(fm.horizontalAdvance(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, - (double)MaxWidth); -#endif - resize(computedWidth, Height); - - event->accept(); -} - -void -FileItem::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - QFont font; - font.setWeight(QFont::Medium); - - QFontMetrics fm(font); - - QPainterPath path; - path.addRoundedRect(QRectF(0, 0, width(), height()), 10, 10); - - painter.setPen(Qt::NoPen); - painter.fillPath(path, backgroundColor_); - painter.drawPath(path); - - QPainterPath circle; - circle.addEllipse(QPoint(IconXCenter, IconYCenter), IconRadius, IconRadius); - - painter.setPen(Qt::NoPen); - painter.fillPath(circle, iconColor_); - painter.drawPath(circle); - - icon_.paint(&painter, - QRect(IconXCenter - DownloadIconRadius / 2, - IconYCenter - DownloadIconRadius / 2, - DownloadIconRadius, - DownloadIconRadius), - Qt::AlignCenter, - QIcon::Normal); - - const int textStartX = HorizontalPadding + 2 * IconRadius + TextPadding; - const int textStartY = VerticalPadding + fm.ascent() / 2; - - // Draw the filename. - QString elidedText = fm.elidedText( - text_, Qt::ElideRight, width() - HorizontalPadding * 2 - TextPadding - 2 * IconRadius); - - painter.setFont(font); - painter.setPen(QPen(textColor_)); - painter.drawText(QPoint(textStartX, textStartY), elidedText); - - // Draw the filesize. - font.setWeight(QFont::Normal); - painter.setFont(font); - painter.setPen(QPen(textColor_)); - painter.drawText(QPoint(textStartX, textStartY + 1.5 * fm.ascent()), readableFileSize_); -} diff --git a/src/timeline/widgets/FileItem.h b/src/timeline/widgets/FileItem.h deleted file mode 100644 index d63cce88..00000000 --- a/src/timeline/widgets/FileItem.h +++ /dev/null @@ -1,79 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include -#include -#include -#include - -#include - -class FileItem : public QWidget -{ - Q_OBJECT - - Q_PROPERTY(QColor textColor WRITE setTextColor READ textColor) - Q_PROPERTY(QColor iconColor WRITE setIconColor READ iconColor) - Q_PROPERTY(QColor backgroundColor WRITE setBackgroundColor READ backgroundColor) - -public: - FileItem(const mtx::events::RoomEvent &event, - QWidget *parent = nullptr); - - FileItem(const QString &url, - const QString &filename, - uint64_t size, - QWidget *parent = nullptr); - - QSize sizeHint() const override; - - void setTextColor(const QColor &color) { textColor_ = color; } - void setIconColor(const QColor &color) { iconColor_ = color; } - void setBackgroundColor(const QColor &color) { backgroundColor_ = color; } - - QColor textColor() const { return textColor_; } - QColor iconColor() const { return iconColor_; } - QColor backgroundColor() const { return backgroundColor_; } - -protected: - void paintEvent(QPaintEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - -private slots: - void fileDownloaded(const QByteArray &data); - -private: - void openUrl(); - void init(); - - QUrl url_; - QString text_; - QString readableFileSize_; - QString filenameToSave_; - - mtx::events::RoomEvent event_; - - QIcon icon_; - - QColor textColor_ = QColor("white"); - QColor iconColor_ = QColor("#38A3D8"); - QColor backgroundColor_ = QColor("#333"); -}; diff --git a/src/timeline/widgets/ImageItem.cpp b/src/timeline/widgets/ImageItem.cpp deleted file mode 100644 index 26c569d7..00000000 --- a/src/timeline/widgets/ImageItem.cpp +++ /dev/null @@ -1,267 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "Config.h" -#include "ImageItem.h" -#include "Logging.h" -#include "MatrixClient.h" -#include "Utils.h" -#include "dialogs/ImageOverlay.h" - -void -ImageItem::downloadMedia(const QUrl &url) -{ - auto proxy = std::make_shared(); - connect(proxy.get(), &MediaProxy::imageDownloaded, this, &ImageItem::setImage); - - http::client()->download(url.toString().toStdString(), - [proxy = std::move(proxy), url](const std::string &data, - const std::string &, - const std::string &, - mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn( - "failed to retrieve image {}: {} {}", - url.toString().toStdString(), - err->matrix_error.error, - static_cast(err->status_code)); - return; - } - - QPixmap img; - img.loadFromData(QByteArray(data.data(), data.size())); - - emit proxy->imageDownloaded(img); - }); -} - -void -ImageItem::saveImage(const QString &filename, const QByteArray &data) -{ - try { - QFile file(filename); - - if (!file.open(QIODevice::WriteOnly)) - return; - - file.write(data); - file.close(); - } catch (const std::exception &e) { - nhlog::ui()->warn("Error while saving file to: {}", e.what()); - } -} - -void -ImageItem::init() -{ - setMouseTracking(true); - setCursor(Qt::PointingHandCursor); - setAttribute(Qt::WA_Hover, true); - - downloadMedia(url_); -} - -ImageItem::ImageItem(const mtx::events::RoomEvent &event, QWidget *parent) - : QWidget(parent) - , event_{event} -{ - url_ = QString::fromStdString(event.content.url); - text_ = QString::fromStdString(event.content.body); - - init(); -} - -ImageItem::ImageItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) - : QWidget(parent) - , url_{url} - , text_{filename} -{ - Q_UNUSED(size); - init(); -} - -void -ImageItem::openUrl() -{ - if (url_.toString().isEmpty()) - return; - - auto urlToOpen = utils::mxcToHttp( - url_, QString::fromStdString(http::client()->server()), http::client()->port()); - - if (!QDesktopServices::openUrl(urlToOpen)) - nhlog::ui()->warn("could not open url: {}", urlToOpen.toStdString()); -} - -QSize -ImageItem::sizeHint() const -{ - if (image_.isNull()) - return QSize(max_width_, bottom_height_); - - return QSize(width_, height_); -} - -void -ImageItem::setImage(const QPixmap &image) -{ - image_ = image; - scaled_image_ = utils::scaleDown(max_width_, max_height_, image_); - - width_ = scaled_image_.width(); - height_ = scaled_image_.height(); - - setFixedSize(width_, height_); - update(); -} - -void -ImageItem::mousePressEvent(QMouseEvent *event) -{ - if (!isInteractive_) { - event->accept(); - return; - } - - if (event->button() != Qt::LeftButton) - return; - - if (image_.isNull()) { - openUrl(); - return; - } - - if (textRegion_.contains(event->pos())) { - openUrl(); - } else { - auto imgDialog = new dialogs::ImageOverlay(image_); - imgDialog->show(); - connect(imgDialog, &dialogs::ImageOverlay::saving, this, &ImageItem::saveAs); - } -} - -void -ImageItem::resizeEvent(QResizeEvent *event) -{ - if (!image_) - return QWidget::resizeEvent(event); - - scaled_image_ = utils::scaleDown(max_width_, max_height_, image_); - - width_ = scaled_image_.width(); - height_ = scaled_image_.height(); - - setFixedSize(width_, height_); -} - -void -ImageItem::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - QFont font; - - QFontMetrics metrics(font); - const int fontHeight = metrics.height() + metrics.ascent(); - - if (image_.isNull()) { - QString elidedText = metrics.elidedText(text_, Qt::ElideRight, max_width_ - 10); -#if QT_VERSION < QT_VERSION_CHECK(5, 11, 0) - setFixedSize(metrics.width(elidedText), fontHeight); -#else - setFixedSize(metrics.horizontalAdvance(elidedText), fontHeight); -#endif - painter.setFont(font); - painter.setPen(QPen(QColor(66, 133, 244))); - painter.drawText(QPoint(0, fontHeight / 2), elidedText); - - return; - } - - imageRegion_ = QRectF(0, 0, width_, height_); - - QPainterPath path; - path.addRoundedRect(imageRegion_, 5, 5); - - painter.setPen(Qt::NoPen); - painter.fillPath(path, scaled_image_); - painter.drawPath(path); - - // Bottom text section - if (isInteractive_ && underMouse()) { - const int textBoxHeight = fontHeight / 2 + 6; - - textRegion_ = QRectF(0, height_ - textBoxHeight, width_, textBoxHeight); - - QPainterPath textPath; - textPath.addRoundedRect(textRegion_, 0, 0); - - painter.fillPath(textPath, QColor(40, 40, 40, 140)); - - QString elidedText = metrics.elidedText(text_, Qt::ElideRight, width_ - 10); - - font.setWeight(QFont::Medium); - painter.setFont(font); - painter.setPen(QPen(QColor(Qt::white))); - - textRegion_.adjust(5, 0, 5, 0); - painter.drawText(textRegion_, Qt::AlignVCenter, elidedText); - } -} - -void -ImageItem::saveAs() -{ - auto filename = QFileDialog::getSaveFileName(this, tr("Save image"), text_); - - if (filename.isEmpty()) - return; - - const auto url = url_.toString().toStdString(); - - auto proxy = std::make_shared(); - connect(proxy.get(), &MediaProxy::imageSaved, this, &ImageItem::saveImage); - - http::client()->download( - url, - [proxy = std::move(proxy), filename, url](const std::string &data, - const std::string &, - const std::string &, - mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn("failed to retrieve image {}: {} {}", - url, - err->matrix_error.error, - static_cast(err->status_code)); - return; - } - - emit proxy->imageSaved(filename, QByteArray(data.data(), data.size())); - }); -} diff --git a/src/timeline/widgets/ImageItem.h b/src/timeline/widgets/ImageItem.h deleted file mode 100644 index 65bd962d..00000000 --- a/src/timeline/widgets/ImageItem.h +++ /dev/null @@ -1,104 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include -#include -#include - -#include - -namespace dialogs { -class ImageOverlay; -} - -class ImageItem : public QWidget -{ - Q_OBJECT -public: - ImageItem(const mtx::events::RoomEvent &event, - QWidget *parent = nullptr); - - ImageItem(const QString &url, - const QString &filename, - uint64_t size, - QWidget *parent = nullptr); - - QSize sizeHint() const override; - -public slots: - //! Show a save as dialog for the image. - void saveAs(); - void setImage(const QPixmap &image); - void saveImage(const QString &filename, const QByteArray &data); - -protected: - void paintEvent(QPaintEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - - //! Whether the user can interact with the displayed image. - bool isInteractive_ = true; - -private: - void init(); - void openUrl(); - void downloadMedia(const QUrl &url); - - int max_width_ = 500; - int max_height_ = 300; - - int width_; - int height_; - - QPixmap scaled_image_; - QPixmap image_; - - QUrl url_; - QString text_; - - int bottom_height_ = 30; - - QRectF textRegion_; - QRectF imageRegion_; - - mtx::events::RoomEvent event_; -}; - -class StickerItem : public ImageItem -{ - Q_OBJECT - -public: - StickerItem(const mtx::events::Sticker &event, QWidget *parent = nullptr) - : ImageItem{QString::fromStdString(event.content.url), - QString::fromStdString(event.content.body), - event.content.info.size, - parent} - , event_{event} - { - isInteractive_ = false; - setCursor(Qt::ArrowCursor); - setMouseTracking(false); - setAttribute(Qt::WA_Hover, false); - } - -private: - mtx::events::Sticker event_; -}; diff --git a/src/timeline/widgets/VideoItem.cpp b/src/timeline/widgets/VideoItem.cpp deleted file mode 100644 index 4b5dc022..00000000 --- a/src/timeline/widgets/VideoItem.cpp +++ /dev/null @@ -1,65 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include - -#include "Config.h" -#include "MatrixClient.h" -#include "Utils.h" -#include "timeline/widgets/VideoItem.h" - -void -VideoItem::init() -{ - url_ = utils::mxcToHttp( - url_, QString::fromStdString(http::client()->server()), http::client()->port()); -} - -VideoItem::VideoItem(const mtx::events::RoomEvent &event, QWidget *parent) - : QWidget(parent) - , url_{QString::fromStdString(event.content.url)} - , text_{QString::fromStdString(event.content.body)} - , event_{event} -{ - readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); - - init(); - - auto layout = new QVBoxLayout(this); - layout->setMargin(0); - layout->setSpacing(0); - - QString link = QString("%2").arg(url_.toString()).arg(text_); - - label_ = new QLabel(link, this); - label_->setMargin(0); - label_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); - label_->setOpenExternalLinks(true); - - layout->addWidget(label_); -} - -VideoItem::VideoItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) - : QWidget(parent) - , url_{url} - , text_{filename} -{ - readableFileSize_ = utils::humanReadableFileSize(size); - - init(); -} diff --git a/src/timeline/widgets/VideoItem.h b/src/timeline/widgets/VideoItem.h deleted file mode 100644 index 26fa1c35..00000000 --- a/src/timeline/widgets/VideoItem.h +++ /dev/null @@ -1,51 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include -#include -#include -#include - -#include - -class VideoItem : public QWidget -{ - Q_OBJECT - -public: - VideoItem(const mtx::events::RoomEvent &event, - QWidget *parent = nullptr); - - VideoItem(const QString &url, - const QString &filename, - uint64_t size, - QWidget *parent = nullptr); - -private: - void init(); - - QUrl url_; - QString text_; - QString readableFileSize_; - - QLabel *label_; - - mtx::events::RoomEvent event_; -}; diff --git a/src/ui/Avatar.cpp b/src/ui/Avatar.cpp index 501a8968..e4a90f81 100644 --- a/src/ui/Avatar.cpp +++ b/src/ui/Avatar.cpp @@ -101,7 +101,7 @@ Avatar::setIcon(const QIcon &icon) void Avatar::paintEvent(QPaintEvent *) { - bool rounded = QSettings().value("user/avatar/circles", true).toBool(); + bool rounded = QSettings().value("user/avatar_circles", true).toBool(); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing);